コード例 #1
0
def _get_feedback_for_recording(recording_type, recording):
    score = _parse_int_arg('score')

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

    count = min(count, MAX_ITEMS_PER_GET)

    if score:
        if score not in [-1, 1]:
            log_raise_400("Score can have a value of 1 or -1.", request.args)

    feedback = db_feedback.get_feedback_for_recording(recording_type,
                                                      recording,
                                                      limit=count,
                                                      offset=offset,
                                                      score=score)
    total_count = db_feedback.get_feedback_count_for_recording(
        recording_type, recording)

    feedback = [fb.to_api() for fb in feedback]

    return jsonify({
        "feedback": feedback,
        "count": len(feedback),
        "total_count": total_count,
        "offset": offset
    })
コード例 #2
0
def get_playlist(playlist_mbid):
    """
    Fetch the given playlist.

    :param playlist_mbid: The playlist mbid to fetch.
    :type playlist_mbid: ``str``
    :param fetch_metadata: Optional, pass value 'false' to skip lookup up recording metadata
    :type fetch_metadata: ``bool``
    :statuscode 200: Yay, you have data!
    :statuscode 404: Playlist not found
    :statuscode 401: Invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """

    if not is_valid_uuid(playlist_mbid):
        log_raise_400("Provided playlist ID is invalid.")

    fetch_metadata = parse_boolean_arg("fetch_metadata", True)

    playlist = db_playlist.get_by_mbid(playlist_mbid, True)
    if playlist is None:
        raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    user = validate_auth_header(optional=True)
    user_id = None
    if user:
        user_id = user["id"]
    if not playlist.is_visible_by(user_id):
        raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    if fetch_metadata:
        fetch_playlist_recording_metadata(playlist)

    return jsonify(serialize_jspf(playlist))
コード例 #3
0
def copy_playlist(playlist_mbid):
    """

    Copy a playlist -- the new playlist will be given the name "Copy of <playlist_name>".
    POST body data does not need to contain anything.

    :reqheader Authorization: Token <user token>
    :statuscode 200: playlist copied.
    :statuscode 401: invalid authorization. See error message for details.
    :statuscode 404: Playlist not found
    :resheader Content-Type: *application/json*
    """

    user = validate_auth_header()

    if not is_valid_uuid(playlist_mbid):
        log_raise_400("Provided playlist ID is invalid.")

    playlist = db_playlist.get_by_mbid(playlist_mbid)
    if playlist is None or not playlist.is_visible_by(user["id"]):
        raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    try:
        new_playlist = db_playlist.copy_playlist(playlist, user["id"])
    except Exception as e:
        current_app.logger.error("Error copying playlist: {}".format(e))
        raise APIInternalServerError("Failed to copy the playlist. Please try again.")

    return jsonify({'status': 'ok', 'playlist_mbid': new_playlist.mbid})
コード例 #4
0
def delete_playlist(playlist_mbid):
    """

    Delete a playlist. POST body data does not need to contain anything.

    :reqheader Authorization: Token <user token>
    :statuscode 200: playlist deleted.
    :statuscode 401: invalid authorization. See error message for details.
    :statuscode 403: forbidden. the requesting user was not allowed to carry out this operation.
    :statuscode 404: Playlist not found
    :resheader Content-Type: *application/json*
    """

    user = validate_auth_header()

    if not is_valid_uuid(playlist_mbid):
        log_raise_400("Provided playlist ID is invalid.")

    playlist = db_playlist.get_by_mbid(playlist_mbid)
    if playlist is None or not playlist.is_visible_by(user["id"]):
        raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    if playlist.creator_id != user["id"]:
        raise APIForbidden("You are not allowed to delete this playlist.")

    try:
        db_playlist.delete_playlist(playlist)
    except Exception as e:
        current_app.logger.error("Error deleting playlist: {}".format(e))
        raise APIInternalServerError("Failed to delete the playlist. Please try again.")

    return jsonify({'status': 'ok'})
コード例 #5
0
def get_playlist(playlist_mbid):
    """
    Fetch the given playlist.

    :param playlist_mbid: Optional, The playlist mbid to fetch.
    :statuscode 200: Yay, you have data!
    :statuscode 404: Playlist not found
    :statuscode 401: Invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """

    if not is_valid_uuid(playlist_mbid):
        log_raise_400("Provided playlist ID is invalid.")

    playlist = db_playlist.get_by_mbid(playlist_mbid, True)
    if playlist is None:
        raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    if not playlist.public:
        user = validate_auth_header()
        if playlist.creator_id != user["id"]:
            raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    fetch_playlist_recording_metadata(playlist)

    return jsonify(serialize_jspf(playlist))
