ImdbSearchController.java

package org.xandercat.pmdb.controller;

import java.security.Principal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;

import javax.servlet.http.HttpSession;
import javax.validation.Valid;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.xandercat.pmdb.dto.Movie;
import org.xandercat.pmdb.dto.MovieCollection;
import org.xandercat.pmdb.dto.imdb.MovieDetailsRequest;
import org.xandercat.pmdb.dto.imdb.MovieDetails;
import org.xandercat.pmdb.dto.imdb.Result;
import org.xandercat.pmdb.dto.imdb.SearchRequest;
import org.xandercat.pmdb.dto.imdb.SearchResult;
import org.xandercat.pmdb.exception.WebServicesException;
import org.xandercat.pmdb.exception.CollectionSharingException;
import org.xandercat.pmdb.exception.ServiceLimitExceededException;
import org.xandercat.pmdb.form.imdb.SearchForm;
import org.xandercat.pmdb.service.CollectionService;
import org.xandercat.pmdb.service.ImdbSearchService;
import org.xandercat.pmdb.service.MovieService;
import org.xandercat.pmdb.util.Alerts;
import org.xandercat.pmdb.util.ViewUtil;
import org.xandercat.pmdb.util.ajax.JsonResponse;
import org.xandercat.pmdb.util.format.FormatUtil;

/**
 * Controller for functions involving IMDB search.  This includes IMDB browsing, adding new
 * movies using IMDB as the information source, and associating existing movie collection 
 * movies with movies on the IMDB.
 * 
 * @author Scott Arnold
 */
@Controller
public class ImdbSearchController {

	private static final Logger LOGGER = LogManager.getLogger(ImdbSearchController.class);
	private static final String SESSION_KEY_LAST_SEARCH = "imdbLastSearchString";
	public static final String IMDB_ID_PATTERN = "^tt[0-9]+$";
	
	@Autowired
	private ImdbSearchService imdbSearchService;
	
	@Autowired
	private MovieService movieService;
	
	@Autowired
	private CollectionService collectionService;
	
	@ModelAttribute("viewTab")
	public String getViewTab() {
		return ViewUtil.TAB_IMDB_SEARCH;
	}
	
	/**
	 * Page for performing IMDB search.
	 * 
	 * @param model    model
	 * @param session  session
	 * 
	 * @return page for performing IMDB search
	 */
	@RequestMapping("/imdbsearch")
	public String imdbSearch(Model model, HttpSession session) {
		model.addAttribute("searchForm", new SearchForm());
		Alerts.setSessionAlertWithKey(model, session, "IMDBSearchLimit", Alerts.AlertType.WARNING, "alert.imdbsearch.limits");
		return "imdbsearch/imdbSearch";
	}

	/**
	 * Handle linking operation on movie, returning final view to navigate to.
	 * 
	 * @param model       model
	 * @param principal   principal
	 * @param searchForm  search form
	 * @param result      binding result
	 * @param session     http session
	 * @param movie       movie being linked
	 * @return view to navigate to
	 * @throws ServiceLimitExceededException 
	 * @throws WebServicesException 
	 * @throws CollectionSharingException 
	 */
	private String handleLinkImdb(Model model, Principal principal, SearchForm searchForm, BindingResult result, HttpSession session, Movie movie) 
			throws WebServicesException, ServiceLimitExceededException, CollectionSharingException {
		String linkId = searchForm.getLinkImdbId().trim();
		if ("unlink".equals(linkId)) {
			imdbSearchService.removeImdbAttributes(movie);
			movieService.updateMovie(movie, principal.getName());	
		} else if (!"skip".equals(linkId)) {
			MovieDetails movieDetails = imdbSearchService.getMovieDetails(new MovieDetailsRequest(searchForm.getLinkImdbId().trim()));
			imdbSearchService.addImdbAttributes(movie, movieDetails);
			movieService.updateMovie(movie, principal.getName());
		}
		if (searchForm.isLinkAll()) {
			final String previousTitle = movie.getTitle().toLowerCase();
			List<Movie> unlinkedMovies = movieService.getUnlinkedMoviesForDefaultCollection(principal.getName());
			if (unlinkedMovies.size() > 0) {
				Optional<Movie> nextMovie = unlinkedMovies.stream()
						.filter(uMovie -> uMovie.getTitle().toLowerCase().compareTo(previousTitle) > 0)
						.findFirst();
				if (!nextMovie.isPresent()) {
					// wrap back around to beginning of list
					nextMovie = unlinkedMovies.stream().findFirst();
				}
				searchForm.setLinkMovieId(nextMovie.get().getId());
				searchForm.setLinkImdbId(null); // this is critical or it would trigger infinite recursion
				searchForm.setTitle(nextMovie.get().getTitle());
				searchForm.setPage(1);
				return imdbSearchSubmit(model, principal, searchForm, result, session);
			}
		}
		return "redirect:/";		
	}
	
