Ejemplo n.º 1
0
def get_playlists_for_user(playlist_user_name):
    """
    Fetch playlist metadata in JSPF format without recordings for the given user.
    If a user token is provided in the Authorization header, return private playlists as well
    as public playlists for that user.

    :params count: The number of playlists to return (for pagination). Default
        :data:`~webserver.views.api.DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL`
    :params offset: The offset of into the list of playlists to return (for pagination)
    :statuscode 200: Yay, you have data!
    :statuscode 404: User not found
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header(True)

    count = get_non_negative_param('count',
                                   DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL)
    offset = get_non_negative_param('offset', 0)
    playlist_user = db_user.get_by_mb_id(playlist_user_name)
    if playlist_user is None:
        raise APINotFound("Cannot find user: %s" % playlist_user_name)

    include_private = True if user and user["id"] == playlist_user[
        "id"] else False
    playlists, playlist_count = db_playlist.get_playlists_for_user(
        playlist_user["id"],
        include_private=include_private,
        load_recordings=False,
        count=count,
        offset=offset)

    return jsonify(
        serialize_playlists(playlists, playlist_count, count, offset))
Ejemplo n.º 2
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})
Ejemplo n.º 3
0
def unfollow_user(user_name: str):
    """
    Unfollow 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 unfollowed the user ``user_name``.
    :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)

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

    return jsonify({"status": "ok"})
Ejemplo n.º 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'})
Ejemplo n.º 5
0
def get_playlists_collaborated_on_for_user(playlist_user_name):
    """
    Fetch playlist metadata in JSPF format without recordings for which a user is a collaborator.
    If a playlist is private, it will only be returned if the caller is authorized to edit that playlist.

    :params count: The number of playlists to return (for pagination). Default
        :data:`~webserver.views.api.DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL`
    :params offset: The offset of into the list of playlists to return (for pagination)
    :statuscode 200: Yay, you have data!
    :statuscode 404: User not found
    :resheader Content-Type: *application/json*
    """

    user = validate_auth_header(True)

    count = get_non_negative_param('count',
                                   DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL)
    offset = get_non_negative_param('offset', 0)
    playlist_user = db_user.get_by_mb_id(playlist_user_name)
    if playlist_user is None:
        raise APINotFound("Cannot find user: %s" % playlist_user_name)

    # TODO: This needs to be passed to the DB layer
    include_private = True if user and user["id"] == playlist_user[
        "id"] else False
    playlists, playlist_count = db_playlist.get_playlists_collaborated_on(
        playlist_user["id"],
        include_private=include_private,
        load_recordings=False,
        count=count,
        offset=offset)

    return jsonify(
        serialize_playlists(playlists, playlist_count, count, offset))
Ejemplo n.º 6
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))
Ejemplo n.º 7
0
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_pin_for_user(row_id):
    """
    Deletes the pinned recording with given ``row_id`` from the server.
    A user token (found on  https://listenbrainz.org/profile/) must be provided in the Authorization header!

    :reqheader Authorization: Token <user token>
    :param row_id: the row_id of the pinned recording that should be deleted.
    :type row_id: ``int``
    :statuscode 200: recording unpinned.
    :statuscode 401: invalid authorization. See error message for details.
    :statuscode 404: the requested row_id for the user was not found.
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header()

    try:
        recording_deleted = db_pinned_rec.delete(row_id, user["id"])
    except Exception as e:
        current_app.logger.error(
            "Error while unpinning recording for user: {}".format(e))
        raise APIInternalServerError("Something went wrong. Please try again.")

    if recording_deleted is False:
        raise APINotFound("Cannot find pin with row_id '%s' for user '%s'" %
                          (row_id, user["musicbrainz_id"]))

    return jsonify({"status": "ok"})
Ejemplo n.º 9
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))
Ejemplo n.º 10
0
def unpin_recording_for_user():
    """
    Unpins the currently active pinned recording for the user. A user token (found on  https://listenbrainz.org/profile/)
    must be provided in the Authorization header!

    :reqheader Authorization: Token <user token>
    :statuscode 200: recording unpinned.
    :statuscode 401: invalid authorization. See error message for details.
    :statuscode 404: could not find the active recording to unpin for the user. See error message for details.
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header()

    try:
        recording_unpinned = db_pinned_rec.unpin(user["id"])
    except Exception as e:
        current_app.logger.error(
            "Error while unpinning recording for user: {}".format(e))
        raise APIInternalServerError("Something went wrong. Please try again.")

    if recording_unpinned is False:
        raise APINotFound(
            "Cannot find an active pinned recording for user '%s' to unpin" %
            (user["musicbrainz_id"]))

    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)}")
Ejemplo n.º 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'})
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)
    })
Ejemplo n.º 14
0
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'})
Ejemplo n.º 15
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>
    :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:
            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)

    # 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 APIServiceUnavailable as e:
        raise
    except Exception as e:
        raise APIInternalServerError("Something went wrong. Please try again.")

    return jsonify({'status': 'ok'})