コード例 #6
0
def pin_recording_for_user():
    """
    Pin a recording for user. A user token (found on  https://listenbrainz.org/profile/)
    must be provided in the Authorization header! Each request should contain only one pinned recording item in the payload.

    The format of the JSON to be POSTed to this endpoint should look like the following:

    .. code-block:: json

        {
            "recording_msid": "40ef0ae1-5626-43eb-838f-1b34187519bf",
            "recording_mbid": "<this field is optional>",
            "blurb_content": "Wow..",
            "pinned_until": 1824001816
        }

    :reqheader Authorization: Token <user token>
    :statuscode 200: feedback accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header()

    data = request.json

    if "recording_msid" not in data and "recording_mbid" not in data:
        log_raise_400(
            "JSON document must contain either recording_msid or recording_mbid",
            data)

    try:
        recording_to_pin = WritablePinnedRecording(
            user_id=user["id"],
            recording_msid=data["recording_msid"]
            if "recording_msid" in data else None,
            recording_mbid=data["recording_mbid"]
            if "recording_mbid" in data else None,
            blurb_content=data["blurb_content"]
            if "blurb_content" in data else None,
            pinned_until=data["pinned_until"]
            if "pinned_until" in data else None,
        )
    except ValidationError as e:
        log_raise_400(
            "Invalid JSON document submitted: %s" %
            str(e).replace("\n ", ":").replace("\n", " "), data)

    try:
        recording_to_pin_with_id = db_pinned_rec.pin(recording_to_pin)
    except Exception as e:
        current_app.logger.error(
            "Error while inserting pinned track record: {}".format(e))
        raise APIInternalServerError("Something went wrong. Please try again.")

    return jsonify({
        "pinned_recording":
        _pinned_recording_to_api(recording_to_pin_with_id)
    })
コード例 #7
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!
    :resheader Content-Type: *application/json*
    """

    current_time = int(time.time())
    max_ts = _parse_int_arg("max_ts")
    min_ts = _parse_int_arg("min_ts")

    # if no max given, use now()

    if max_ts and min_ts:
        log_raise_400("You may only specify max_ts or min_ts, not both.")

    # If none are given, start with now and go down
    if max_ts == None and min_ts == None:
        max_ts = current_time

    db_conn = webserver.create_influx(current_app)
    listens = db_conn.fetch_listens(
        user_name,
        limit=min(_parse_int_arg("count", DEFAULT_ITEMS_PER_GET),
                  MAX_ITEMS_PER_GET),
        from_ts=min_ts,
        to_ts=max_ts,
    )
    listen_data = []
    for listen in listens:
        listen_data.append(listen.to_api())

    latest_listen = db_conn.fetch_listens(
        user_name,
        limit=1,
        to_ts=current_time,
    )
    latest_listen_ts = latest_listen[0].ts_since_epoch if len(
        latest_listen) > 0 else 0

    if min_ts:
        listen_data = listen_data[::-1]

    return jsonify({
        'payload': {
            'user_id': user_name,
            'count': len(listen_data),
            'listens': listen_data,
            'latest_listen_ts': latest_listen_ts,
        }
    })
コード例 #8
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
    })
