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();
}
}
}