Ejemplo n.º 16
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'})
Ejemplo n.º 17
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'})
Ejemplo n.º 18
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 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'})
Ejemplo n.º 20
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'})
Ejemplo n.º 21
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'})
Ejemplo n.º 22
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,
    })
Ejemplo n.º 23
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'})
Ejemplo n.º 24
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'})
Ejemplo n.º 25
0
def edit_playlist(playlist_mbid):
    """
    Edit the private/public status, name, description or list of collaborators for an exising playlist.
    The Authorization header must be set and correspond to the owner of the playlist otherwise a 403
    error will be returned. All fields will be overwritten with new values.

    :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 subitting user is not allowed to edit playlists for other users.
    :resheader Content-Type: *application/json*
    """

    user = validate_auth_header()

    data = request.json
    validate_playlist(data)

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

    playlist = db_playlist.get_by_mbid(playlist_mbid, False)
    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 edit this playlist.")

    try:
        playlist.public = data["playlist"]["extension"][PLAYLIST_EXTENSION_URI]["public"]
    except KeyError:
        pass

    if "annotation" in data["playlist"]:
        # If the annotation key exists but the value is empty ("" or None),
        # unset the description
        description = data["playlist"]["annotation"]
        if description:
            description = _filter_description_html(description)
        else:
            description = None
        playlist.description = description

    if data["playlist"].get("title"):
        playlist.name = data["playlist"]["title"]

    collaborators = data.get("playlist", {}).\
        get("extension", {}).get(PLAYLIST_EXTENSION_URI, {}).\
        get("collaborators", [])
    users = {}

    # Uniquify collaborators list
    collaborators = list(set(collaborators))

    # Don't allow creator to also be a collaborator
    if user["musicbrainz_id"] in collaborators:
        collaborators.remove(user["musicbrainz_id"])

    if collaborators:
        users = db_user.get_many_users_by_mb_id(collaborators)

    collaborator_ids = []
    for collaborator in collaborators:
        if collaborator.lower() not in users:
            log_raise_400("Collaborator {} doesn't exist".format(collaborator))
        collaborator_ids.append(users[collaborator.lower()]["id"])

    playlist.collaborators = collaborators
    playlist.collaborator_ids = collaborator_ids

    db_playlist.update_playlist(playlist)

    return jsonify({'status': 'ok'})
Ejemplo n.º 26
0
def create_playlist():
    """
    Create a playlist. The playlist must be in JSPF format with MusicBrainz extensions, which is defined
    here: https://musicbrainz.org/doc/jspf . To create an empty playlist, you can send an empty playlist
    with only the title field filled out. If you would like to create a playlist populated with recordings,
    each of the track items in the playlist must have an identifier element that contains the MusicBrainz
    recording that includes the recording MBID.

    When creating a playlist, only the playlist title and the track identifier elements will be used -- all
    other elements in the posted JSPF wil be ignored.

    If a created_for field is found and the user is not an approved playlist bot, then a 403 forbidden will be raised.

    :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 submitting user is not allowed to create playlists for other users.
    :resheader Content-Type: *application/json*
    """

    user = validate_auth_header()

    data = request.json
    validate_create_playlist_required_items(data)
    validate_playlist(data)

    public = data["playlist"]["extension"][PLAYLIST_EXTENSION_URI]["public"]
    collaborators = data.get("playlist", {}).\
        get("extension", {}).get(PLAYLIST_EXTENSION_URI, {}).\
        get("collaborators", [])

    # Uniquify collaborators list
    collaborators = list(set(collaborators))

    # Don't allow creator to also be a collaborator
    if user["musicbrainz_id"] in collaborators:
        collaborators.remove(user["musicbrainz_id"])

    username_lookup = collaborators
    created_for = data["playlist"].get("created_for", None)
    if created_for:
        username_lookup.append(created_for)

    users = {}
    if username_lookup:
        users = db_user.get_many_users_by_mb_id(username_lookup)

    collaborator_ids = []
    for collaborator in collaborators:
        if collaborator.lower() not in users:
            log_raise_400("Collaborator {} doesn't exist".format(collaborator))
        collaborator_ids.append(users[collaborator.lower()]["id"])

    # filter description
    description = data["playlist"].get("annotation", None)
    if description is not None:
        description = _filter_description_html(description)

    playlist = WritablePlaylist(name=data['playlist']['title'],
                                creator_id=user["id"],
                                description=description,
                                collaborator_ids=collaborator_ids,
                                collaborators=collaborators,
                                public=public)

    if data["playlist"].get("created_for", None):
        if user["musicbrainz_id"] not in current_app.config["APPROVED_PLAYLIST_BOTS"]:
            raise APIForbidden("Playlist contains a created_for field, but submitting user is not an approved playlist bot.")
        created_for_user = users.get(data["playlist"]["created_for"].lower())
        if not created_for_user:
            log_raise_400("created_for user does not exist.")
        playlist.created_for_id = created_for_user["id"]

    if "track" in data["playlist"]:
        for track in data["playlist"]["track"]:
            try:
                playlist.recordings.append(WritablePlaylistRecording(mbid=UUID(track['identifier'][len(PLAYLIST_TRACK_URI_PREFIX):]),
                                           added_by_id=user["id"]))
            except ValueError:
                log_raise_400("Invalid recording MBID found in submitted recordings")

    try:
        playlist = db_playlist.create(playlist)
    except Exception as e:
        current_app.logger.error("Error while creating new playlist: {}".format(e))
        raise APIInternalServerError("Failed to create the playlist. Please try again.")

    return jsonify({'status': 'ok', 'playlist_mbid': playlist.mbid})