	/**
	 * Handles link mode operations, storing movie to link in the model, and either returning final view to navigate to, 
	 * or empty if no actual link needs to be performed.
	 * 
	 * @param model       model
	 * @param principal   principal
	 * @param searchForm  search form
	 * @param result      binding result
	 * @param session     http session
	 * @return view to navigate to, or empty if unable to successfully handle linking
	 */
	private Optional<String> handleLinkMovie(Model model, Principal principal, SearchForm searchForm, BindingResult result, HttpSession session) {
		try {
			Optional<Movie> movie = movieService.getMovie(searchForm.getLinkMovieId(), principal.getName());
			collectionService.assertCollectionEditable(movie.get().getCollectionId(), principal.getName());
			if (FormatUtil.isNotBlank(searchForm.getLinkImdbId())) {
				// link movie and return to movie list or go to next unlinked movie
				return Optional.of(handleLinkImdb(model, principal, searchForm, result, session, movie.get()));
			}
			model.addAttribute("linkMovie", movie.get());
		} catch (Exception e) {
			LOGGER.error("Unable to get movie to link.", e);
			Alerts.setErrorMessage(model, "This movie cannot be linked.");
		}
		return Optional.empty();
	}
	
	/**
	 * Helper method to build a search result from a movie details pull.  This allows a user to search by IMDB id.  This can be 
	 * helpful if a movie cannot be found via the IMDB search service but can be found on the IMDB website itself.  Example:  the
	 * movie "F/X" cannot be found in the IMDB search service due to limitations of their API in handling the slash character, but can 
	 * be found on the IMDB site itself.
	 * 
	 * @param imdbId IMDB id
	 * 
	 * @return search result
	 * @throws WebServicesException
	 * @throws ServiceLimitExceededException
	 */
	private SearchResult buildSearchResultFromImdbId(String imdbId) throws WebServicesException, ServiceLimitExceededException {
		MovieDetailsRequest request = new MovieDetailsRequest(imdbId);
		MovieDetails movieDetails = imdbSearchService.getMovieDetails(request);
		SearchResult searchResult = new SearchResult();
		if (movieDetails == null) {
			searchResult.setTotalResults("0");
			searchResult.setResults(new ArrayList<Result>());
		} else {
			searchResult.setTotalResults("1");
			Result result = new Result();
			result.setImdbID(imdbId);
			result.setPoster(movieDetails.getPoster());
			result.setTitle(movieDetails.getTitle());
			result.setType(movieDetails.getType());
			result.setYear(movieDetails.getYear());
			searchResult.setResults(Collections.singletonList(result));
		}
		return searchResult;
	}
	
	/**
	 * Search the IMDB.
	 * 
	 * @param model       model
	 * @param principal   principal
	 * @param searchForm  search form
	 * @param result      binding result
	 * @param session     session
	 * 
	 * @return search results page
	 */
	@RequestMapping("/imdbsearch/searchSubmit")
	public String imdbSearchSubmit(Model model, Principal principal,
			@ModelAttribute("searchForm") @Valid SearchForm searchForm,
			BindingResult result, HttpSession session) {
		
		// handle alerts and errors
		Alerts.setSessionAlertWithKey(model, session, "IMDBSearchLimit", Alerts.AlertType.WARNING, "alert.imdbsearch.limits");
		if (result.hasErrors()) {
			return "imdbsearch/imdbSearch";
		}
		
		// handle mode where user is linking movies in their collection to IMDB
		if (FormatUtil.isNotBlank(searchForm.getLinkMovieId())) {
			Optional<String> returnView = handleLinkMovie(model, principal, searchForm, result, session);
			if (returnView.isPresent()) {
				return returnView.get();
			}
		}
		
		// execute search
		String title = searchForm.getTitle();
		String year = (FormatUtil.isBlank(searchForm.getYear()))? null : searchForm.getYear().trim();
		SearchResult searchResult = null;
		try {
			Integer previousHash = (Integer) session.getAttribute(SESSION_KEY_LAST_SEARCH);
			Integer currentHash = Integer.valueOf(searchForm.hashCode());
			if (!currentHash.equals(previousHash)) {
				// search criteria has changed; reset to page 1
				searchForm.setPage(1);
			}
			if (title.matches(IMDB_ID_PATTERN)) {
				// shortcut for finding movie by the IMDB ID when regular search is insufficient (the RapidAPI web service has some limitations on title format)
				searchResult = buildSearchResultFromImdbId(title);
			} else {
				searchResult = imdbSearchService.searchImdb(new SearchRequest(title, year, Integer.valueOf(searchForm.getPage())));
			}
			session.setAttribute(SESSION_KEY_LAST_SEARCH, currentHash);
			model.addAttribute("searched", Boolean.TRUE);
		} catch (ServiceLimitExceededException e) {
			Alerts.setErrorMessage(model, "The maximum number of allowed IMDB service calls for today has been reached.  Please retry at a later date.");
			return "imdbsearch/imdbSearch";
		} catch (WebServicesException wse) {
			// with a little more work we could still provide paginator, as sometimes only a specific page fails. (example: "dragon" page 3 results in error 5/23/2020)
			Alerts.setErrorMessage(model, "Search results could not be obtained for this page.");
			return "imdbsearch/imdbSearch";
		}
		List<Result> searchResults = searchResult.getResults();
		if (FormatUtil.isBlank(searchResult.getTotalResults())) {
			model.addAttribute("totalResults", Integer.valueOf(0));
		} else {
			model.addAttribute("totalResults", Integer.valueOf(searchResult.getTotalResults()));
		}
		model.addAttribute("searchResults", searchResults);
		
		// pass information about users default collection to view
		Optional<MovieCollection> defaultMovieCollection = collectionService.getDefaultMovieCollection(principal.getName());
		if (defaultMovieCollection.isPresent()) {
			model.addAttribute("defaultMovieCollection", defaultMovieCollection.get());
			if (searchResults != null && searchResults.size() > 0) {
				try {
					Set<String> imdbIdsInCollection = movieService.getImdbIdsInDefaultCollection(principal.getName());
					searchResults.stream()
							.filter(r -> imdbIdsInCollection.contains(r.getImdbID()))
							.forEach(r -> r.setInCollection(true));
				} catch (WebServicesException e) {
					LOGGER.error("Unable to read IMDB IDs from collection.", e);
					Alerts.setErrorMessage(model, "What movies you already have in your collection will not be indicated due to an error accessing your movie collection.");
				}
			}
		}
		return "imdbsearch/imdbSearch";
	}
	