コード例 #9
0
def submit_listen():
    """
    Submit listens to the server. A user token (found on https://listenbrainz.org/user/import ) must
    be provided in the Authorization header!

    For complete details on the format of the JSON to be POSTed to this endpoint, see :ref:`json-doc`.

    :reqheader Authorization: Token <user token>
    :statuscode 200: listen(s) accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = _validate_auth_header()

    raw_data = request.get_data()
    try:
        data = ujson.loads(raw_data.decode("utf-8"))
    except ValueError as e:
        log_raise_400("Cannot parse JSON document: %s" % e, raw_data)

    try:
        payload = data['payload']
        if len(payload) == 0:
            return "success"

        if len(raw_data) > len(payload) * MAX_LISTEN_SIZE:
            log_raise_400(
                "JSON document is too large. In aggregate, listens may not "
                "be larger than %d characters." % MAX_LISTEN_SIZE, payload)

        if data['listen_type'] not in ('playing_now', 'single', 'import'):
            log_raise_400("JSON document requires a valid listen_type key.",
                          payload)

        listen_type = _get_listen_type(data['listen_type'])
        if (listen_type == LISTEN_TYPE_SINGLE or listen_type
                == LISTEN_TYPE_PLAYING_NOW) and len(payload) > 1:
            log_raise_400(
                "JSON document contains more than listen for a single/playing_now. "
                "It should contain only one.", payload)
    except KeyError:
        log_raise_400("Invalid JSON document submitted.", raw_data)

    # validate listens to make sure json is okay
    for listen in payload:
        validate_listen(listen, listen_type)

    try:
        insert_payload(payload,
                       user,
                       listen_type=_get_listen_type(data['listen_type']))
    except ServiceUnavailable as e:
        raise
    except Exception as e:
        raise InternalServerError("Something went wrong. Please try again.")

    return "success"
コード例 #10
0
ファイル: api.py プロジェクト: Uditgulati/listenbrainz-server
def submit_listen():
    """
    Submit listens to the server. A user token (found on  https://listenbrainz.org/profile/ ) must
    be provided in the Authorization header!

    Listens should be submitted for tracks when the user has listened to half the track or 4 minutes of
    the track, whichever is lower. If the user hasn't listened to 4 minutes or half the track, it doesn't 
    fully count as a listen and should not be submitted.

    For complete details on the format of the JSON to be POSTed to this endpoint, see :ref:`json-doc`.

    :reqheader Authorization: Token <user token>
    :statuscode 200: listen(s) accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = _validate_auth_header()

    raw_data = request.get_data()
    try:
        data = ujson.loads(raw_data.decode("utf-8"))
    except ValueError as e:
        log_raise_400("Cannot parse JSON document: %s" % e, raw_data)

    try:
        payload = data['payload']
        if len(payload) == 0:
            return "success"

        if len(raw_data) > len(payload) * MAX_LISTEN_SIZE:
            log_raise_400("JSON document is too large. In aggregate, listens may not "
                           "be larger than %d characters." % MAX_LISTEN_SIZE, payload)

        if data['listen_type'] not in ('playing_now', 'single', 'import'):
            log_raise_400("JSON document requires a valid listen_type key.", payload)

        listen_type = _get_listen_type(data['listen_type'])
        if (listen_type == LISTEN_TYPE_SINGLE or listen_type == LISTEN_TYPE_PLAYING_NOW) and len(payload) > 1:
            log_raise_400("JSON document contains more than listen for a single/playing_now. "
                           "It should contain only one.", payload)
    except KeyError:
        log_raise_400("Invalid JSON document submitted.", raw_data)

    # validate listens to make sure json is okay
    for listen in payload:
        validate_listen(listen, listen_type)

    try:
        insert_payload(payload, user, listen_type=_get_listen_type(data['listen_type']))
    except ServiceUnavailable as e:
        raise
    except Exception as e:
        raise InternalServerError("Something went wrong. Please try again.")

    return jsonify({'status': 'ok'})
コード例 #11
0
def get_feedback_for_user(user_name):
    """
    Get feedback given by user ``user_name``. The format for the JSON returned is defined in our :ref:`feedback-json-doc`.

    If the optional argument ``score`` is not given, this endpoint will return all the feedback submitted by the user.
    Otherwise filters the feedback to be returned by score.

    :param score: Optional, If 1 then returns the loved recordings, if -1 returns hated recordings.
    :type score: ``int``
    :param count: Optional, number of feedback items 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 feedback items to skip from the beginning, for pagination.
        Ex. An offset of 5 means the top 5 feedback will be skipped, defaults to 0.
    :type offset: ``int``
    :param metadata: Optional, 'true' or 'false' if this call should return the metadata for the feedback.
    :type metadata: ``str``
    :statuscode 200: Yay, you have data!
    :resheader Content-Type: *application/json*
    """

    score = _parse_int_arg('score')
    metadata = parse_boolean_arg('metadata')

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

    count = min(count, MAX_ITEMS_PER_GET)

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

    if score:
        if score not in [-1, 1]:
            log_raise_400("Score can have a value of 1 or -1.", request.args)

    feedback = db_feedback.get_feedback_for_user(user_id=user["id"],
                                                 limit=count,
                                                 offset=offset,
                                                 score=score,
                                                 metadata=metadata)
    total_count = db_feedback.get_feedback_count_for_user(user["id"])

    feedback = [fb.to_api() for fb in feedback]

    return jsonify({
        "feedback": feedback,
        "count": len(feedback),
        "total_count": total_count,
        "offset": offset
    })
コード例 #12
0
def move_playlist_item(playlist_mbid):
    """

    To move an item in a playlist, the POST data needs to specify the recording MBID and current index
    of the track to move (from), where to move it to (to) and how many tracks from that position should
    be moved (count). The format of the post data should look as follows:

    .. code-block:: json

        {
            "mbid": "<mbid>",
            "from": 3,
            "to": 4,
            "count": 2
        }

    :reqheader Authorization: Token <user token>
    :statuscode 200: move operation succeeded
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :statuscode 403: forbidden. the requesting user was not allowed to carry out this operation.
    :resheader Content-Type: *application/json*
    """

    user = validate_auth_header()

    if not is_valid_uuid(playlist_mbid):
        log_raise_400("Provided playlist ID is invalid.")

    playlist = db_playlist.get_by_mbid(playlist_mbid)
    if playlist is None or not playlist.is_visible_by(user["id"]):
        raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    if not playlist.is_modifiable_by(user["id"]):
        raise APIForbidden(
            "You are not allowed to move recordings in this playlist.")

    data = request.json
    validate_move_data(data)

    try:
        db_playlist.move_recordings(playlist, data['from'], data['to'],
                                    data['count'])
    except Exception as e:
        current_app.logger.error(
            "Error while moving recordings in the playlist: {}".format(e))
        raise APIInternalServerError(
            "Failed to move recordings in the playlist. Please try again.")

    return jsonify({'status': 'ok'})
コード例 #13
0
def _validate_get_endpoint_params(db_conn: TimescaleListenStore,
                                  user_name: str) -> Tuple[int, int, int, int]:
    """ Validates parameters for listen GET endpoints like /username/listens and /username/feed/events

    Returns a tuple of integers: (min_ts, max_ts, count, time_range)
    """
    max_ts = _parse_int_arg("max_ts")
    min_ts = _parse_int_arg("min_ts")
    time_range = _parse_int_arg("time_range", DEFAULT_TIME_RANGE)

    if time_range < 1 or time_range > MAX_TIME_RANGE:
        log_raise_400("time_range must be between 1 and %d." % MAX_TIME_RANGE)

    if max_ts and min_ts:
        if max_ts < min_ts:
            log_raise_400("max_ts should be greater than min_ts")

        if (max_ts - min_ts) > MAX_TIME_RANGE * SECONDS_IN_TIME_RANGE:
            log_raise_400(
                "time_range specified by min_ts and max_ts should be less than %d days."
                % MAX_TIME_RANGE * 5)

    # Validate requetsed listen count is positive
    count = min(_parse_int_arg("count", DEFAULT_ITEMS_PER_GET),
                MAX_ITEMS_PER_GET)
    if count < 0:
        log_raise_400("Number of items requested should be positive")

    return min_ts, max_ts, count, time_range
コード例 #14
0
ファイル: api.py プロジェクト: amCap1712/listenbrainz-server
def delete_listen():
    """
    Delete a particular listen from a user's listen history.
    This checks for the correct authorization token and deletes the listen.

    .. note::

        The listen is not deleted immediately, but is scheduled for deletion, which
        usually happens shortly after the hour.

    The format of the JSON to be POSTed to this endpoint is:

    .. code-block:: json

        {
            "listened_at": 1,
            "recording_msid": "d23f4719-9212-49f0-ad08-ddbfbfc50d6f"
        }

    :reqheader Authorization: Token <user token>
    :reqheader Content-Type: *application/json*
    :statuscode 200: listen deleted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header()

    data = request.json

    if "listened_at" not in data:
        log_raise_400("Listen timestamp missing.")
    try:
        listened_at = data["listened_at"]
        listened_at = int(listened_at)
    except ValueError:
        log_raise_400("%s: Listen timestamp invalid." % listened_at)

    if "recording_msid" not in data:
        log_raise_400("Recording MSID missing.")

    recording_msid = data["recording_msid"]
    if not is_valid_uuid(recording_msid):
        log_raise_400("%s: Recording MSID format invalid." % recording_msid)

    try:
        timescale_connection._ts.delete_listen(listened_at=listened_at,
                                               recording_msid=recording_msid, user_id=user["id"])
    except TimescaleListenStoreException as e:
        current_app.logger.error("Cannot delete listen for user: %s" % str(e))
        raise APIServiceUnavailable(
            "We couldn't delete the listen. Please try again later.")
    except Exception as e:
        current_app.logger.error("Cannot delete listen for user: %s" % str(e))
        raise APIInternalServerError(
            "We couldn't delete the listen. Please try again later.")

    return jsonify({'status': 'ok'})
コード例 #15
0
def validate_delete_data(data):
    """
        Check that the passed JSON for a delete recordings endpoint call are valid. Raise 400 with
        error message if not.
    """

    if "index" not in data or "count" not in data:
        log_raise_400("post data for a remove instruction must include the keys 'index' and 'count'.")

    try:
        index_value = int(data["index"])
        count_value = int(data["count"])
        if index_value < 0 or count_value < 0:
            raise ValueError
    except ValueError:
        log_raise_400("move instruction values for 'index' and 'count' must all be positive integers.")
コード例 #16
0
def save_list():
    creator = _validate_auth_header()
    raw_data = request.get_data()
    try:
        data = ujson.loads(raw_data.decode("utf-8"))
    except ValueError as e:
        log_raise_400("Cannot parse JSON document: %s" % str(e), raw_data)

    try:
        list_name = data['name']
        list_id = data['id']
        members = data['users']
    except KeyError as e:
        log_raise_400("JSON missing key: %s" % str(e))

    members = db_user.validate_usernames(members)
    if list_id is None:
        # create a new list
        try:
            list_id = db_follow_list.save(
                name=list_name,
                creator=creator['id'],
                members=[member['id'] for member in members],
            )
        except DatabaseException as e:
            raise APIForbidden("List with same name already exists.")
    else:

        # do some validation
        current_list = db_follow_list.get(list_id)
        if current_list is None:
            raise APINotFound("List not found: %d" % list_id)
        if current_list['creator'] != creator['id']:
            raise APIUnauthorized("You can only edit your own lists.")

        # update the old list
        db_follow_list.update(
            list_id=list_id,
            name=list_name,
            members=[member['id'] for member in members],
        )

    return jsonify({
        "code": 200,
        "message": "it worked!",
        "list_id": list_id,
    })
コード例 #17
0
def get_feedback_for_recording(recording_msid):
    """
    Get feedback for recording with given ``recording_msid``. The format for the JSON returned
    is defined in our :ref:`feedback-json-doc`.

    :param score: Optional, If 1 then returns the loved recordings, if -1 returns hated recordings.
    :type score: ``int``
    :param count: Optional, number of feedback items 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 feedback items to skip from the beginning, for pagination.
        Ex. An offset of 5 means the top 5 feedback will be skipped, defaults to 0.
    :type offset: ``int``
    :statuscode 200: Yay, you have data!
    :resheader Content-Type: *application/json*
    """

    if not is_valid_uuid(recording_msid):
        log_raise_400("%s MSID format invalid." % recording_msid)

    score = _parse_int_arg('score')

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

    count = min(count, MAX_ITEMS_PER_GET)

    if score:
        if score not in [-1, 1]:
            log_raise_400("Score can have a value of 1 or -1.", request.args)

    feedback = db_feedback.get_feedback_for_recording(
        recording_msid=recording_msid, limit=count, offset=offset, score=score)
    total_count = db_feedback.get_feedback_count_for_recording(recording_msid)

    for i, fb in enumerate(feedback):
        fb.user_id = fb.user_name
        del fb.user_name
        feedback[i] = fb.dict()

    return jsonify({
        "feedback": feedback,
        "count": len(feedback),
        "total_count": total_count,
        "offset": offset
    })
コード例 #18
0
ファイル: api.py プロジェクト: Uditgulati/listenbrainz-server
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!
    :resheader Content-Type: *application/json*
    """

    max_ts = _parse_int_arg("max_ts")
    min_ts = _parse_int_arg("min_ts")

    # if no max given, use now()

    if max_ts and min_ts:
        log_raise_400("You may only specify max_ts or min_ts, not both.")

    # If none are given, start with now and go down
    if max_ts == None and min_ts == None:
        max_ts = int(time.time())

    db_conn = webserver.create_influx(current_app)
    listens = db_conn.fetch_listens(
        user_name,
        limit=min(_parse_int_arg("count", DEFAULT_ITEMS_PER_GET), MAX_ITEMS_PER_GET),
        from_ts=min_ts,
        to_ts=max_ts,
    )
    listen_data = []
    for listen in listens:
        listen_data.append(listen.to_api())

    if min_ts:
        listen_data = listen_data[::-1]

    return jsonify({'payload': {
        'user_id': user_name,
        'count': len(listen_data),
        'listens': listen_data,
    }})
コード例 #19
0
def add_playlist_item(playlist_mbid, offset):
    """
    Append recordings to an existing playlist by posting a playlist with one of more recordings in it.
    The playlist must be in JSPF format with MusicBrainz extensions, which is defined here:
    https://musicbrainz.org/doc/jspf .

    If the offset is provided in the URL, then the recordings will be added at that offset,
    otherwise they will be added at the end of the playlist.

    You may only add :data:`~webserver.views.playlist_api.MAX_RECORDINGS_PER_ADD` recordings in one
    call to this endpoint.

    :reqheader Authorization: Token <user token>
    :statuscode 200: playlist accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :statuscode 403: forbidden. the requesting user was not allowed to carry out this operation.
    :resheader Content-Type: *application/json*
    """

    user = validate_auth_header()
    if offset is not None and offset < 0:
        log_raise_400("Offset must be a positive integer.")

    if not is_valid_uuid(playlist_mbid):
        log_raise_400("Provided playlist ID is invalid.")

    playlist = db_playlist.get_by_mbid(playlist_mbid)
    if playlist is None or not playlist.is_visible_by(user["id"]):
        raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    if not playlist.is_modifiable_by(user["id"]):
        raise APIForbidden("You are not allowed to add recordings to this playlist.")

    data = request.json
    validate_playlist(data)

    if len(data["playlist"]["track"]) > MAX_RECORDINGS_PER_ADD:
        log_raise_400("You may only add max %d recordings per call." % MAX_RECORDINGS_PER_ADD)

    precordings = []
    if "track" in data["playlist"]:
        for track in data["playlist"]["track"]:
            try:
                mbid = UUID(track['identifier'][len(PLAYLIST_TRACK_URI_PREFIX):])
            except (KeyError, ValueError):
                log_raise_400("Track %d has an invalid identifier field, it must be a complete URI.")
            precordings.append(WritablePlaylistRecording(mbid=mbid, added_by_id=user["id"]))

    try:
        db_playlist.add_recordings_to_playlist(playlist, precordings, offset)
    except Exception as e:
        current_app.logger.error("Error while adding recordings to playlist: {}".format(e))
        raise APIInternalServerError("Failed to add recordings to the playlist. Please try again.")

    return jsonify({'status': 'ok'})
コード例 #20
0
def delete_playlist_item(playlist_mbid):
    """

    To delete an item in a playlist, the POST data needs to specify the recording MBID and current index
    of the track to delete, and how many tracks from that position should be moved deleted. The format of the
    post data should look as follows:

    .. code-block:: json
      {“index” : 3, “count”: 2}

    :reqheader Authorization: Token <user token>
    :statuscode 200: playlist accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :statuscode 403: forbidden. the requesting user was not allowed to carry out this operation.
    :resheader Content-Type: *application/json*
    """

    user = validate_auth_header()

    if not is_valid_uuid(playlist_mbid):
        log_raise_400("Provided playlist ID is invalid.")

    playlist = db_playlist.get_by_mbid(playlist_mbid)
    if playlist is None or \
       (playlist.creator_id != user["id"] and not playlist.public):
        raise APINotFound("Cannot find playlist: %s" % playlist_mbid)

    if playlist.creator_id != user["id"]:
        raise APIForbidden(
            "You are not allowed to remove recordings from this playlist.")

    data = request.json
    validate_delete_data(data)

    try:
        db_playlist.delete_recordings_from_playlist(playlist, data['index'],
                                                    data['count'])
    except Exception as e:
        current_app.logger.error(
            "Error while deleting recordings from playlist: {}".format(e))
        raise APIInternalServerError(
            "Failed to deleting recordings from the playlist. Please try again."
        )

    return jsonify({'status': 'ok'})
コード例 #21
0
def get_feedback_for_recording_msid(recording_msid):
    """
    Get feedback for recording with given ``recording_msid``. The format for the JSON returned is defined in
    our :ref:`feedback-json-doc`.

    :param score: Optional, If 1 then returns the loved recordings, if -1 returns hated recordings.
    :type score: ``int``
    :param count: Optional, number of feedback items 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 feedback items to skip from the beginning, for pagination.
        Ex. An offset of 5 means the top 5 feedback will be skipped, defaults to 0.
    :type offset: ``int``
    :statuscode 200: Yay, you have data!
    :resheader Content-Type: *application/json*
    """
    if not is_valid_uuid(recording_msid):
        log_raise_400(f"{recording_msid} msid format invalid.")
    return _get_feedback_for_recording("recording_msid", recording_msid)
コード例 #22
0
ファイル: api.py プロジェクト: phw/listenbrainz-server
def _validate_get_endpoint_params(db_conn: TimescaleListenStore,
                                  user_name: str) -> Tuple[int, int, int, int]:
    """ Validates parameters for listen GET endpoints like /username/listens and /username/feed/events

    Returns a tuple of integers: (min_ts, max_ts, count)
    """
    max_ts = _parse_int_arg("max_ts")
    min_ts = _parse_int_arg("min_ts")

    if max_ts and min_ts:
        if max_ts < min_ts:
            log_raise_400("max_ts should be greater than min_ts")

    # Validate requetsed listen count is positive
    count = min(_parse_int_arg("count", DEFAULT_ITEMS_PER_GET),
                MAX_ITEMS_PER_GET)
    if count < 0:
        log_raise_400("Number of items requested should be positive")

    return min_ts, max_ts, count
コード例 #23
0
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,
    })
