Package: MusicResourcesController$NotFoundException
MusicResourcesController$NotFoundException
name | instruction | branch | complexity | line | method | ||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
MusicResourcesController.NotFoundException() |
|
|
|
|
|
Coverage
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:
29: import javax.annotation.Nonnull;
30: import javax.annotation.Nullable;
31: import javax.inject.Inject;
32: import java.util.List;
33: import java.util.Optional;
34: import java.util.function.Function;
35: import java.util.stream.Stream;
36: import java.io.IOException;
37: import java.net.URLEncoder;
38: import org.springframework.core.io.support.ResourceRegion;
39: import org.springframework.http.HttpStatus;
40: import org.springframework.http.MediaType;
41: import org.springframework.http.ResponseEntity;
42: import org.springframework.web.bind.annotation.PathVariable;
43: import org.springframework.web.bind.annotation.RequestHeader;
44: import org.springframework.web.bind.annotation.RequestMapping;
45: import org.springframework.web.bind.annotation.RequestParam;
46: import org.springframework.web.bind.annotation.ResponseBody;
47: import org.springframework.web.bind.annotation.ResponseStatus;
48: import org.springframework.web.bind.annotation.RestController;
49: import it.tidalwave.util.Finder;
50: import it.tidalwave.util.Id;
51: import it.tidalwave.util.annotation.VisibleForTesting;
52: import it.tidalwave.messagebus.annotation.ListensTo;
53: import it.tidalwave.messagebus.annotation.SimpleMessageSubscriber;
54: import it.tidalwave.bluemarine2.model.MediaCatalog;
55: import it.tidalwave.bluemarine2.model.audio.AudioFile;
56: import it.tidalwave.bluemarine2.model.role.AudioFileSupplier;
57: import it.tidalwave.bluemarine2.model.spi.SourceAwareFinder;
58: import it.tidalwave.bluemarine2.message.PersistenceInitializedNotification;
59: import it.tidalwave.bluemarine2.rest.impl.resource.AudioFileResource;
60: import it.tidalwave.bluemarine2.rest.impl.resource.DetailedRecordResource;
61: import it.tidalwave.bluemarine2.rest.impl.resource.RecordResource;
62: import it.tidalwave.bluemarine2.rest.impl.resource.TrackResource;
63: import lombok.extern.slf4j.Slf4j;
64: import static java.util.stream.Collectors.*;
65: import static java.nio.charset.StandardCharsets.UTF_8;
66: import static org.springframework.http.HttpHeaders.*;
67: import static org.springframework.http.HttpStatus.*;
68: import static org.springframework.http.MediaType.*;
69: import static it.tidalwave.util.FunctionalCheckedExceptionWrappers.*;
70: import static it.tidalwave.role.ui.Displayable._Displayable_;
71: import static it.tidalwave.bluemarine2.model.MediaItem.Metadata.*;
72: import static it.tidalwave.bluemarine2.model.role.AudioFileSupplier._AudioFileSupplier_;
73:
74: /***********************************************************************************************************************
75: *
76: * @author Fabrizio Giudici
77: *
78: **********************************************************************************************************************/
79: @RestController @SimpleMessageSubscriber @Slf4j
80: public class MusicResourcesController
81: {
82: static interface Streamable<ENTITY, FINDER extends SourceAwareFinder<FINDER, ENTITY>> extends SourceAwareFinder<ENTITY, FINDER>
83: {
84: public Stream<ENTITY> stream();
85: }
86:
87: @ResponseStatus(value = NOT_FOUND)
88: static class NotFoundException extends RuntimeException
89: {
90: private static final long serialVersionUID = 3099300911009857337L;
91: }
92:
93: @ResponseStatus(value = SERVICE_UNAVAILABLE)
94: static class UnavailableException extends RuntimeException
95: {
96: private static final long serialVersionUID = 3644567083880573896L;
97: }
98:
99: @Inject
100: private MediaCatalog catalog;
101:
102: private volatile boolean persistenceInitialized;
103:
104: /*******************************************************************************************************************
105: *
106: *
107: ******************************************************************************************************************/
108: @VisibleForTesting void onPersistenceInitializedNotification (@ListensTo final PersistenceInitializedNotification notification)
109: throws IOException
110: {
111: log.info("onPersistenceInitializedNotification({})", notification);
112: persistenceInitialized = false;
113: }
114:
115: /*******************************************************************************************************************
116: *
117: * Exports record resources.
118: *
119: * @param source the data source
120: * @param fallback the fallback data source
121: * @return the JSON representation of the records
122: *
123: ******************************************************************************************************************/
124: @ResponseBody
125: @RequestMapping(value = "/record", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
126: public List<RecordResource> getRecords (@RequestParam(required = false, defaultValue = "embedded") final String source,
127: @RequestParam(required = false, defaultValue = "embedded") final String fallback)
128: {
129: log.info("getRecords({}, {})", source, fallback);
130: checkStatus();
131: return finalized(catalog.findRecords(), source, fallback, RecordResource::new);
132: }
133:
134: /*******************************************************************************************************************
135: *
136: * Exports a single record resource.
137: *
138: * @param id the record id
139: * @param source the data source
140: * @param fallback the fallback data source
141: * @return the JSON representation of the record
142: *
143: ******************************************************************************************************************/
144: @ResponseBody
145: @RequestMapping(value = "/record/{id}", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
146: public DetailedRecordResource getRecord (@PathVariable final String id,
147: @RequestParam(required = false, defaultValue = "embedded") final String source,
148: @RequestParam(required = false, defaultValue = "embedded") final String fallback)
149: {
150: log.info("getRecord({}, {}, {})", id, source, fallback);
151: checkStatus();
152: final List<TrackResource> tracks = finalized(catalog.findTracks().inRecord(Id.of(id)), source, fallback, TrackResource::new);
153: return single(finalized(catalog.findRecords().withId(Id.of(id)), source, fallback,
154: record -> new DetailedRecordResource(record, tracks)));
155: }
156:
157: /*******************************************************************************************************************
158: *
159: * Exports the cover art of a record.
160: *
161: * @param id the record id
162: * @return the cover art image
163: *
164: ******************************************************************************************************************/
165: @RequestMapping(value = "/record/{id}/coverart")
166: public ResponseEntity<byte[]> getRecordCoverArt (@PathVariable final String id)
167: {
168: log.info("getRecordCoverArt({})", id);
169: checkStatus();
170: return catalog.findTracks().inRecord(Id.of(id))
171: .stream()
172: .flatMap(track -> track.asMany(_AudioFileSupplier_).stream())
173: .map(AudioFileSupplier::getAudioFile)
174: .flatMap(af -> af.getMetadata().getAll(ARTWORK).stream())
175: .findAny()
176: .map(bytes -> bytesResponse(bytes, "image", "jpeg", "coverart.jpg"))
177: .orElseThrow(NotFoundException::new);
178: }
179:
180: /*******************************************************************************************************************
181: *
182: * Exports track resources.
183: *
184: * @param source the data source
185: * @param fallback the fallback data source
186: * @return the JSON representation of the tracks
187: *
188: ******************************************************************************************************************/
189: @ResponseBody
190: @RequestMapping(value = "/track", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
191: public List<TrackResource> getTracks (@RequestParam(required = false, defaultValue = "embedded") final String source,
192: @RequestParam(required = false, defaultValue = "embedded") final String fallback)
193: {
194: log.info("getTracks({}, {})", source, fallback);
195: checkStatus();
196: return finalized(catalog.findTracks(), source, fallback, TrackResource::new);
197: }
198:
199: /*******************************************************************************************************************
200: *
201: * Exports a single track resource.
202: *
203: * @param id the track id
204: * @param source the data source
205: * @param fallback the fallback data source
206: * @return the JSON representation of the track
207: *
208: ******************************************************************************************************************/
209: @ResponseBody
210: @RequestMapping(value = "/track/{id}", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
211: public TrackResource getTrack (@PathVariable final String id,
212: @RequestParam(required = false, defaultValue = "embedded") final String source,
213: @RequestParam(required = false, defaultValue = "embedded") final String fallback)
214: {
215: log.info("getTrack({}, {}, {})", id, source, fallback);
216: checkStatus();
217: return single(finalized(catalog.findTracks().withId(Id.of(id)), source, fallback, TrackResource::new));
218: }
219:
220: /*******************************************************************************************************************
221: *
222: * Exports audio file resources.
223: *
224: * @param source the data source
225: * @param fallback the fallback data source
226: * @return the JSON representation of the audio files
227: *
228: ******************************************************************************************************************/
229: @ResponseBody
230: @RequestMapping(value = "/audiofile", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
231: public List<AudioFileResource> getAudioFiles (@RequestParam(required = false, defaultValue = "embedded") final String source,
232: @RequestParam(required = false, defaultValue = "embedded") final String fallback)
233: {
234: log.info("getAudioFiles({}, {})", source, fallback);
235: checkStatus();
236: return finalized(catalog.findAudioFiles(), source, fallback, AudioFileResource::new);
237: }
238:
239: /*******************************************************************************************************************
240: *
241: * Exports a single audio file resource.
242: *
243: * @param id the audio file id
244: * @param source the data source
245: * @param fallback the fallback data source
246: * @return the JSON representation of the audio file
247: *
248: ******************************************************************************************************************/
249: @ResponseBody
250: @RequestMapping(value = "/audiofile/{id}", produces = { APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE })
251: public AudioFileResource getAudioFile (@PathVariable final String id,
252: @RequestParam(required = false, defaultValue = "embedded") final String source,
253: @RequestParam(required = false, defaultValue = "embedded") final String fallback)
254: {
255: log.info("getAudioFile({}, {}, {})", id, source, fallback);
256: checkStatus();
257: return single(finalized(catalog.findAudioFiles().withId(Id.of(id)), source, fallback, AudioFileResource::new));
258: }
259:
260: /*******************************************************************************************************************
261: *
262: * @param id the audio file id
263: * @param rangeHeader the "Range" HTTP header
264: * @return the binary contents
265: *
266: ******************************************************************************************************************/
267: @RequestMapping(value = "/audiofile/{id}/content")
268: public ResponseEntity<ResourceRegion> getAudioFileContent (
269: @PathVariable final String id,
270: @RequestHeader(name = "Range", required = false) final String rangeHeader)
271: {
272: log.info("getAudioFileContent({})", id);
273: checkStatus();
274: return catalog.findAudioFiles().withId(Id.of(id)).optionalResult()
275: .map(_f(af -> audioFileContentResponse(af, rangeHeader)))
276: .orElseThrow(NotFoundException::new);
277: }
278:
279: /*******************************************************************************************************************
280: *
281: * @param id the audio file id
282: * @return the binary contents
283: *
284: ******************************************************************************************************************/
285: @RequestMapping(value = "/audiofile/{id}/coverart")
286: public ResponseEntity<byte[]> getAudioFileCoverArt (@PathVariable final String id)
287: {
288: log.info("getAudioFileCoverArt({})", id);
289: checkStatus();
290: final Optional<AudioFile> audioFile = catalog.findAudioFiles().withId(Id.of(id)).optionalResult();
291: log.debug(">>>> audioFile: {}", audioFile);
292: return audioFile.flatMap(file -> file.getMetadata().getAll(ARTWORK).stream().findFirst())
293: .map(bytes -> bytesResponse(bytes, "image", "jpeg", "coverart.jpg"))
294: .orElseThrow(NotFoundException::new);
295: }
296:
297: /*******************************************************************************************************************
298: *
299: ******************************************************************************************************************/
300: @Nonnull
301: private <T> T single (@Nonnull final List<T> list)
302: {
303: if (list.isEmpty())
304: {
305: throw new NotFoundException();
306: }
307:
308: return list.get(0);
309: }
310:
311: /*******************************************************************************************************************
312: *
313: ******************************************************************************************************************/
314: @Nonnull
315: private <ENTITY, FINDER extends SourceAwareFinder<ENTITY, FINDER>, JSON>
316: List<JSON> finalized (@Nonnull final FINDER finder,
317: @Nonnull final String source,
318: @Nonnull final String fallback,
319: @Nonnull final Function<ENTITY, JSON> mapper)
320: {
321: final FINDER f = finder.importedFrom(Id.of(source)).withFallback(Id.of(fallback));
322: return ((Finder<ENTITY>)f) // FIXME: hacky, because SourceAwareFinder does not extends Finder
323: .stream()
324: .map(mapper)
325: .collect(toList());
326: }
327:
328: /*******************************************************************************************************************
329: *
330: ******************************************************************************************************************/
331: @Nonnull
332: private ResponseEntity<ResourceRegion> audioFileContentResponse (@Nonnull final AudioFile file,
333: @Nullable final String rangeHeader)
334: throws IOException
335: {
336: final long length = file.getSize();
337: final List<Range> ranges = Range.fromHeader(rangeHeader, length);
338:
339: if (ranges.size() > 1)
340: {
341: throw new RuntimeException("Can't support multi-range" + ranges); // FIXME
342: }
343:
344: // E.g. HTML5 <audio> crashes if fed with too many data.
345: final long maxSize = (rangeHeader != null) ? 1024*1024 : length;
346: final Range fullRange = Range.full(length);
347: final Range range = ranges.stream().findFirst().orElse(fullRange).subrange(maxSize);
348:
349: final String displayName = file.as(_Displayable_).getDisplayName(); // FIXME: getRdfsLabel()
350: final HttpStatus status = range.equals(fullRange) ? OK : PARTIAL_CONTENT;
351: return file.getContent().map(resource -> ResponseEntity.status(status)
352: .contentType(new MediaType("audio", "mpeg"))
353: .header(CONTENT_DISPOSITION, contentDisposition(displayName))
354: .body(range.getRegion(resource)))
355: .orElseThrow(NotFoundException::new);
356: }
357:
358: /*******************************************************************************************************************
359: *
360: ******************************************************************************************************************/
361: @Nonnull
362: private ResponseEntity<byte[]> bytesResponse (@Nonnull final byte[] bytes,
363: @Nonnull final String type,
364: @Nonnull final String subtype,
365: @Nonnull final String contentDisposition)
366: {
367: return ResponseEntity.ok()
368: .contentType(new MediaType(type, subtype))
369: .contentLength(bytes.length)
370: .header(CONTENT_DISPOSITION, contentDisposition(contentDisposition))
371: .body(bytes);
372: }
373:
374: /*******************************************************************************************************************
375: *
376: ******************************************************************************************************************/
377: @Nonnull
378: private static String contentDisposition (@Nonnull final String string)
379: {
380: // See https://tools.ietf.org/html/rfc6266#section-5
381: return String.format("filename=\"%s\"; filename*=utf-8''%s", string, URLEncoder.encode(string, UTF_8));
382: }
383:
384: /*******************************************************************************************************************
385: *
386: ******************************************************************************************************************/
387: private void checkStatus()
388: {
389: if (persistenceInitialized)
390: {
391: throw new UnavailableException();
392: }
393: }
394: }