Ejemplo n.º 27
0
def user_feed(user_name: str):
    """ Get feed events for a user's timeline.

    :param user_name: The MusicBrainz ID of the user whose timeline is being requested.
    :type user_name: ``str``
    :param max_ts: If you specify a ``max_ts`` timestamp, events with timestamps less than the value will be returned
    :param min_ts: If you specify a ``min_ts`` timestamp, events with timestamps greater than the value will be returned
    :param count: Optional, number of events to return. Default: :data:`~webserver.views.api.DEFAULT_ITEMS_PER_GET` . Max: :data:`~webserver.views.api.MAX_ITEMS_PER_GET`
    :statuscode 200: Successful query, you have feed events!
    :statuscode 400: Bad request, check ``response['error']`` for more details.
    :statuscode 401: Unauthorized, you do not have permission to view this user's feed.
    :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 view this user's timeline.")

    db_conn = webserver.create_timescale(current_app)
    min_ts, max_ts, count, time_range = _validate_get_endpoint_params(
        db_conn, user_name)
    if min_ts is None and max_ts is None:
        max_ts = int(time.time())

    users_following = db_user_relationship.get_following_for_user(user['id'])

    # get all listen events
    musicbrainz_ids = [user['musicbrainz_id'] for user in users_following]
    if len(users_following) == 0:
        listen_events = []
    else:
        listen_events = get_listen_events(db_conn, musicbrainz_ids, min_ts,
                                          max_ts, count, time_range)

    # for events like "follow" and "recording recommendations", we want to show the user
    # their own events as well
    users_for_feed_events = users_following + [user]
    follow_events = get_follow_events(
        user_ids=tuple(user['id'] for user in users_for_feed_events),
        min_ts=min_ts or 0,
        max_ts=max_ts or int(time.time()),
        count=count,
    )

    recording_recommendation_events = get_recording_recommendation_events(
        users_for_events=users_for_feed_events,
        min_ts=min_ts or 0,
        max_ts=max_ts or int(time.time()),
        count=count,
    )

    notification_events = get_notification_events(user, count)

    # TODO: add playlist event and like event
    all_events = sorted(listen_events + follow_events +
                        recording_recommendation_events + notification_events,
                        key=lambda event: -event.created)

    # sadly, we need to serialize the event_type ourselves, otherwise, jsonify converts it badly
    for index, event in enumerate(all_events):
        all_events[index].event_type = event.event_type.value

    all_events = all_events[:count]

    return jsonify({
        'payload': {
            'count': len(all_events),
            'user_id': user_name,
            'events': [event.dict() for event in all_events],
        }
    })
Ejemplo n.º 28
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'})
Ejemplo n.º 29
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
    :type user_name: ``str``
    :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.
    :statuscode 404: user or service not found. See error message for details.
    """
    if request.method == 'GET':
        user_name = request.args.get('user_name', '')
        service_name = request.args.get('service', 'lastfm')
        try:
            service = ExternalServiceType[service_name.upper()]
        except KeyError:
            raise APINotFound(
                "Service does not exist: {}".format(service_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))
        latest_import_ts = listens_importer.get_latest_listened_at(
            user["id"], service)
        return jsonify({
            'musicbrainz_id':
            user['musicbrainz_id'],
            'latest_import':
            0 if not latest_import_ts else int(latest_import_ts.strftime('%s'))
        })
    elif request.method == 'POST':
        user = validate_auth_header()

        try:
            data = ujson.loads(request.get_data())
            ts = int(data.get('ts', 0))
            service_name = data.get('service', 'lastfm')
            service = ExternalServiceType[service_name.upper()]
        except (ValueError, KeyError):
            raise APIBadRequest('Invalid data sent')

        try:
            last_import_ts = listens_importer.get_latest_listened_at(
                user["id"], service)
            last_import_ts = 0 if not last_import_ts else int(
                last_import_ts.strftime('%s'))
            if ts > last_import_ts:
                listens_importer.update_latest_listened_at(
                    user["id"], service, ts)
        except DatabaseException:
            current_app.logger.error("Error while updating latest import: ",
                                     exc_info=True)
            raise APIInternalServerError(
                'Could not update latest_import, try again')

        # During unrelated tests _ts may be None -- improving this would be a great headache.
        # However, during the test of this code and while serving requests _ts is set.
        if _ts:
            _ts.set_listen_count_expiry_for_user(user['musicbrainz_id'])

        return jsonify({'status': 'ok'})