org.gravidence.gravifon.resource.Scrobbles.java Source code

Java tutorial

Introduction

Here is the source code for org.gravidence.gravifon.resource.Scrobbles.java

Source

/*
 * The MIT License
 *
 * Copyright 2013 Gravidence.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package org.gravidence.gravifon.resource;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.UriInfo;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang.ObjectUtils;
import org.apache.commons.lang.StringUtils;
import org.gravidence.gravifon.db.AlbumsDBClient;
import org.gravidence.gravifon.db.ArtistsDBClient;
import org.gravidence.gravifon.db.LabelsDBClient;
import org.gravidence.gravifon.db.ScrobblesDBClient;
import org.gravidence.gravifon.db.TracksDBClient;
import org.gravidence.gravifon.db.UsersDBClient;
import org.gravidence.gravifon.db.domain.AlbumDocument;
import org.gravidence.gravifon.db.domain.ArtistDocument;
import org.gravidence.gravifon.db.domain.ScrobbleDocument;
import org.gravidence.gravifon.db.domain.TrackDocument;
import org.gravidence.gravifon.db.domain.UserDocument;
import org.gravidence.gravifon.exception.EntityNotFoundException;
import org.gravidence.gravifon.exception.GravifonException;
import org.gravidence.gravifon.exception.ValidationException;
import org.gravidence.gravifon.exception.error.GravifonError;
import org.gravidence.gravifon.resource.bean.AlbumBean;
import org.gravidence.gravifon.resource.bean.ArtistBean;
import org.gravidence.gravifon.resource.bean.DurationBean;
import org.gravidence.gravifon.resource.bean.PageBean;
import org.gravidence.gravifon.resource.bean.ScrobbleBean;
import org.gravidence.gravifon.resource.bean.ScrobblesInfoBean;
import org.gravidence.gravifon.resource.bean.TrackBean;
import org.gravidence.gravifon.resource.message.StatusResponse;
import org.gravidence.gravifon.util.BasicUtils;
import org.gravidence.gravifon.util.DateTimeUtils;
import org.gravidence.gravifon.validation.ScrobbleDeleteValidator;
import org.gravidence.gravifon.validation.ScrobbleRetrieveValidator;
import org.gravidence.gravifon.validation.ScrobbleSearchValidator;
import org.gravidence.gravifon.validation.ScrobbleSubmitValidator;
import org.gravidence.gravifon.validation.ScrobblesInfoValidator;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * Scrobbles resource.<p>
 * Provides <code>scrobble</code> entity management API.
 * 
 * @see ScrobblesDBClient
 * 
 * @author Maksim Liauchuk <maksim_liauchuk@fastmail.fm>
 */
@Path("/v1/scrobbles")
@Consumes("application/json;charset=UTF-8")
@Produces("application/json;charset=UTF-8")
public class Scrobbles {

    private static final Logger LOGGER = LoggerFactory.getLogger(Scrobbles.class);

    /**
     * {@link UsersDBClient} instance.
     */
    @Autowired
    private UsersDBClient usersDBClient;

    /**
     * {@link ScrobblesDBClient} instance.
     */
    @Autowired
    private ScrobblesDBClient scrobblesDBClient;

    /**
     * {@link TracksDBClient} instance.
     */
    @Autowired
    private TracksDBClient tracksDBClient;

    /**
     * {@link ArtistsDBClient} instance.
     */
    @Autowired
    private ArtistsDBClient artistsDBClient;

    /**
     * {@link AlbumsDBClient} instance.
     */
    @Autowired
    private AlbumsDBClient albumsDBClient;

    /**
     * {@link LabelsDBClient} instance.
     */
    @Autowired
    private LabelsDBClient labelsDBClient;

    // Validators
    private final ScrobblesInfoValidator scrobblesInfoValidator = new ScrobblesInfoValidator();
    private final ScrobbleSubmitValidator scrobbleSubmitValidator = new ScrobbleSubmitValidator();
    private final ScrobbleRetrieveValidator scrobbleRetrieveValidator = new ScrobbleRetrieveValidator();
    private final ScrobbleSearchValidator scrobbleSearchValidator = new ScrobbleSearchValidator();
    private final ScrobbleDeleteValidator scrobbleDeleteValidator = new ScrobbleDeleteValidator();