コード例 #24
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!
    :resheader Content-Type: *application/json*
    """

    current_time = int(time.time())
    max_ts = _parse_int_arg("max_ts")
    min_ts = _parse_int_arg("min_ts")
    time_range = _parse_int_arg("time_range", DEFAULT_TIME_RANGE)

    if time_range < 1 or time_range > MAX_TIME_RANGE:
        log_raise_400("time_range must be between 1 and %d." % MAX_TIME_RANGE)

    # if no max given, use now()
    if max_ts and min_ts:
        log_raise_400("You may only specify max_ts or min_ts, not both.")

    db_conn = webserver.create_timescale(current_app)
    (min_ts_per_user,
     max_ts_per_user) = db_conn.get_timestamps_for_user(user_name)

    # If none are given, start with now and go down
    if max_ts == None and min_ts == None:
        max_ts = max_ts_per_user + 1

    # Validate requetsed listen count is positive
    count = min(_parse_int_arg("count", DEFAULT_ITEMS_PER_GET),
                MAX_ITEMS_PER_GET)
    if count < 0:
        log_raise_400("Number of listens requested should be positive")

    listens = db_conn.fetch_listens(user_name,
                                    limit=count,
                                    from_ts=min_ts,
                                    to_ts=max_ts,
                                    time_range=time_range)
    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,
        }
    })
コード例 #25
0
def validate_create_playlist_required_items(jspf):
    """Given a JSPF dict, ensure that the title and public fields are present.
    These fields are required only when creating a new playlist"""

    if "playlist" not in jspf:
        log_raise_400("JSPF playlist requires 'playlist' element")

    if "title" not in jspf["playlist"]:
        log_raise_400("JSPF playlist must contain a title element with the title of the playlist.")

    if "public" not in jspf["playlist"].get("extension", {}).get(PLAYLIST_EXTENSION_URI, {}):
        log_raise_400("JSPF playlist.extension.https://musicbrainz.org/doc/jspf#playlist.public field must be given.")
def submit_recommendation_feedback():
    """
    Submit recommendation feedback. A user token (found on  https://listenbrainz.org/profile/ )
    must be provided in the Authorization header! Each request should contain only one feedback in the payload.

    A sample feedback may look like:

    .. code-block:: json

        {
            "recording_mbid": "d23f4719-9212-49f0-ad08-ddbfbfc50d6f",
            "rating": "love"
        }

    :reqheader Authorization: Token <user token>
    :statuscode 200: feedback accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header()

    data = request.json

    if 'recording_mbid' not in data or 'rating' not in data:
        log_raise_400("JSON document must contain recording_mbid and rating",
                      data)

    if 'recording_mbid' in data and 'rating' in data and len(data) > 2:
        log_raise_400(
            "JSON document must only contain recording_mbid and rating", data)

    try:
        feedback_submit = RecommendationFeedbackSubmit(
            user_id=user["id"],
            recording_mbid=data["recording_mbid"],
            rating=data["rating"])
    except ValidationError as e:
        log_raise_400(
            "Invalid JSON document submitted: %s" %
            str(e).replace("\n ", ":").replace("\n", " "), data)

    try:
        db_feedback.insert(feedback_submit)
    except Exception as e:
        current_app.logger.error(
            "Error while inserting recommendation feedback: {}".format(e))
        raise APIInternalServerError("Something went wrong. Please try again.")

    return jsonify({'status': 'ok'})
コード例 #27
0
def recording_feedback():
    """
    Submit recording feedback (love/hate) to the server. A user token (found on  https://listenbrainz.org/profile/ )
    must be provided in the Authorization header! Each request should contain only one feedback in the payload.

    For complete details on the format of the JSON to be POSTed to this endpoint, see :ref:`feedback-json-doc`.

    :reqheader Authorization: Token <user token>
    :statuscode 200: feedback accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = _validate_auth_header()

    data = request.json

    if 'recording_msid' not in data or 'score' not in data:
        log_raise_400(
            "JSON document must contain recording_msid and "
            "score top level keys", data)

    if 'recording_msid' in data and 'score' in data and len(data) > 2:
        log_raise_400(
            "JSON document may only contain recording_msid and "
            "score top level keys", data)

    try:
        feedback = Feedback(user_id=user["id"],
                            recording_msid=data["recording_msid"],
                            score=data["score"])
    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", " "), data)
    try:
        if feedback.score == 0:
            db_feedback.delete(feedback)
        else:
            db_feedback.insert(feedback)
    except Exception as e:
        current_app.logger.error(
            "Error while inserting recording feedback: {}".format(e))
        raise APIInternalServerError("Something went wrong. Please try again.")

    return jsonify({'status': 'ok'})
コード例 #28
0
def recording_feedback():
    """
    Submit recording feedback (love/hate) to the server. A user token (found on  https://listenbrainz.org/profile/ )
    must be provided in the Authorization header! Each request should contain only one feedback in the payload.

    For complete details on the format of the JSON to be POSTed to this endpoint, see :ref:`feedback-json-doc`.

    :reqheader Authorization: Token <user token>
    :statuscode 200: feedback accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header()

    data = request.json

    if ('recording_msid' not in data
            and 'recording_mbid' not in data) or 'score' not in data:
        log_raise_400(
            "JSON document must contain either recording_msid or recording_mbid, and "
            "score top level keys", data)

    if set(data) - {"recording_msid", "recording_mbid", "score"}:
        log_raise_400(
            "JSON document may only contain recording_msid, recording_mbid and "
            "score top level keys", data)

    try:
        feedback = Feedback(user_id=user["id"],
                            recording_msid=data.get("recording_msid", None),
                            recording_mbid=data.get("recording_mbid", None),
                            score=data["score"])
    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", " "), data)

    if feedback.score == FEEDBACK_DEFAULT_SCORE:
        db_feedback.delete(feedback)
    else:
        db_feedback.insert(feedback)

    return jsonify({'status': 'ok'})
コード例 #29
0
def validate_move_data(data):
    """
        Check that the passed JSON for a move recordings endpoint call are valid. Raise 400 with
        error message if not.
    """

    if "mbid" not in data or "from" not in data or "to" not in data or "count" not in data:
        log_raise_400("post data for a move instruction must include the keys 'from', 'to', 'count' and 'mbid'.")

    if not is_valid_uuid(data["mbid"]):
        log_raise_400("move instruction mbid is not a valid mbid.")

    try:
        from_value = int(data["from"])
        to_value = int(data["to"])
        count_value = int(data["count"])
        if from_value < 0 or to_value < 0 or count_value < 0:
            raise ValueError
    except ValueError:
        log_raise_400("move instruction values for 'from', 'to' and 'count' must all be positive integers.")
コード例 #30
0
def get_feedback_for_user(user_name):
    """
    Get feedback given by user ``user_name``.

    A sample response may look like:

    .. code-block:: json

        {
            "count": 1,
            "feedback": [
                {
                    "created": "1345679998",
                    "recording_mbid": "d23f4719-9212-49f0-ad08-ddbfbfc50d6f",
                    "rating": "love"
                },
                "-- more feedback data here ---"
            ],
            "offset": 0,
            "total_count": 1,
            "user_name": "Vansika"
        }

    If the optional argument ``rating`` is not given, this endpoint will return all the feedback submitted by the user.
    Otherwise filters the feedback to be returned by rating.

    :param rating: Optional, refer to db/model/recommendation_feedback.py for allowed rating values.
    :type rating: ``str``
    :param count: Optional, number of feedback items 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 feedback items to skip from the beginning, for pagination.
        Ex. An offset of 5 means the top 5 feedback will be skipped, defaults to 0.
    :type offset: ``int``
    :statuscode 200: Yay, you have data!
    :statuscode 404: User not found.
    :statuscode 400: Bad request, check ``response['error']`` for more details
    :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))

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

    if rating:
        expected_rating = get_allowed_ratings()
        if rating not in expected_rating:
            log_raise_400("Rating must be in {}".format(expected_rating), request.args)

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

    count = min(count, MAX_ITEMS_PER_GET)

    feedback = db_feedback.get_feedback_for_user(user_id=user["id"], limit=count, offset=offset, rating=rating)
    total_count = db_feedback.get_feedback_count_for_user(user["id"])

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

    return jsonify({
        "feedback": feedback,
        "count": len(feedback),
        "total_count": total_count,
        "offset": offset,
        "user_name": user["musicbrainz_id"]
    })
コード例 #31
0
def submit_listen():
    """
    Submit listens to the server. A user token (found on  https://listenbrainz.org/profile/ ) must
    be provided in the Authorization header! Each request should also contain at least one listen
    in the payload.

    Listens should be submitted for tracks when the user has listened to half the track or 4 minutes of
    the track, whichever is lower. If the user hasn't listened to 4 minutes or half the track, it doesn't
    fully count as a listen and should not be submitted.

    For complete details on the format of the JSON to be POSTed to this endpoint, see :ref:`json-doc`.

    :reqheader Authorization: Token <user token>
    :reqheader Content-Type: *application/json*
    :statuscode 200: listen(s) accepted.
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header(fetch_email=True)
    if mb_engine and current_app.config[
            "REJECT_LISTENS_WITHOUT_USER_EMAIL"] and not user["email"]:
        raise APIUnauthorized(REJECT_LISTENS_WITHOUT_EMAIL_ERROR)

    raw_data = request.get_data()
    try:
        data = ujson.loads(raw_data.decode("utf-8"))
    except ValueError as e:
        log_raise_400("Cannot parse JSON document: %s" % e, raw_data)

    try:
        payload = data['payload']

        if not isinstance(payload, list):
            raise APIBadRequest(
                "The payload in the JSON document should be a list of listens.",
                payload)

        if len(payload) == 0:
            log_raise_400("JSON document does not contain any listens",
                          payload)

        if len(raw_data) > len(payload) * MAX_LISTEN_SIZE:
            log_raise_400(
                "JSON document is too large. In aggregate, listens may not "
                "be larger than %d characters." % MAX_LISTEN_SIZE, payload)

        if data['listen_type'] not in ('playing_now', 'single', 'import'):
            log_raise_400("JSON document requires a valid listen_type key.",
                          payload)

        listen_type = _get_listen_type(data['listen_type'])
        if (listen_type == LISTEN_TYPE_SINGLE or listen_type
                == LISTEN_TYPE_PLAYING_NOW) and len(payload) > 1:
            log_raise_400(
                "JSON document contains more than listen for a single/playing_now. "
                "It should contain only one.", payload)
    except KeyError:
        log_raise_400("Invalid JSON document submitted.", raw_data)

    try:
        # validate listens to make sure json is okay
        validated_payload = [
            validate_listen(listen, listen_type) for listen in payload
        ]
    except ListenValidationError as err:
        raise APIBadRequest(err.message, err.payload)

    user_metadata = SubmitListenUserMetadata(
        user_id=user['id'], musicbrainz_id=user['musicbrainz_id'])
    insert_payload(validated_payload, user_metadata, listen_type)

    return jsonify({'status': 'ok'})
コード例 #32
0
def validate_playlist(jspf):
    """
        Given a JSPF dict, ensure that title is present and that if tracks are present
        they have valid URIs or MBIDs specified. If any errors are found 400 is raised.
    """

    if "playlist" not in jspf:
        log_raise_400("JSPF playlist requires 'playlist' element")

    if "title" in jspf["playlist"]:
        title = jspf["playlist"]["title"]
        if not title:
            log_raise_400("JSPF playlist must contain a title element with the title of the playlist.")

    if "public" in jspf["playlist"].get("extension", {}).get(PLAYLIST_EXTENSION_URI, {}):
        public = jspf["playlist"]["extension"][PLAYLIST_EXTENSION_URI]["public"]
        if not isinstance(public, bool):
            log_raise_400("JSPF playlist public field must contain a boolean.")

    try:
        # Collaborators are not required, so only validate if they are set
        for collaborator in jspf["playlist"]["extension"][PLAYLIST_EXTENSION_URI]["collaborators"]:
            if not collaborator:
                log_raise_400("The collaborators field contains an empty value.")
    except KeyError:
        pass

    if "track" not in jspf["playlist"]:
        return

    for i, track in enumerate(jspf["playlist"].get("track", [])):
        recording_uri = track.get("identifier")
        if not recording_uri:
            log_raise_400("JSPF playlist track %d must contain an identifier element with recording MBID." % i)

        if recording_uri.startswith(PLAYLIST_TRACK_URI_PREFIX):
            recording_mbid = recording_uri[len(PLAYLIST_TRACK_URI_PREFIX):]
        else:
            log_raise_400("JSPF playlist track %d identifier must have the namespace '%s' prepended to it." %
                          (i, PLAYLIST_TRACK_URI_PREFIX))

        if not is_valid_uuid(recording_mbid):
            log_raise_400("JSPF playlist track %d does not contain a valid track identifier field." % i)