	/**
	 * AJAX request to add movie of given IMDB id to the user's default/active movie collection.
	 * 
	 * @param model      model
	 * @param principal  principal
	 * @param imdbId     IMDB id for the movie to add to the movie collecction
	 * 
	 * @return JSON response with results of adding movie to the collection
	 */
	@RequestMapping(value="/imdbsearch/addToCollection", produces=MediaType.APPLICATION_JSON_VALUE)
	public @ResponseBody JsonResponse addToCollection(Model model, Principal principal, @RequestParam String imdbId) {
		LOGGER.debug("Add To Collection called with ID " + imdbId);
		JsonResponse response = new JsonResponse();
		response.put("imdbId", imdbId);
		MovieDetails movieDetails = null;
		try {
			movieDetails = imdbSearchService.getMovieDetails(new MovieDetailsRequest(imdbId));
		} catch (ServiceLimitExceededException e1) {
			response.setOk(false);
			response.setErrorMessage("Service limit exceeded. You will not be able to add any more movies to your collection today through the IMDB Search function.");
			return response;
		} catch (WebServicesException wse) {
			response.setOk(false);
			response.setErrorMessage("Unable to retrieve details for this movie from the IMDB.  Movie could not be added to your collection.");
			return response;
		}
		Optional<MovieCollection> movieCollection = collectionService.getDefaultMovieCollection(principal.getName());
		Movie movie = new Movie();
		movie.setCollectionId(movieCollection.get().getId());
		imdbSearchService.addImdbAttributes(movie, movieDetails);
		try {
			movieService.addMovie(movie, principal.getName());
		} catch (CollectionSharingException e) {
			LOGGER.error("Unable to add IMDB movie to collection.", e);
			response.setOk(false);
			response.setErrorMessage("You cannot add movies to the collection.");
			return response;
		} catch (WebServicesException e) {
			LOGGER.error("Unable to add IMDB movie to collection.", e);
			response.setOk(false);
			response.setErrorMessage("Unable to add movie to the collection due to a problem with cloud services.");
			return response;			
		}
		return response;
	}
	
	/**
	 * Process of linking a movie or movie collection to their corresponding movie or movies in the IMDB.
	 * 
	 * @param model      model
	 * @param principal  principal
	 * @param movieId    movie ID of movie to link, or "any" if should link first available unlinked movie
	 * @param linkAll    whether or not all movies in a movie collection are being linked
	 * @param session    session
	 * 
	 * @return search result page (executes automatic search for movie being linked)
	 */
	@RequestMapping("/imdbsearch/link")
	public String link(Model model, Principal principal, @RequestParam String movieId, @RequestParam boolean linkAll, HttpSession session) {
		try {
			if ("any".equals(movieId)) {
				Optional<Movie> anyMovie = movieService.getUnlinkedMoviesForDefaultCollection(principal.getName()).stream().findFirst();
				if (anyMovie.isPresent()) {
					movieId = anyMovie.get().getId();
				}
			}
			Optional<Movie> movie = movieService.getMovie(movieId, principal.getName());
			if (!movie.isPresent()) {
				Alerts.setErrorMessage(model, "Requested movie not found.");
				return imdbSearch(model, session);
			}
			collectionService.assertCollectionEditable(movie.get().getCollectionId(), principal.getName());
			SearchForm searchForm = new SearchForm();
			searchForm.setTitle(movie.get().getTitle());
			searchForm.setLinkMovieId(movie.get().getId());
			searchForm.setLinkAll(linkAll);
			model.addAttribute("searchForm", searchForm);
			return imdbSearchSubmit(model, principal, searchForm, ViewUtil.emptyBindingResult("searchForm"), session);
		} catch (Exception e) {
			LOGGER.error("Unable to access movie collection.", e);
			Alerts.setErrorMessage(model, "You cannot add movies to the collection.");
			return imdbSearch(model, session);
		} 
		
		
	}
}