    /**
     * Retrieves <code>/scrobbles</code> database info.
     * 
     * @return status response with <code>/scrobbles</code> database info bean
     */
    @GET
    public StatusResponse info() {
        scrobblesInfoValidator.validate(null, null, null);

        ScrobblesInfoBean entity = new ScrobblesInfoBean();

        entity.setScrobbleAmount(scrobblesDBClient.retrieveScrobbleAmount());

        return new StatusResponse<>(entity);
    }

    /**
     * Submits a list of new scrobbles.
     * 
     * @param httpHeaders request http headers and cookies
     * @param uriInfo request URI details
     * @param scrobbles list of new scrobble details beans
     * @return list of status responses with scrobble identifiers
     */
    @POST
    public List<StatusResponse> submit(@Context HttpHeaders httpHeaders, @Context UriInfo uriInfo,
            List<ScrobbleBean> scrobbles) {
        scrobbleSubmitValidator.validate(httpHeaders.getRequestHeaders(), null, scrobbles);

        UserDocument user = ResourceUtils.authenticateUser(httpHeaders.getRequestHeaders(), usersDBClient, LOGGER);

        ResourceUtils.checkUserStatus(user);

        DateTime userLastActivityDatetime = DateTimeUtils.arrayToDateTime(user.getLastActivityDatetime());
        DateTime lastScrobbleDatetime = new DateTime(userLastActivityDatetime);

        List<StatusResponse> result = new ArrayList<>(scrobbles.size() + 1);

        for (ScrobbleBean scrobble : scrobbles) {
            try {
                scrobble.validate();

                // "Fraud" checks
                checkForDuplicates(user.getId(), scrobble);
                checkForIntersections(user.getId(), scrobble);

                resolveTrackId(scrobble.getTrack());

                ScrobbleDocument scrobbleDoc = scrobble.createDocument();
                scrobbleDoc.setUserId(user.getId());

                scrobbleDoc = scrobblesDBClient.create(scrobbleDoc);

                result.add(new StatusResponse(scrobbleDoc.getId()));

                if (scrobble.getScrobbleStartDatetime().isAfter(lastScrobbleDatetime)) {
                    lastScrobbleDatetime = scrobble.getScrobbleStartDatetime();
                }
            } catch (GravifonException ex) {
                result.add(new StatusResponse(ex.getError().getErrorCode(), ex.getMessage()));
            }
        }

        if (lastScrobbleDatetime.isAfter(userLastActivityDatetime)) {
            user.setLastActivityDatetime(DateTimeUtils.dateTimeToArray(lastScrobbleDatetime));

            usersDBClient.update(user);
        }

        return result;
    }

    /**
     * Retrieves existing scrobble details.<p>
     * Basic HTTP Authorization details are required. {@link GravifonError#NOT_ALLOWED NOT_ALLOWED} error is thrown
     * in case requested scrobble doesn't belong to user specified in authorization details.
     * 
     * @param httpHeaders request http headers and cookies
     * @param uriInfo request URI details
     * @param deep deep retrieval indicator (<code>true</code> means all child entities are to be retrieved as well)
     * @param id scrobble identifier
     * @return status response with scrobble details bean
     */
    @GET
    @Path("{scrobble_id}")
    public StatusResponse retrieve(@Context HttpHeaders httpHeaders, @Context UriInfo uriInfo,
            @QueryParam("deep") Boolean deep, @PathParam("scrobble_id") String id) {
        scrobbleRetrieveValidator.validate(httpHeaders.getRequestHeaders(), uriInfo.getQueryParameters(), null);

        ScrobbleDocument scrobble = scrobblesDBClient.retrieveScrobbleByID(id);

        if (scrobble == null) {
            throw new EntityNotFoundException("Scrobble not found.");
        }

        UserDocument user = ResourceUtils.authorizeUser(httpHeaders.getRequestHeaders(), scrobble.getUserId(),
                usersDBClient, LOGGER);

        ResourceUtils.checkUserStatus(user);

        ScrobbleBean result = new ScrobbleBean().updateBean(scrobble);

        if (Boolean.TRUE.equals(deep)) {
            retrieveCompleteScrobbleDetails(result);
        }

        return new StatusResponse<>(result);
    }

