MusicResourcesController.java

  1. /*
  2.  * *********************************************************************************************************************
  3.  *
  4.  * blueMarine II: Semantic Media Centre
  5.  * http://tidalwave.it/projects/bluemarine2
  6.  *
  7.  * Copyright (C) 2015 - 2021 by Tidalwave s.a.s. (http://tidalwave.it)
  8.  *
  9.  * *********************************************************************************************************************
  10.  *
  11.  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  12.  * the License. You may obtain a copy of the License at
  13.  *
  14.  *     http://www.apache.org/licenses/LICENSE-2.0
  15.  *
  16.  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  17.  * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the
  18.  * specific language governing permissions and limitations under the License.
  19.  *
  20.  * *********************************************************************************************************************
  21.  *
  22.  * git clone https://bitbucket.org/tidalwave/bluemarine2-src
  23.  * git clone https://github.com/tidalwave-it/bluemarine2-src
  24.  *
  25.  * *********************************************************************************************************************
  26.  */
  27. package it.tidalwave.bluemarine2.rest.impl;

  28. import javax.annotation.Nullable;
  29. import javax.annotation.Nonnull;
  30. import javax.inject.Inject;
  31. import java.util.List;
  32. import java.util.Optional;
  33. import java.util.function.Function;
  34. import java.util.stream.Stream;
  35. import java.io.IOException;
  36. import java.net.URLEncoder;
  37. import it.tidalwave.bluemarine2.model.role.AudioFileSupplier;
  38. import it.tidalwave.util.annotation.VisibleForTesting;
  39. import org.springframework.core.io.support.ResourceRegion;
  40. import org.springframework.http.HttpStatus;
  41. import org.springframework.http.MediaType;
  42. import org.springframework.http.ResponseEntity;
  43. import org.springframework.web.bind.annotation.PathVariable;
  44. import org.springframework.web.bind.annotation.RequestHeader;
  45. import org.springframework.web.bind.annotation.RequestMapping;
  46. import org.springframework.web.bind.annotation.RequestParam;
  47. import org.springframework.web.bind.annotation.ResponseBody;
  48. import org.springframework.web.bind.annotation.RestController;
  49. import org.springframework.web.bind.annotation.ResponseStatus;
  50. import it.tidalwave.util.Finder;
  51. import it.tidalwave.util.Id;
  52. import it.tidalwave.messagebus.annotation.ListensTo;
  53. import it.tidalwave.messagebus.annotation.SimpleMessageSubscriber;
  54. import it.tidalwave.bluemarine2.message.PersistenceInitializedNotification;
  55. import it.tidalwave.bluemarine2.model.MediaCatalog;
  56. import it.tidalwave.bluemarine2.model.audio.AudioFile;
  57. import it.tidalwave.bluemarine2.model.spi.SourceAwareFinder;
  58. import it.tidalwave.bluemarine2.rest.impl.resource.TrackResource;
  59. import it.tidalwave.bluemarine2.rest.impl.resource.RecordResource;
  60. import it.tidalwave.bluemarine2.rest.impl.resource.DetailedRecordResource;
  61. import it.tidalwave.bluemarine2.rest.impl.resource.AudioFileResource;
  62. import lombok.extern.slf4j.Slf4j;
  63. import static java.nio.charset.StandardCharsets.UTF_8;
  64. import static java.util.stream.Collectors.toList;
  65. import static org.springframework.http.HttpHeaders.*;
  66. import static org.springframework.http.HttpStatus.*;
  67. import static org.springframework.http.MediaType.*;
  68. import static it.tidalwave.role.ui.Displayable._Displayable_;
  69. import static it.tidalwave.util.FunctionalCheckedExceptionWrappers.*;
  70. import static it.tidalwave.bluemarine2.model.MediaItem.Metadata.ARTWORK;
  71. import static it.tidalwave.bluemarine2.model.role.AudioFileSupplier._AudioFileSupplier_;

  72. /***********************************************************************************************************************
  73.  *
  74.  * @author  Fabrizio Giudici
  75.  *
  76.  **********************************************************************************************************************/
  77. @RestController @SimpleMessageSubscriber @Slf4j
  78. public class MusicResourcesController
  79.   {
  80.     static interface Streamable<ENTITY, FINDER extends SourceAwareFinder<FINDER, ENTITY>> extends SourceAwareFinder<ENTITY, FINDER>
  81.       {
  82.         public Stream<ENTITY> stream();
  83.       }

  84.     @ResponseStatus(value = NOT_FOUND)
  85.     static class NotFoundException extends RuntimeException
  86.       {
  87.         private static final long serialVersionUID = 3099300911009857337L;
  88.       }

  89.     @ResponseStatus(value = SERVICE_UNAVAILABLE)
  90.     static class UnavailableException extends RuntimeException
  91.       {
  92.         private static final long serialVersionUID = 3644567083880573896L;
  93.       }

  94.     @Inject
  95.     private MediaCatalog catalog;

  96.     private volatile boolean persistenceInitialized;

  97.     /*******************************************************************************************************************
  98.      *
  99.      *
  100.      ******************************************************************************************************************/
  101.     @VisibleForTesting void onPersistenceInitializedNotification (@ListensTo final PersistenceInitializedNotification notification)
  102.       throws IOException
  103.       {
  104.         log.info("onPersistenceInitializedNotification({})", notification);
  105.         persistenceInitialized = false;
  106.       }

  107.     /*******************************************************************************************************************
  108.      *
  109.      * Exports record resources.
  110.      *
  111.      * @param   source      the data source
  112.      * @param   fallback    the fallback data source
  113.      * @return              the JSON representation of the records
  114.      *
  115.      ******************************************************************************************************************/
  116.     @ResponseBody
  117.     @RequestMapping(value = "/record", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
  118.     public List<RecordResource> getRecords (@RequestParam(required = false, defaultValue = "embedded") final String source,
  119.                                             @RequestParam(required = false, defaultValue = "embedded") final String fallback)
  120.       {
  121.         log.info("getRecords({}, {})", source, fallback);
  122.         checkStatus();
  123.         return finalized(catalog.findRecords(), source, fallback, RecordResource::new);
  124.       }

  125.     /*******************************************************************************************************************
  126.      *
  127.      * Exports a single record resource.
  128.      *
  129.      * @param   id          the record id
  130.      * @param   source      the data source
  131.      * @param   fallback    the fallback data source
  132.      * @return              the JSON representation of the record
  133.      *
  134.      ******************************************************************************************************************/
  135.     @ResponseBody
  136.     @RequestMapping(value = "/record/{id}", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
  137.     public DetailedRecordResource getRecord (@PathVariable final String id,
  138.                                              @RequestParam(required = false, defaultValue = "embedded") final String source,
  139.                                              @RequestParam(required = false, defaultValue = "embedded") final String fallback)
  140.       {
  141.         log.info("getRecord({}, {}, {})", id, source, fallback);
  142.         checkStatus();
  143.         final List<TrackResource> tracks = finalized(catalog.findTracks().inRecord(Id.of(id)), source, fallback, TrackResource::new);
  144.         return single(finalized(catalog.findRecords().withId(Id.of(id)), source, fallback,
  145.                                 record -> new DetailedRecordResource(record, tracks)));
  146.       }

  147.     /*******************************************************************************************************************
  148.      *
  149.      * Exports the cover art of a record.
  150.      *
  151.      * @param   id          the record id
  152.      * @return              the cover art image
  153.      *
  154.      ******************************************************************************************************************/
  155.     @RequestMapping(value = "/record/{id}/coverart")
  156.     public ResponseEntity<byte[]> getRecordCoverArt (@PathVariable final String id)
  157.       {
  158.         log.info("getRecordCoverArt({})", id);
  159.         checkStatus();
  160.         return catalog.findTracks().inRecord(Id.of(id))
  161.                                    .stream()
  162.                                    .flatMap(track -> track.asMany(_AudioFileSupplier_).stream())
  163.                                    .map(AudioFileSupplier::getAudioFile)
  164.                                    .flatMap(af -> af.getMetadata().getAll(ARTWORK).stream())
  165.                                    .findAny()
  166.                                    .map(bytes -> bytesResponse(bytes, "image", "jpeg", "coverart.jpg"))
  167.                                    .orElseThrow(NotFoundException::new);
  168.       }

  169.     /*******************************************************************************************************************
  170.      *
  171.      * Exports track resources.
  172.      *
  173.      * @param   source      the data source
  174.      * @param   fallback    the fallback data source
  175.      * @return              the JSON representation of the tracks
  176.      *
  177.      ******************************************************************************************************************/
  178.     @ResponseBody
  179.     @RequestMapping(value = "/track", produces  = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
  180.     public List<TrackResource> getTracks (@RequestParam(required = false, defaultValue = "embedded") final String source,
  181.                                           @RequestParam(required = false, defaultValue = "embedded") final String fallback)
  182.       {
  183.         log.info("getTracks({}, {})", source, fallback);
  184.         checkStatus();
  185.         return finalized(catalog.findTracks(), source, fallback, TrackResource::new);
  186.       }

  187.     /*******************************************************************************************************************
  188.      *
  189.      * Exports a single track resource.
  190.      *
  191.      * @param   id          the track id
  192.      * @param   source      the data source
  193.      * @param   fallback    the fallback data source
  194.      * @return              the JSON representation of the track
  195.      *
  196.      ******************************************************************************************************************/
  197.     @ResponseBody
  198.     @RequestMapping(value = "/track/{id}", produces  = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
  199.     public TrackResource getTrack (@PathVariable final String id,
  200.                                    @RequestParam(required = false, defaultValue = "embedded") final String source,
  201.                                    @RequestParam(required = false, defaultValue = "embedded") final String fallback)
  202.       {
  203.         log.info("getTrack({}, {}, {})", id, source, fallback);
  204.         checkStatus();
  205.         return single(finalized(catalog.findTracks().withId(Id.of(id)), source, fallback, TrackResource::new));
  206.       }

  207.     /*******************************************************************************************************************
  208.      *
  209.      * Exports audio file resources.
  210.      *
  211.      * @param   source      the data source
  212.      * @param   fallback    the fallback data source
  213.      * @return              the JSON representation of the audio files
  214.      *
  215.      ******************************************************************************************************************/
  216.     @ResponseBody
  217.     @RequestMapping(value = "/audiofile", produces  = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
  218.     public List<AudioFileResource> getAudioFiles (@RequestParam(required = false, defaultValue = "embedded") final String source,
  219.                                                   @RequestParam(required = false, defaultValue = "embedded") final String fallback)
  220.       {
  221.         log.info("getAudioFiles({}, {})", source, fallback);
  222.         checkStatus();
  223.         return finalized(catalog.findAudioFiles(), source, fallback, AudioFileResource::new);
  224.       }

  225.     /*******************************************************************************************************************
  226.      *
  227.      * Exports a single audio file resource.
  228.      *
  229.      * @param   id          the audio file id
  230.      * @param   source      the data source
  231.      * @param   fallback    the fallback data source
  232.      * @return              the JSON representation of the audio file
  233.      *
  234.      ******************************************************************************************************************/
  235.     @ResponseBody
  236.     @RequestMapping(value = "/audiofile/{id}", produces  = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
  237.     public AudioFileResource getAudioFile (@PathVariable final String id,
  238.                                            @RequestParam(required = false, defaultValue = "embedded") final String source,
  239.                                            @RequestParam(required = false, defaultValue = "embedded") final String fallback)
  240.       {
  241.         log.info("getAudioFile({}, {}, {})", id, source, fallback);
  242.         checkStatus();
  243.         return single(finalized(catalog.findAudioFiles().withId(Id.of(id)), source, fallback, AudioFileResource::new));
  244.       }

  245.     /*******************************************************************************************************************
  246.      *
  247.      * @param   id          the audio file id
  248.      * @param   rangeHeader the "Range" HTTP header
  249.      * @return              the binary contents
  250.      *
  251.      ******************************************************************************************************************/
  252.     @RequestMapping(value = "/audiofile/{id}/content")
  253.     public ResponseEntity<ResourceRegion> getAudioFileContent (
  254.             @PathVariable final String id,
  255.             @RequestHeader(name = "Range", required = false) final String rangeHeader)
  256.       {
  257.         log.info("getAudioFileContent({})", id);
  258.         checkStatus();
  259.         return catalog.findAudioFiles().withId(Id.of(id)).optionalResult()
  260.                                                           .map(_f(af -> audioFileContentResponse(af, rangeHeader)))
  261.                                                           .orElseThrow(NotFoundException::new);
  262.       }

  263.     /*******************************************************************************************************************
  264.      *
  265.      * @param   id          the audio file id
  266.      * @return              the binary contents
  267.      *
  268.      ******************************************************************************************************************/
  269.     @RequestMapping(value = "/audiofile/{id}/coverart")
  270.     public ResponseEntity<byte[]> getAudioFileCoverArt (@PathVariable final String id)
  271.       {
  272.         log.info("getAudioFileCoverArt({})", id);
  273.         checkStatus();
  274.         final Optional<AudioFile> audioFile = catalog.findAudioFiles().withId(Id.of(id)).optionalResult();
  275.         log.debug(">>>> audioFile: {}", audioFile);
  276.         return audioFile.flatMap(file -> file.getMetadata().getAll(ARTWORK).stream().findFirst())
  277.                         .map(bytes -> bytesResponse(bytes, "image", "jpeg", "coverart.jpg"))
  278.                         .orElseThrow(NotFoundException::new);
  279.       }

  280.     /*******************************************************************************************************************
  281.      *
  282.      ******************************************************************************************************************/
  283.     @Nonnull
  284.     private <T> T single (@Nonnull final List<T> list)
  285.       {
  286.         if (list.isEmpty())
  287.           {
  288.             throw new NotFoundException();
  289.           }

  290.         return list.get(0);
  291.       }

  292.     /*******************************************************************************************************************
  293.      *
  294.      ******************************************************************************************************************/
  295.     @Nonnull
  296.     private <ENTITY, FINDER extends SourceAwareFinder<ENTITY, FINDER>, JSON>
  297.         List<JSON> finalized (@Nonnull final FINDER finder,
  298.                               @Nonnull final String source,
  299.                               @Nonnull final String fallback,
  300.                               @Nonnull final Function<ENTITY, JSON> mapper)
  301.       {
  302.         final FINDER f = finder.importedFrom(Id.of(source)).withFallback(Id.of(fallback));
  303.         return ((Finder<ENTITY>)f) // FIXME: hacky, because SourceAwareFinder does not extends Finder
  304.                      .stream()
  305.                      .map(mapper)
  306.                      .collect(toList());
  307.       }

  308.     /*******************************************************************************************************************
  309.      *
  310.      ******************************************************************************************************************/
  311.     @Nonnull
  312.     private ResponseEntity<ResourceRegion> audioFileContentResponse (@Nonnull final AudioFile file,
  313.                                                                      @Nullable final String rangeHeader)
  314.       throws IOException
  315.       {
  316.         final long length = file.getSize();
  317.         final List<Range> ranges = Range.fromHeader(rangeHeader, length);

  318.         if (ranges.size() > 1)
  319.           {
  320.             throw new RuntimeException("Can't support multi-range" + ranges); // FIXME
  321.           }

  322.         // E.g. HTML5 <audio> crashes if fed with too many data.
  323.         final long maxSize = (rangeHeader != null) ? 1024*1024 : length;
  324.         final Range fullRange = Range.full(length);
  325.         final Range range = ranges.stream().findFirst().orElse(fullRange).subrange(maxSize);

  326.         final String displayName = file.as(_Displayable_).getDisplayName(); // FIXME: getRdfsLabel()
  327.         final HttpStatus status = range.equals(fullRange) ? OK : PARTIAL_CONTENT;
  328.         return file.getContent().map(resource -> ResponseEntity.status(status)
  329.                                                             .contentType(new MediaType("audio", "mpeg"))
  330.                                                             .header(CONTENT_DISPOSITION, contentDisposition(displayName))
  331.                                                             .body(range.getRegion(resource)))
  332.                                 .orElseThrow(NotFoundException::new);
  333.       }

  334.     /*******************************************************************************************************************
  335.      *
  336.      ******************************************************************************************************************/
  337.     @Nonnull
  338.     private ResponseEntity<byte[]> bytesResponse (@Nonnull final byte[] bytes,
  339.                                                   @Nonnull final String type,
  340.                                                   @Nonnull final String subtype,
  341.                                                   @Nonnull final String contentDisposition)
  342.       {
  343.         return ResponseEntity.ok()
  344.                              .contentType(new MediaType(type, subtype))
  345.                              .contentLength(bytes.length)
  346.                              .header(CONTENT_DISPOSITION, contentDisposition(contentDisposition))
  347.                              .body(bytes);
  348.       }

  349.     /*******************************************************************************************************************
  350.      *
  351.      ******************************************************************************************************************/
  352.     @Nonnull
  353.     private static String contentDisposition (@Nonnull final String string)
  354.       {
  355.         // See https://tools.ietf.org/html/rfc6266#section-5
  356.         return String.format("filename=\"%s\"; filename*=utf-8''%s", string, URLEncoder.encode(string, UTF_8));
  357.       }

  358.     /*******************************************************************************************************************
  359.      *
  360.      ******************************************************************************************************************/
  361.     private void checkStatus()
  362.       {
  363.         if (persistenceInitialized)
  364.           {
  365.             throw new UnavailableException();
  366.           }
  367.       }
  368.   }