MusicResourcesController.java
- /*
- * *********************************************************************************************************************
- *
- * blueMarine II: Semantic Media Centre
- * http://tidalwave.it/projects/bluemarine2
- *
- * Copyright (C) 2015 - 2021 by Tidalwave s.a.s. (http://tidalwave.it)
- *
- * *********************************************************************************************************************
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
- * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations under the License.
- *
- * *********************************************************************************************************************
- *
- * git clone https://bitbucket.org/tidalwave/bluemarine2-src
- * git clone https://github.com/tidalwave-it/bluemarine2-src
- *
- * *********************************************************************************************************************
- */
- package it.tidalwave.bluemarine2.rest.impl;
- import javax.annotation.Nullable;
- import javax.annotation.Nonnull;
- import javax.inject.Inject;
- import java.util.List;
- import java.util.Optional;
- import java.util.function.Function;
- import java.util.stream.Stream;
- import java.io.IOException;
- import java.net.URLEncoder;
- import it.tidalwave.bluemarine2.model.role.AudioFileSupplier;
- import it.tidalwave.util.annotation.VisibleForTesting;
- import org.springframework.core.io.support.ResourceRegion;
- import org.springframework.http.HttpStatus;
- import org.springframework.http.MediaType;
- import org.springframework.http.ResponseEntity;
- import org.springframework.web.bind.annotation.PathVariable;
- import org.springframework.web.bind.annotation.RequestHeader;
- import org.springframework.web.bind.annotation.RequestMapping;
- import org.springframework.web.bind.annotation.RequestParam;
- import org.springframework.web.bind.annotation.ResponseBody;
- import org.springframework.web.bind.annotation.RestController;
- import org.springframework.web.bind.annotation.ResponseStatus;
- import it.tidalwave.util.Finder;
- import it.tidalwave.util.Id;
- import it.tidalwave.messagebus.annotation.ListensTo;
- import it.tidalwave.messagebus.annotation.SimpleMessageSubscriber;
- import it.tidalwave.bluemarine2.message.PersistenceInitializedNotification;
- import it.tidalwave.bluemarine2.model.MediaCatalog;
- import it.tidalwave.bluemarine2.model.audio.AudioFile;
- import it.tidalwave.bluemarine2.model.spi.SourceAwareFinder;
- import it.tidalwave.bluemarine2.rest.impl.resource.TrackResource;
- import it.tidalwave.bluemarine2.rest.impl.resource.RecordResource;
- import it.tidalwave.bluemarine2.rest.impl.resource.DetailedRecordResource;
- import it.tidalwave.bluemarine2.rest.impl.resource.AudioFileResource;
- import lombok.extern.slf4j.Slf4j;
- import static java.nio.charset.StandardCharsets.UTF_8;
- import static java.util.stream.Collectors.toList;
- import static org.springframework.http.HttpHeaders.*;
- import static org.springframework.http.HttpStatus.*;
- import static org.springframework.http.MediaType.*;
- import static it.tidalwave.role.ui.Displayable._Displayable_;
- import static it.tidalwave.util.FunctionalCheckedExceptionWrappers.*;
- import static it.tidalwave.bluemarine2.model.MediaItem.Metadata.ARTWORK;
- import static it.tidalwave.bluemarine2.model.role.AudioFileSupplier._AudioFileSupplier_;
- /***********************************************************************************************************************
- *
- * @author Fabrizio Giudici
- *
- **********************************************************************************************************************/
- @RestController @SimpleMessageSubscriber @Slf4j
- public class MusicResourcesController
- {
- static interface Streamable<ENTITY, FINDER extends SourceAwareFinder<FINDER, ENTITY>> extends SourceAwareFinder<ENTITY, FINDER>
- {
- public Stream<ENTITY> stream();
- }
- @ResponseStatus(value = NOT_FOUND)
- static class NotFoundException extends RuntimeException
- {
- private static final long serialVersionUID = 3099300911009857337L;
- }
- @ResponseStatus(value = SERVICE_UNAVAILABLE)
- static class UnavailableException extends RuntimeException
- {
- private static final long serialVersionUID = 3644567083880573896L;
- }
- @Inject
- private MediaCatalog catalog;
- private volatile boolean persistenceInitialized;
- /*******************************************************************************************************************
- *
- *
- ******************************************************************************************************************/
- @VisibleForTesting void onPersistenceInitializedNotification (@ListensTo final PersistenceInitializedNotification notification)
- throws IOException
- {
- log.info("onPersistenceInitializedNotification({})", notification);
- persistenceInitialized = false;
- }
- /*******************************************************************************************************************
- *
- * Exports record resources.
- *
- * @param source the data source
- * @param fallback the fallback data source
- * @return the JSON representation of the records
- *
- ******************************************************************************************************************/
- @ResponseBody
- @RequestMapping(value = "/record", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
- public List<RecordResource> getRecords (@RequestParam(required = false, defaultValue = "embedded") final String source,
- @RequestParam(required = false, defaultValue = "embedded") final String fallback)
- {
- log.info("getRecords({}, {})", source, fallback);
- checkStatus();
- return finalized(catalog.findRecords(), source, fallback, RecordResource::new);
- }
- /*******************************************************************************************************************
- *
- * Exports a single record resource.
- *
- * @param id the record id
- * @param source the data source
- * @param fallback the fallback data source
- * @return the JSON representation of the record
- *
- ******************************************************************************************************************/
- @ResponseBody
- @RequestMapping(value = "/record/{id}", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
- public DetailedRecordResource getRecord (@PathVariable final String id,
- @RequestParam(required = false, defaultValue = "embedded") final String source,
- @RequestParam(required = false, defaultValue = "embedded") final String fallback)
- {
- log.info("getRecord({}, {}, {})", id, source, fallback);
- checkStatus();
- final List<TrackResource> tracks = finalized(catalog.findTracks().inRecord(Id.of(id)), source, fallback, TrackResource::new);
- return single(finalized(catalog.findRecords().withId(Id.of(id)), source, fallback,
- record -> new DetailedRecordResource(record, tracks)));
- }
- /*******************************************************************************************************************
- *
- * Exports the cover art of a record.
- *
- * @param id the record id
- * @return the cover art image
- *
- ******************************************************************************************************************/
- @RequestMapping(value = "/record/{id}/coverart")
- public ResponseEntity<byte[]> getRecordCoverArt (@PathVariable final String id)
- {
- log.info("getRecordCoverArt({})", id);
- checkStatus();
- return catalog.findTracks().inRecord(Id.of(id))
- .stream()
- .flatMap(track -> track.asMany(_AudioFileSupplier_).stream())
- .map(AudioFileSupplier::getAudioFile)
- .flatMap(af -> af.getMetadata().getAll(ARTWORK).stream())
- .findAny()
- .map(bytes -> bytesResponse(bytes, "image", "jpeg", "coverart.jpg"))
- .orElseThrow(NotFoundException::new);
- }
- /*******************************************************************************************************************
- *
- * Exports track resources.
- *
- * @param source the data source
- * @param fallback the fallback data source
- * @return the JSON representation of the tracks
- *
- ******************************************************************************************************************/
- @ResponseBody
- @RequestMapping(value = "/track", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
- public List<TrackResource> getTracks (@RequestParam(required = false, defaultValue = "embedded") final String source,
- @RequestParam(required = false, defaultValue = "embedded") final String fallback)
- {
- log.info("getTracks({}, {})", source, fallback);
- checkStatus();
- return finalized(catalog.findTracks(), source, fallback, TrackResource::new);
- }
- /*******************************************************************************************************************
- *
- * Exports a single track resource.
- *
- * @param id the track id
- * @param source the data source
- * @param fallback the fallback data source
- * @return the JSON representation of the track
- *
- ******************************************************************************************************************/
- @ResponseBody
- @RequestMapping(value = "/track/{id}", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
- public TrackResource getTrack (@PathVariable final String id,
- @RequestParam(required = false, defaultValue = "embedded") final String source,
- @RequestParam(required = false, defaultValue = "embedded") final String fallback)
- {
- log.info("getTrack({}, {}, {})", id, source, fallback);
- checkStatus();
- return single(finalized(catalog.findTracks().withId(Id.of(id)), source, fallback, TrackResource::new));
- }
- /*******************************************************************************************************************
- *
- * Exports audio file resources.
- *
- * @param source the data source
- * @param fallback the fallback data source
- * @return the JSON representation of the audio files
- *
- ******************************************************************************************************************/
- @ResponseBody
- @RequestMapping(value = "/audiofile", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
- public List<AudioFileResource> getAudioFiles (@RequestParam(required = false, defaultValue = "embedded") final String source,
- @RequestParam(required = false, defaultValue = "embedded") final String fallback)
- {
- log.info("getAudioFiles({}, {})", source, fallback);
- checkStatus();
- return finalized(catalog.findAudioFiles(), source, fallback, AudioFileResource::new);
- }
- /*******************************************************************************************************************
- *
- * Exports a single audio file resource.
- *
- * @param id the audio file id
- * @param source the data source
- * @param fallback the fallback data source
- * @return the JSON representation of the audio file
- *
- ******************************************************************************************************************/
- @ResponseBody
- @RequestMapping(value = "/audiofile/{id}", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
- public AudioFileResource getAudioFile (@PathVariable final String id,
- @RequestParam(required = false, defaultValue = "embedded") final String source,
- @RequestParam(required = false, defaultValue = "embedded") final String fallback)
- {
- log.info("getAudioFile({}, {}, {})", id, source, fallback);
- checkStatus();
- return single(finalized(catalog.findAudioFiles().withId(Id.of(id)), source, fallback, AudioFileResource::new));
- }
- /*******************************************************************************************************************
- *
- * @param id the audio file id
- * @param rangeHeader the "Range" HTTP header
- * @return the binary contents
- *
- ******************************************************************************************************************/
- @RequestMapping(value = "/audiofile/{id}/content")
- public ResponseEntity<ResourceRegion> getAudioFileContent (
- @PathVariable final String id,
- @RequestHeader(name = "Range", required = false) final String rangeHeader)
- {
- log.info("getAudioFileContent({})", id);
- checkStatus();
- return catalog.findAudioFiles().withId(Id.of(id)).optionalResult()
- .map(_f(af -> audioFileContentResponse(af, rangeHeader)))
- .orElseThrow(NotFoundException::new);
- }
- /*******************************************************************************************************************
- *
- * @param id the audio file id
- * @return the binary contents
- *
- ******************************************************************************************************************/
- @RequestMapping(value = "/audiofile/{id}/coverart")
- public ResponseEntity<byte[]> getAudioFileCoverArt (@PathVariable final String id)
- {
- log.info("getAudioFileCoverArt({})", id);
- checkStatus();
- final Optional<AudioFile> audioFile = catalog.findAudioFiles().withId(Id.of(id)).optionalResult();
- log.debug(">>>> audioFile: {}", audioFile);
- return audioFile.flatMap(file -> file.getMetadata().getAll(ARTWORK).stream().findFirst())
- .map(bytes -> bytesResponse(bytes, "image", "jpeg", "coverart.jpg"))
- .orElseThrow(NotFoundException::new);
- }
- /*******************************************************************************************************************
- *
- ******************************************************************************************************************/
- @Nonnull
- private <T> T single (@Nonnull final List<T> list)
- {
- if (list.isEmpty())
- {
- throw new NotFoundException();
- }
- return list.get(0);
- }
- /*******************************************************************************************************************
- *
- ******************************************************************************************************************/
- @Nonnull
- private <ENTITY, FINDER extends SourceAwareFinder<ENTITY, FINDER>, JSON>
- List<JSON> finalized (@Nonnull final FINDER finder,
- @Nonnull final String source,
- @Nonnull final String fallback,
- @Nonnull final Function<ENTITY, JSON> mapper)
- {
- final FINDER f = finder.importedFrom(Id.of(source)).withFallback(Id.of(fallback));
- return ((Finder<ENTITY>)f) // FIXME: hacky, because SourceAwareFinder does not extends Finder
- .stream()
- .map(mapper)
- .collect(toList());
- }
- /*******************************************************************************************************************
- *
- ******************************************************************************************************************/
- @Nonnull
- private ResponseEntity<ResourceRegion> audioFileContentResponse (@Nonnull final AudioFile file,
- @Nullable final String rangeHeader)
- throws IOException
- {
- final long length = file.getSize();
- final List<Range> ranges = Range.fromHeader(rangeHeader, length);
- if (ranges.size() > 1)
- {
- throw new RuntimeException("Can't support multi-range" + ranges); // FIXME
- }
- // E.g. HTML5 <audio> crashes if fed with too many data.
- final long maxSize = (rangeHeader != null) ? 1024*1024 : length;
- final Range fullRange = Range.full(length);
- final Range range = ranges.stream().findFirst().orElse(fullRange).subrange(maxSize);
- final String displayName = file.as(_Displayable_).getDisplayName(); // FIXME: getRdfsLabel()
- final HttpStatus status = range.equals(fullRange) ? OK : PARTIAL_CONTENT;
- return file.getContent().map(resource -> ResponseEntity.status(status)
- .contentType(new MediaType("audio", "mpeg"))
- .header(CONTENT_DISPOSITION, contentDisposition(displayName))
- .body(range.getRegion(resource)))
- .orElseThrow(NotFoundException::new);
- }
- /*******************************************************************************************************************
- *
- ******************************************************************************************************************/
- @Nonnull
- private ResponseEntity<byte[]> bytesResponse (@Nonnull final byte[] bytes,
- @Nonnull final String type,
- @Nonnull final String subtype,
- @Nonnull final String contentDisposition)
- {
- return ResponseEntity.ok()
- .contentType(new MediaType(type, subtype))
- .contentLength(bytes.length)
- .header(CONTENT_DISPOSITION, contentDisposition(contentDisposition))
- .body(bytes);
- }
- /*******************************************************************************************************************
- *
- ******************************************************************************************************************/
- @Nonnull
- private static String contentDisposition (@Nonnull final String string)
- {
- // See https://tools.ietf.org/html/rfc6266#section-5
- return String.format("filename=\"%s\"; filename*=utf-8''%s", string, URLEncoder.encode(string, UTF_8));
- }
- /*******************************************************************************************************************
- *
- ******************************************************************************************************************/
- private void checkStatus()
- {
- if (persistenceInitialized)
- {
- throw new UnavailableException();
- }
- }
- }