    /**
     * Searches for existing scrobbles that match selected filter and user.<p>
     * Basic HTTP Authorization details are required.
     * 
     * @param httpHeaders request http headers and cookies
     * @param uriInfo request URI details
     * @param deep deep retrieval indicator (<code>true</code> means all child entities are to be retrieved as well)
     * @param forward start scrobble identifier in forward direction 
     * @param backward start scrobble identifier in backward direction
     * @param amount number of scrobbles to retrieve
     * @param start opening bound of date range
     * @param end closing bound of date range
     * @return status response with page bean (scrobble details beans inside)
     */
    @GET
    @Path("search")
    public StatusResponse search(@Context HttpHeaders httpHeaders, @Context UriInfo uriInfo,
            @QueryParam("deep") Boolean deep, @QueryParam("forward") String forward,
            @QueryParam("backward") String backward, @QueryParam("amount") Long amount,
            @QueryParam("start") String start, @QueryParam("end") String end) {
        scrobbleSearchValidator.validate(httpHeaders.getRequestHeaders(), uriInfo.getQueryParameters(), null);

        UserDocument user = ResourceUtils.authenticateUser(httpHeaders.getRequestHeaders(), usersDBClient, LOGGER);

        ResourceUtils.checkUserStatus(user);

        PageBean<ScrobbleBean> result = new PageBean<>();
        result.setItems(new ArrayList<ScrobbleBean>());

        // Scrobble sub key (start datetime) to start page with
        String scrobbleStartDatetime;
        boolean ascending;
        if (forward != null) {
            scrobbleStartDatetime = "".equals(forward) ? null : BasicUtils.decodeFromBase64(forward);
            ascending = true;
        } else if (backward != null) {
            scrobbleStartDatetime = "".equals(backward) ? null : BasicUtils.decodeFromBase64(backward);
            ascending = false;
        } else {
            scrobbleStartDatetime = null;
            ascending = false;
        }

        List<ScrobbleDocument> scrobbles;

        DateTime startDatetime = extractDatetimeFromQueryParam(start, "start");
        DateTime endDatetime = extractDatetimeFromQueryParam(end, "end");

        scrobbles = scrobblesDBClient.retrieveScrobblesByUserID(user.getId(), scrobbleStartDatetime, startDatetime,
                endDatetime, ascending, amount);

        if (scrobbles != null) {
            int scrobblesToReturn;
            if (amount >= scrobbles.size()) {
                scrobblesToReturn = scrobbles.size();
            } else {
                // Skip extra (last) scrobble - it's needed for next page info
                scrobblesToReturn = scrobbles.size() - 1;

                scrobbleStartDatetime = Arrays
                        .toString(scrobbles.get(scrobblesToReturn).getScrobbleStartDatetime());
                result.setNext(BasicUtils.encodeToBase64(scrobbleStartDatetime));
            }

            for (int i = 0; i < scrobblesToReturn; i++) {
                ScrobbleBean scrobbleBean = new ScrobbleBean().updateBean(scrobbles.get(i));

                if (Boolean.TRUE.equals(deep)) {
                    retrieveCompleteScrobbleDetails(scrobbleBean);
                }

                result.getItems().add(scrobbleBean);
            }
        }

        return new StatusResponse<>(result);
    }

    /**
     * Deletes existing scrobble.<p>
     * Basic HTTP Authorization details are required. {@link GravifonError#NOT_ALLOWED NOT_ALLOWED} error is thrown
     * in case requested scrobble doesn't belong to user specified in authorization details.
     * 
     * @param httpHeaders request http headers and cookies
     * @param id scrobble identifier
     * @return status response
     */
    @DELETE
    @Path("{scrobble_id}")
    public StatusResponse delete(@Context HttpHeaders httpHeaders, @PathParam("scrobble_id") String id) {
        scrobbleDeleteValidator.validate(httpHeaders.getRequestHeaders(), null, null);

        ScrobbleDocument scrobble = scrobblesDBClient.retrieveScrobbleByID(id);

        if (scrobble == null) {
            throw new EntityNotFoundException("Scrobble not found.");
        }

        UserDocument user = ResourceUtils.authorizeUser(httpHeaders.getRequestHeaders(), scrobble.getUserId(),
                usersDBClient, LOGGER);

        ResourceUtils.checkUserStatus(user);

        scrobblesDBClient.delete(scrobble);

        return new StatusResponse();
    }

    /**
     * Retrieves artist document by artist name or creates a new one if nothing has been found.<p>
     * First (e.g. default) artist is taken if many variations exist. Proper one should be chosen by voting mechanism.
     * 
     * @param artist artist details bean
     * @return artist details document
     */
    private ArtistDocument retrieveOrCreateArtistDocument(ArtistBean artist) {
        ArtistDocument result;

        if (artist == null) {
            result = null;
        } else {
            List<ArtistDocument> artistDocs = artistsDBClient.retrieveArtistsByName(artist.getName());
            if (CollectionUtils.isEmpty(artistDocs)) {
                result = artistsDBClient.create(artist.createDocument());
            } else {
                // TODO think about this hardcode
                result = artistDocs.get(0);
            }
        }

        return result;
    }

    /**
     * Retrieves album document by album title or creates a new one if nothing has been found.<p>
     * First (e.g. default) album is taken if many variations exist. Proper one should be chosen by voting mechanism.<p>
     * Album artists are retrieved/created as well.
     * 
     * @param album album details bean
     * @return album details document
     */
    private AlbumDocument retrieveOrCreateAlbumDocument(AlbumBean album) {
        AlbumDocument result;

        if (album == null) {
            result = null;
        } else {
            List<AlbumDocument> albumDocs = albumsDBClient.retrieveAlbumsByKey(album.getKey());
            if (CollectionUtils.isEmpty(albumDocs)) {
                resolveArtistIds(album.getArtists());

                result = albumsDBClient.create(album.createDocument());
            } else {
                // TODO think about this hardcode
                result = albumDocs.get(0);
            }
        }

        return result;
    }

    /**
     * Retrieves track document by track title or creates a new one if nothing has been found.<p>
     * First (e.g. default) track is taken if many variations exist. Proper one should be chosen by voting mechanism.<p>
     * Track artists, track album and album artists are retrieved/created as well.
     * 
     * @param track track details bean
     * @return track details document
     */
    private TrackDocument retrieveOrCreateTrackDocument(TrackBean track) {
        TrackDocument result;

        if (track == null) {
            result = null;
        } else {
            List<String> key = track.getKey();
            // find exact track record
            List<TrackDocument> trackDocs = tracksDBClient.retrieveTracksByKey(key);
            if (CollectionUtils.isEmpty(trackDocs)) {
                resolveAlbumId(track.getAlbum());

                // find all tracks that belong to artists+album
                key.remove(key.size() - 1);
                trackDocs = tracksDBClient.retrieveTracksByIncompleteKey(key);
                // no tracks found for artists+album, so just need to use first found artists or create new ones
                if (CollectionUtils.isEmpty(trackDocs)) {
                    resolveArtistIds(track.getArtists());
                }
                // use existing artist identifiers from found tracks
                else {
                    for (String artistId : trackDocs.get(0).getArtistIds()) {
                        ArtistDocument artistDoc = artistsDBClient.retrieveArtistByID(artistId);
                        for (ArtistBean artist : track.getArtists()) {
                            if (StringUtils.equalsIgnoreCase(artist.getName(), artistDoc.getName())) {
                                artist.setId(artistDoc.getId());
                                break;
                            }
                        }
                    }
                }

                result = tracksDBClient.create(track.createDocument());
            } else {
                // TODO think about this hardcode
                result = trackDocs.get(0);
            }
        }

        return result;
    }

    /**
     * Updates artist details bean with artist identifier.<p>
     * Appropriate artist details document is retrieved (or created) by artist name.
     * 
     * @param artist artist details bean
     * 
     * @see #retrieveOrCreateArtistDocument(org.gravidence.gravifon.resource.bean.ArtistBean)
     */
    private void resolveArtistId(ArtistBean artist) {
        ArtistDocument artistDoc = retrieveOrCreateArtistDocument(artist);

        artist.updateBean(artistDoc);
    }

    /**
     * Updates artist details beans with appropriate artist identifiers.
     * 
     * @param artists artist details beans
     * 
     * @see #resolveArtistId(org.gravidence.gravifon.resource.bean.ArtistBean)
     */
    private void resolveArtistIds(List<ArtistBean> artists) {
        if (CollectionUtils.isNotEmpty(artists)) {
            for (ArtistBean artist : artists) {
                resolveArtistId(artist);
            }
        }
    }

    /**
     * Updates album details bean with album identifier.<p>
     * Appropriate album details document is retrieved (or created) by album title.
     * 
     * @param album album details bean
     * 
     * @see #retrieveOrCreateAlbumDocument(org.gravidence.gravifon.resource.bean.AlbumBean)
     */
    private void resolveAlbumId(AlbumBean album) {
        if (album != null) {
            AlbumDocument albumDoc = retrieveOrCreateAlbumDocument(album);

            album.updateBean(albumDoc);
        }
    }

    /**
     * Updates track details bean with track identifier.<p>
     * Appropriate track details document is retrieved (or created) by track title.
     * 
     * @param track track details bean
     * 
     * @see #retrieveOrCreateTrackDocument(org.gravidence.gravifon.resource.bean.TrackBean)
     */
    private void resolveTrackId(TrackBean track) {
        TrackDocument trackDoc = retrieveOrCreateTrackDocument(track);

        track.updateBean(trackDoc);
    }

    /**
     * Retrieves complete scrobble details and updates given bean with them.<p>
     * Scrobble document specifies just track identifier so track and child entities details
     * (e.g. track artists, album, album artists) are retrieved by this method.
     * 
     * @param scrobble scrobble details bean
     */
    private void retrieveCompleteScrobbleDetails(ScrobbleBean scrobble) {
        if (scrobble != null) {
            TrackDocument trackDocument = tracksDBClient.retrieveTrackByID(scrobble.getTrack().getId());

            TrackBean track = new TrackBean().updateBean(trackDocument);
            scrobble.setTrack(track);

            retrieveCompleteArtistsDetails(track.getArtists());

            // Album is optional attribute
            if (trackDocument.getAlbumId() != null) {
                AlbumDocument albumDocument = albumsDBClient.retrieveAlbumByID(trackDocument.getAlbumId());

                AlbumBean album = new AlbumBean().updateBean(albumDocument);
                track.setAlbum(album);

                retrieveCompleteArtistsDetails(album.getArtists());
            }
        }
    }

    /**
     * Retrieves complete artists details and updates given beans with them.<p>
     * Artist document specifies just artist identifier so artist details are retrieved by this method.
     * 
     * @param artists artist details beans
     */
    private void retrieveCompleteArtistsDetails(List<ArtistBean> artists) {
        if (CollectionUtils.isNotEmpty(artists)) {
            for (ArtistBean artist : artists) {
                ArtistDocument artistDocument = artistsDBClient.retrieveArtistByID(artist.getId());
                artist.updateBean(artistDocument);
            }
        }
    }

    /**
     * Checks that scrobble wasn't already processed.<p>
     * "Scrobble already processed" means that absolutely identical scrobble event start datetime exists in database.
     * 
     * @param userId user identifier
     * @param scrobble scrobble details bean to check
     * @throws GravifonException in case supplied scrobble classified as duplicate/fraud
     */
    private void checkForDuplicates(String userId, ScrobbleBean scrobble) throws GravifonException {
        List<ScrobbleDocument> history = scrobblesDBClient.retrieveScrobblesByKey(userId,
                scrobble.getScrobbleStartDatetime());
        if (CollectionUtils.isNotEmpty(history)) {
            for (ScrobbleDocument doc : history) {
                // Scrobble event start datetime already compared as part of complete key
                // so compare durations only
                if (ObjectUtils.equals(doc.getScrobbleDuration(),
                        DurationBean.getMillisAmount(scrobble.getScrobbleDuration()))) {
                    throw new GravifonException(GravifonError.DUPLICATE_SCROBBLE,
                            "Scrobble was already processed.");
                } else {
                    throw new GravifonException(GravifonError.FRAUD_SCROBBLE,
                            "Another scrobble was already processed at that time.");
                }
            }
        }
    }

    // TODO implement the logic
    private void checkForIntersections(String userId, ScrobbleBean scrobble) throws GravifonException {
        //        DateTime checkRangeStart = scrobble.getScrobbleStartDatetime().minusHours(3);
        //        DateTime checkRangeEnd = scrobble.getScrobbleEndDatetime().plusHours(3);
        //        
        //        // TODO take care of pagination
        //        List<ScrobbleDocument> history = scrobblesDBClient.retrieveScrobblesByUserIDAndDateRange(
        //                userId, null, checkRangeStart, checkRangeEnd, true, null);
    }

    /**
     * Converts raw query param value to datetime.
     * 
     * @param paramValue query param value
     * @param paramName query param name (to specify in error message if any)
     * @return extracted datetime
     * @throws ValidationException in case supplied query param value is not <code>null</code> and is not valid datetime
     */
    private DateTime extractDatetimeFromQueryParam(String paramValue, String paramName) throws ValidationException {
        DateTime result;

        if (paramValue == null) {
            result = null;
        } else {
            try {
                result = DateTime.parse(paramValue);
            } catch (Exception ex) {
                throw new ValidationException(GravifonError.INVALID,
                        String.format("Query param '%s' is invalid.", paramName));
            }
        }

        return result;
    }

}