diff --git a/airsonic-main/src/main/java/org/airsonic/player/command/MusicFolderSettingsCommand.java b/airsonic-main/src/main/java/org/airsonic/player/command/MusicFolderSettingsCommand.java index 392799953..ee7290131 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/command/MusicFolderSettingsCommand.java +++ b/airsonic-main/src/main/java/org/airsonic/player/command/MusicFolderSettingsCommand.java @@ -53,7 +53,7 @@ public class MusicFolderSettingsCommand { private String excludePatternString; private boolean ignoreSymLinks; private boolean enableCueIndexing; - private boolean hideIndexedFiles; + private boolean hideVirtualTracks; private Boolean fullScan; private Boolean clearFullScanSettingAfterScan; @@ -149,12 +149,12 @@ public void setEnableCueIndexing(boolean enableCueIndexing) { this.enableCueIndexing = enableCueIndexing; } - public boolean getHideIndexedFiles() { - return hideIndexedFiles; + public boolean isHideVirtualTracks() { + return hideVirtualTracks; } - public void setHideIndexedFiles(boolean hideIndexedFiles) { - this.hideIndexedFiles = hideIndexedFiles; + public void setHideVirtualTracks(boolean hideVirtualTracks) { + this.hideVirtualTracks = hideVirtualTracks; } public void setIgnoreSymLinks(boolean ignoreSymLinks) { diff --git a/airsonic-main/src/main/java/org/airsonic/player/config/AirsonicCueConfig.java b/airsonic-main/src/main/java/org/airsonic/player/config/AirsonicCueConfig.java index e36b7fef4..c3fc3b8dd 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/config/AirsonicCueConfig.java +++ b/airsonic-main/src/main/java/org/airsonic/player/config/AirsonicCueConfig.java @@ -1,5 +1,7 @@ package org.airsonic.player.config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -7,6 +9,8 @@ @ConfigurationProperties(prefix = "airsonic.cue") public class AirsonicCueConfig { + private static final Logger LOG = LoggerFactory.getLogger(AirsonicCueConfig.class); + // properties private boolean enabled; private boolean hideIndexedFiles; @@ -24,6 +28,7 @@ public void setEnabled(boolean enabled) { } public void setHideIndexedFiles(boolean hideIndexedFiles) { + LOG.warn("deprecated property 'airsonic.cue.hide-indexed-files'. Use 'airsonic.hide-virtual-tracks' instead."); this.hideIndexedFiles = hideIndexedFiles; } } diff --git a/airsonic-main/src/main/java/org/airsonic/player/config/AirsonicDefaultFolderConfig.java b/airsonic-main/src/main/java/org/airsonic/player/config/AirsonicFolderConfig.java similarity index 91% rename from airsonic-main/src/main/java/org/airsonic/player/config/AirsonicDefaultFolderConfig.java rename to airsonic-main/src/main/java/org/airsonic/player/config/AirsonicFolderConfig.java index 8eff4035d..970b4089a 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/config/AirsonicDefaultFolderConfig.java +++ b/airsonic-main/src/main/java/org/airsonic/player/config/AirsonicFolderConfig.java @@ -25,7 +25,7 @@ @Component @ConfigurationProperties(prefix = "airsonic") -public class AirsonicDefaultFolderConfig { +public class AirsonicFolderConfig { // constants private static final String DEFAULT_MUSIC_FOLDER_WINDOWS = "c:\\music"; @@ -41,6 +41,10 @@ public class AirsonicDefaultFolderConfig { private String defaultPodcastFolder = Util.isWindows() ? DEFAULT_PODCAST_FOLDER_WINDOWS : DEFAULT_PODCAST_FOLDER_OTHER; // airsonic.defaultPlaylistFolder private String defaultPlaylistFolder = Util.isWindows() ? DEFAULT_PLAYLIST_FOLDER_WINDOWS : DEFAULT_PLAYLIST_FOLDER_OTHER; + // airsonic.hideVirtualTracks + private boolean hideVirtualTracks = true; + + /** * Returns the directory @@ -69,6 +73,10 @@ public String getDefaultPlaylistFolder() { return defaultPlaylistFolder; } + public boolean isHideVirtualTracks() { + return hideVirtualTracks; + } + public void setDefaultMusicFolder(String defaultMusicFolder) { this.defaultMusicFolder = getFolderProperty(defaultMusicFolder, DEFAULT_MUSIC_FOLDER_WINDOWS, DEFAULT_MUSIC_FOLDER_OTHER); } @@ -80,4 +88,8 @@ public void setDefaultPodcastFolder(String defaultPodcastFolder) { public void setDefaultPlaylistFolder(String defaultPlaylistFolder) { this.defaultPlaylistFolder = getFolderProperty(defaultPlaylistFolder, DEFAULT_PLAYLIST_FOLDER_WINDOWS, DEFAULT_PLAYLIST_FOLDER_OTHER); } + + public void setHideVirtualTracks(boolean hideVirtualTracks) { + this.hideVirtualTracks = hideVirtualTracks; + } } diff --git a/airsonic-main/src/main/java/org/airsonic/player/controller/MusicFolderSettingsController.java b/airsonic-main/src/main/java/org/airsonic/player/controller/MusicFolderSettingsController.java index a92d5d209..367096458 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/controller/MusicFolderSettingsController.java +++ b/airsonic-main/src/main/java/org/airsonic/player/controller/MusicFolderSettingsController.java @@ -114,7 +114,7 @@ protected void formBackingObject(@RequestParam(value = "scanNow", required = fal command.setExcludePatternString(settingsService.getExcludePatternString()); command.setIgnoreSymLinks(settingsService.getIgnoreSymLinks()); command.setEnableCueIndexing(settingsService.getEnableCueIndexing()); - command.setHideIndexedFiles(settingsService.getEnableCueIndexing() && settingsService.getHideIndexedFiles()); + command.setHideVirtualTracks(settingsService.getHideVirtualTracks()); command.setFullScan(settingsService.getFullScan()); command.setClearFullScanSettingAfterScan(!settingsService.getFullScan() ? settingsService.getFullScan() : settingsService.getClearFullScanSettingAfterScan()); @@ -207,7 +207,7 @@ protected String onSubmit(@ModelAttribute("command") MusicFolderSettingsCommand settingsService.setExcludePatternString(command.getExcludePatternString()); settingsService.setIgnoreSymLinks(command.getIgnoreSymLinks()); settingsService.setEnableCueIndexing(command.isEnableCueIndexing()); - settingsService.setHideIndexedFiles(command.isEnableCueIndexing() && command.getHideIndexedFiles()); + settingsService.setHideVirtualTracks(command.isHideVirtualTracks()); settingsService.setFullScan(command.getFullScan()); settingsService.setClearFullScanSettingAfterScan(!command.getFullScan() ? command.getFullScan() : command.getClearFullScanSettingAfterScan()); settingsService.save(); diff --git a/airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java b/airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java index 354126d84..801bfcdfd 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java +++ b/airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java @@ -49,10 +49,12 @@ import org.springframework.util.StreamUtils; import org.springframework.web.bind.ServletRequestBindingException; import org.springframework.web.bind.ServletRequestUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import jakarta.servlet.http.HttpServletRequest; @@ -279,6 +281,11 @@ public ResponseEntity handleRequest(Authentication authentication, return ResponseEntity.ok().headers(headers).body(resource); } + @ExceptionHandler(AsyncRequestNotUsableException.class) + public void handleAsyncRequestNotUsableException(AsyncRequestNotUsableException e) { + LOG.info("Client Aborted"); + } + private void scrobble(MediaFile mediaFile, Player player, boolean submission) { // Don't scrobble REST players (except Sonos) if (player.getClientId() == null || player.getClientId().equals(SonosHelper.AIRSONIC_CLIENT_ID)) { diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/CoverArtCreateService.java b/airsonic-main/src/main/java/org/airsonic/player/service/CoverArtCreateService.java index c15b86680..5bc3c7130 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/CoverArtCreateService.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/CoverArtCreateService.java @@ -75,9 +75,6 @@ public class CoverArtCreateService { @Autowired private CoverArtService coverArtService; - @Autowired - private JaudiotaggerParser jaudiotaggerParser; - @Autowired private PlaylistService playlistService; @@ -315,7 +312,7 @@ private InputStream getImageInputStream(CoverArt art) throws IOException { public Pair getImageInputStreamWithType(Path file) throws IOException { InputStream is; String mimeType; - if (jaudiotaggerParser.isApplicable(file)) { + if (JaudiotaggerParser.isImageAvailable(file)) { LOG.trace("Using Jaudio Tagger for reading artwork from {}", file); try { LOG.trace("Reading artwork from file {}", file); diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/MediaFileService.java b/airsonic-main/src/main/java/org/airsonic/player/service/MediaFileService.java index 49e90468d..98b4c513c 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/MediaFileService.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/MediaFileService.java @@ -20,6 +20,7 @@ */ package org.airsonic.player.service; +import com.google.common.math.DoubleMath; import com.ibm.icu.text.CharsetDetector; import com.ibm.icu.text.CharsetMatch; import org.airsonic.player.ajax.MediaFileEntry; @@ -38,6 +39,8 @@ import org.airsonic.player.repository.OffsetBasedPageRequest; import org.airsonic.player.repository.StarredMediaFileRepository; import org.airsonic.player.service.cache.MediaFileCache; +import org.airsonic.player.service.metadata.Chapter; +import org.airsonic.player.service.metadata.FFmpegParser; import org.airsonic.player.service.metadata.JaudiotaggerParser; import org.airsonic.player.service.metadata.MetaData; import org.airsonic.player.service.metadata.MetaDataParser; @@ -64,6 +67,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; + import java.io.BufferedInputStream; import java.io.FileInputStream; import java.io.IOException; @@ -102,8 +108,6 @@ public class MediaFileService { @Autowired private AlbumRepository albumRepository; @Autowired - private JaudiotaggerParser parser; - @Autowired private MetaDataParserFactory metaDataParserFactory; @Autowired private CoverArtService coverArtService; @@ -121,6 +125,10 @@ public class MediaFileService { private GenreRepository genreRepository; @Autowired private MediaFileCache mediaFileCache; + @Autowired + private FFmpegParser ffmpegParser; + + private final double DURATION_EPSILON = 1e-2; public MediaFile getMediaFile(String pathName) { return getMediaFile(Paths.get(pathName)); @@ -722,128 +730,142 @@ public void populateStarredDate(MediaFile mediaFile, String username) { mediaFile.setStarredDate(starredDate); } - private List updateChildren(MediaFile parent) { + @Nullable + private List updateChildren(@Nonnull MediaFile parent) { // Check timestamps. if (parent.getChildrenLastUpdated().compareTo(parent.getChanged()) >= 0) { return null; } - Map, MediaFile> storedChildrenMap = mediaFileRepository.findByFolderAndParentPath(parent.getFolder(), parent.getPath(), Sort.by("startPosition")).parallelStream() - .collect(Collectors.toConcurrentMap(i -> Pair.of(i.getPath(), i.getStartPosition()), i -> i)); MusicFolder folder = parent.getFolder(); + Map, MediaFile> storedChildrenMap = mediaFileRepository.findByFolderAndParentPath(folder, parent.getPath(), Sort.by("startPosition")).parallelStream() + .collect(Collectors.toConcurrentMap(i -> Pair.of(i.getPath(), i.getStartPosition()), i -> i)); boolean isEnableCueIndexing = settingsService.getEnableCueIndexing(); + // cue sheets Map cueSheets = new ConcurrentHashMap<>(); + // media files that are not indexed + Map bareFiles = new ConcurrentHashMap<>(); + // m4b files are treated as audio books + Map audioBooks = new ConcurrentHashMap<>(); - if (isEnableCueIndexing) { - LOG.debug("Cue indexing enabled"); - try (Stream children = Files.list(parent.getFullPath())) { - children.parallel() - .filter(x -> { - return "cue".equalsIgnoreCase(FilenameUtils.getExtension(x.toString())) || "flac".equalsIgnoreCase(FilenameUtils.getExtension(x.toString())); - }) - .forEach(x -> { - CueSheet cueSheet = getCueSheet(x); - if (cueSheet != null && cueSheet.getFileData() != null && cueSheet.getFileData().size() > 0) { - cueSheets.put(folder.getPath().relativize(x).toString(), cueSheet); - } - }); - } catch (IOException e) { - LOG.warn("Error reading FLAC embedded cue sheets (ignored)", e); - // ignore - } - } - - // collect files, if any + // Collect all children. try (Stream children = Files.list(parent.getFullPath())) { - Map bareFiles = children.parallel() - .filter(this::includeMediaFileByPath) + children.parallel() .filter(x -> mediaFolderService.getMusicFolderForFile(x, true, true).map(f -> f.equals(folder)).orElse(false)) - .map(x -> folder.getPath().relativize(x)) - .map(x -> { - MediaFile media = storedChildrenMap.remove(Pair.of(x.toString(), MediaFile.NOT_INDEXED)); - if (media == null) { - media = createMediaFileByFile(x, folder); + .forEach(x -> { + Path relativePath = folder.getPath().relativize(x); + if (includeMediaFileByPath(x)) { + MediaFile mediaFile = storedChildrenMap.remove(Pair.of(relativePath.toString(), MediaFile.NOT_INDEXED)); + if (mediaFile == null) { // Not found in database, must read from disk. + mediaFile = createMediaFileByFile(relativePath, folder); + if (mediaFile != null) { + updateMediaFile(mediaFile); + } + } else if (!mediaFile.hasIndex()) { + mediaFile = checkLastModified(mediaFile, false); // has to be false, only time it's called + } // Add children that are not already stored. - if (media != null) { - updateMediaFile(media); + if (mediaFile != null) { + if ("m4b".equals(mediaFile.getFormat())) { + audioBooks.put(relativePath.toString(), mediaFile); + } else { + bareFiles.put(FilenameUtils.getName(mediaFile.getPath()), mediaFile); + } } - } else { - if (!media.hasIndex()) { - media = checkLastModified(media, false); // has to be false, only time it's called + return; + } else if (isEnableCueIndexing) { + LOG.debug("Cue indexing enabled"); + CueSheet cueSheet = getCueSheet(x); + if (cueSheet != null) { + cueSheets.put(relativePath.toString(), cueSheet); } + return; } - return media; - }) - .collect(Collectors.toConcurrentMap(m -> FilenameUtils.getName(m.getPath()), m -> m)); - - // collect indexed tracks, if any - List result = new ArrayList<>(); - - if (isEnableCueIndexing) { - List indexedTracks = cueSheets.entrySet().parallelStream().flatMap(e -> { - String indexPath = e.getKey(); - CueSheet cueSheet = e.getValue(); - - String filePath = cueSheet.getFileData().get(0).getFile(); - MediaFile base = bareFiles.remove(FilenameUtils.getName(filePath)); - - if (Objects.nonNull(base)) { - base.setIndexPath(indexPath); // update indexPath in mediaFile - Instant mediaChanged = FileUtil.lastModified(base.getFullPath()); - Instant cueChanged = FileUtil.lastModified(base.getFullIndexPath()); - base.setChanged(mediaChanged.compareTo(cueChanged) >= 0 ? mediaChanged : cueChanged); - updateMediaFile(base); - List tracks = createIndexedTracks(base, cueSheet); - // remove stored children that are now indexed - tracks.forEach(t -> storedChildrenMap.remove(Pair.of(t.getPath(), t.getStartPosition()))); - tracks.add(base); - return tracks.stream(); - } else { - LOG.warn("Cue sheet file {} not found", filePath); - return Stream.empty(); - } - }).collect(Collectors.toList()); - result.addAll(indexedTracks); - } + }); + } catch (IOException e) { + LOG.warn("Could not retrieve and update all the children for {} in folder {}. Will skip", parent.getPath(), folder.getId(), e); + return null; + } - // remove indexPath for deleted cuesheets, if any - List nonIndexedTracks = bareFiles.values().stream().parallel() - .map(m -> { - if (m.hasIndex()) { - m.setIndexPath(null); - updateMediaFile(m); - } - return m; - }) - .collect(Collectors.toList()); - result.addAll(nonIndexedTracks); + // collect indexed tracks, if any + List result = new ArrayList<>(); - // Delete children that no longer exist on disk. - storedChildrenMap.values().forEach(f -> delete(f)); + // cue tracks + if (isEnableCueIndexing) { + List indexedTracks = cueSheets.entrySet().parallelStream().flatMap(e -> { + String indexPath = e.getKey(); + CueSheet cueSheet = e.getValue(); + + String filePath = cueSheet.getFileData().get(0).getFile(); + MediaFile base = bareFiles.remove(FilenameUtils.getName(filePath)); + + if (Objects.nonNull(base)) { + base.setIndexPath(indexPath); // update indexPath in mediaFile + Instant mediaChanged = FileUtil.lastModified(base.getFullPath()); + Instant cueChanged = FileUtil.lastModified(base.getFullIndexPath()); + base.setChanged(mediaChanged.compareTo(cueChanged) >= 0 ? mediaChanged : cueChanged); + updateMediaFile(base); + List tracks = createIndexedTracks(base, cueSheet); + // remove stored children that are now indexed + tracks.forEach(t -> storedChildrenMap.remove(Pair.of(t.getPath(), t.getStartPosition()))); + tracks.add(base); + return tracks.stream(); + } else { + LOG.warn("Cue sheet file {} not found", filePath); + return Stream.empty(); + } + }).collect(Collectors.toList()); + result.addAll(indexedTracks); + } - // Update timestamp in parent. - parent.setChildrenLastUpdated(parent.getChanged()); - parent.setPresent(true); - updateMediaFile(parent); + // m4b audio books + List audioBookTracks = audioBooks.entrySet().parallelStream().flatMap(e -> { + String basePath = e.getKey(); + MediaFile base = e.getValue(); + List tracks = createAudioBookTracks(base); + if (!CollectionUtils.isEmpty(tracks)) { + base.setIndexPath(basePath); // update indexPath in mediaFile + updateMediaFile(base); + } + tracks.forEach(t -> storedChildrenMap.remove(Pair.of(t.getPath(), t.getStartPosition()))); + tracks.add(base); + return tracks.stream(); + }).collect(Collectors.toList()); + result.addAll(audioBookTracks); + + // remove indexPath for deleted cuesheets, if any + List nonIndexedTracks = bareFiles.values().stream().parallel() + .map(m -> { + if (m.hasIndex()) { + m.setIndexPath(null); + updateMediaFile(m); + } + return m; + }) + .collect(Collectors.toList()); + result.addAll(nonIndexedTracks); - return result; + // Delete children that no longer exist on disk. + storedChildrenMap.values().forEach(f -> delete(f)); - } catch (Exception e) { - LOG.warn("Could not retrieve and update all the children for {} in folder {}. Will skip", parent.getPath(), folder.getId(), e); + // Update timestamp in parent. + parent.setChildrenLastUpdated(parent.getChanged()); + parent.setPresent(true); + updateMediaFile(parent); - return null; - } + return result; } /** * hide specific file types in player and API */ public boolean showMediaFile(MediaFile media) { - return (settingsService.getEnableCueIndexing() || media.getStartPosition() == MediaFile.NOT_INDEXED) && - !(settingsService.getHideIndexedFiles() && media.hasIndex()); + boolean isRealMedia = !(media.hasIndex() || media.isIndexedTrack()); + boolean isHidden = settingsService.getHideVirtualTracks() ^ media.isIndexedTrack(); + return isRealMedia || isHidden; } private boolean includeMediaFile(MediaFile candidate) { @@ -1028,6 +1050,100 @@ private MediaFile updateMediaFileByFile(MediaFile mediaFile, boolean isCheckedEx } + /** + * Returns m4b audio book tracks from the given audio book file. + * + * @param base The audio book file. + * @return The audio book tracks. + */ + @Nonnull + private List createAudioBookTracks(@Nonnull MediaFile base) { + List children = new ArrayList<>(); + Path audioFile = base.getFullPath(); + Map storedChildrenMap = new ConcurrentHashMap<>(); + + try { + List chapters = ffmpegParser.getMetaData(audioFile).getChapters(); + if (CollectionUtils.isEmpty(chapters)) { + return children; + } + // get existing children + MusicFolder baseFolder = base.getFolder(); + String basePath = base.getPath(); + storedChildrenMap = mediaFileRepository.findByFolderAndPath(baseFolder, basePath).parallelStream() + .filter(MediaFile::isIndexedTrack) + .collect(Collectors.toConcurrentMap(i -> Math.round(i.getStartPosition() * 10), i -> i)); + + boolean update = needsUpdate(base, settingsService.isFastCacheEnabled()); + + // get base properties + Instant lastModified = FileUtil.lastModified(audioFile); + Instant childrenLastUpdated = Instant.now().plusSeconds(100 * 365 * 24 * 60 * 60); // now + 100 years, + // tracks do not have + // children + + for (Chapter chapter : chapters) { + if (chapter.getStartTimeSeconds() == null || chapter.getEndTimeSeconds() == null) { + continue; + } + Double duration = chapter.getEndTimeSeconds() - chapter.getStartTimeSeconds(); + MediaFile existingFile = storedChildrenMap.remove(Math.round(chapter.getStartTimeSeconds() * 10)); + if (existingFile != null + && !DoubleMath.fuzzyEquals(existingFile.getDuration(), duration, DURATION_EPSILON)) { + existingFile = null; + } + MediaFile track = existingFile; + if (update || existingFile == null) { + track = existingFile != null ? existingFile : new MediaFile(); + track.setPath(basePath); + track.setAlbumArtist(base.getAlbumArtist()); + track.setAlbumName(base.getAlbumName()); + track.setTitle(chapter.getTitle()); + track.setArtist(base.getArtist()); + track.setParentPath(base.getParentPath()); + track.setFolder(baseFolder); + track.setChanged(lastModified); + track.setLastScanned(Instant.now()); + track.setChildrenLastUpdated(childrenLastUpdated); + track.setCreated(lastModified); + track.setPresent(true); + track.setStartPosition(chapter.getStartTimeSeconds()); + track.setDuration(duration); + track.setTrackNumber(chapter.getId()); + track.setDiscNumber(base.getDiscNumber()); + track.setGenre(base.getGenre()); + track.setYear(base.getYear()); + track.setBitRate(base.getBitRate()); + track.setVariableBitRate(base.isVariableBitRate()); + track.setHeight(base.getHeight()); + track.setWidth(base.getWidth()); + track.setFormat(base.getFormat()); + track.setMusicBrainzRecordingId(base.getMusicBrainzRecordingId()); + track.setMusicBrainzReleaseId(base.getMusicBrainzReleaseId()); + long estimatedSize = (long) (duration / base.getDuration() * Files.size(audioFile)); + if (estimatedSize > 0 && estimatedSize < Files.size(audioFile)) { + track.setFileSize(estimatedSize); + } else { + track.setFileSize(Files.size(audioFile) / chapters.size()); + } + track.setPlayCount((existingFile == null) ? 0 : existingFile.getPlayCount()); + track.setLastPlayed((existingFile == null) ? null : existingFile.getLastPlayed()); + track.setComment((existingFile == null) ? null : existingFile.getComment()); + track.setMediaType(base.getMediaType()); + + updateMediaFile(track); + } + children.add(track); + } + return children; + } catch (Exception e) { + LOG.warn("Could not retrieve chapters for {}.", audioFile.toString(), e); + return new ArrayList<>(); + } finally { + storedChildrenMap.values().forEach(this::delete); + } + } + private List createIndexedTracks(MediaFile base, CueSheet cueSheet) { Map, MediaFile> storedChildrenMap = mediaFileRepository.findByFolderAndPath(base.getFolder(), base.getPath()).parallelStream() @@ -1206,6 +1322,7 @@ public void setMemoryCacheEnabled(boolean memoryCacheEnabled) { */ private CueSheet getCueSheet(Path cueFile) { try { + CueSheet cueSheet = null; switch (FilenameUtils.getExtension(cueFile.toString()).toLowerCase()) { case "cue": Charset cs = Charset.forName("UTF-8"); // default to UTF-8 @@ -1222,18 +1339,24 @@ private CueSheet getCueSheet(Path cueFile) { } catch (IOException e) { LOG.warn("Defaulting to UTF-8 for cuesheet {}", cueFile); } - CueSheet cueSheet = CueParser.parse(cueFile, cs); + cueSheet = CueParser.parse(cueFile, cs); if (cueSheet.getMessages().stream().filter(m -> m.toString().toLowerCase().contains("warning")).findFirst().isPresent()) { LOG.warn("Error parsing cuesheet {}", cueFile); return null; } - return cueSheet; + break; case "flac": - return FLACReader.getCueSheet(cueFile); - + cueSheet = FLACReader.getCueSheet(cueFile); + break; default: return null; } + // validation + if (cueSheet == null || cueSheet.getFileData() == null || cueSheet.getFileData().size() == 0) { + LOG.warn("Error parsing cuesheet {}", cueFile); + return null; + } + return cueSheet; } catch (IOException e) { LOG.warn("Error getting cuesheet for {} ", cueFile); return null; @@ -1293,7 +1416,6 @@ private Path findFileCover(Collection candidates) { private Path findTagCover(Collection candidates) { // Look for embedded images in audiofiles. return candidates.stream() - .filter(parser::isApplicable) .filter(JaudiotaggerParser::isImageAvailable) .findFirst() .orElse(null); @@ -1330,10 +1452,7 @@ public void setMetaDataParserFactory(MetaDataParserFactory metaDataParserFactory } @Transactional(isolation = Isolation.READ_COMMITTED) - public void updateMediaFile(MediaFile mediaFile) { - if (mediaFile == null) { - throw new IllegalArgumentException("mediaFile must not be null"); - } + public void updateMediaFile(@Nonnull MediaFile mediaFile) { mediaFileCache.removeMediaFile(mediaFile); if (mediaFile.getId() != null && mediaFileRepository.existsById(mediaFile.getId())) { mediaFileRepository.save(mediaFile); diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/SettingsService.java b/airsonic-main/src/main/java/org/airsonic/player/service/SettingsService.java index a6684114c..0d6c50d6d 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/SettingsService.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/SettingsService.java @@ -25,7 +25,7 @@ import com.google.common.cache.LoadingCache; import com.google.common.util.concurrent.RateLimiter; import org.airsonic.player.config.AirsonicCueConfig; -import org.airsonic.player.config.AirsonicDefaultFolderConfig; +import org.airsonic.player.config.AirsonicFolderConfig; import org.airsonic.player.config.AirsonicHomeConfig; import org.airsonic.player.domain.*; import org.airsonic.player.service.sonos.SonosServiceRegistration; @@ -141,7 +141,7 @@ public class SettingsService { private static final String KEY_EXPORT_PLAYLIST_FORMAT = "PlaylistExportFormat"; private static final String KEY_IGNORE_SYMLINKS = "IgnoreSymLinks"; private static final String KEY_ENABLE_CUE_INDEXING = "EnableCueIndexing"; - private static final String KEY_HIDE_INDEXED_FILES = "HideIndexedFiles"; + private static final String KEY_HIDE_VIRTUAL_TRACKS = "HideIndexedFiles"; private static final String KEY_EXCLUDE_PATTERN_STRING = "ExcludePattern"; private static final String KEY_CAPTCHA_ENABLED = "CaptchaEnabled"; @@ -259,6 +259,9 @@ public class SettingsService { private static final String LOCALES_FILE = "/org/airsonic/player/i18n/locales.txt"; private static final String THEMES_FILE = "/org/airsonic/player/theme/themes.txt"; + // deprecated property + private static final String KEY_HIDE_INDEXED_FILES = "HideIndexedFiles"; + private static final Logger LOG = LoggerFactory.getLogger(SettingsService.class); private List themes; @@ -268,7 +271,7 @@ public class SettingsService { @Autowired private AirsonicHomeConfig homeConfig; @Autowired - private AirsonicDefaultFolderConfig defaultFolderConfig; + private AirsonicFolderConfig defaultFolderConfig; @Autowired private AirsonicCueConfig cueConfig; @@ -315,6 +318,7 @@ public static Map getMigratedPropertyKeys() { keyMaps.put("IgnoreFileTimestamps", KEY_FULL_SCAN); keyMaps.put("MediaScannerParallelism", "AIRSONIC_SCAN_PARALLELISM"); + keyMaps.put(KEY_HIDE_INDEXED_FILES, KEY_HIDE_VIRTUAL_TRACKS); return keyMaps; } @@ -366,7 +370,7 @@ public static void setDefaultConstants(Environment env, Map defa env.getProperty("libresonic.home") ); - AirsonicDefaultFolderConfig defaultFolderConfig = new AirsonicDefaultFolderConfig(); + AirsonicFolderConfig defaultFolderConfig = new AirsonicFolderConfig(); defaultFolderConfig.setDefaultMusicFolder(env.getProperty("airsonic.defaultMusicFolder")); defaultFolderConfig.setDefaultPodcastFolder(env.getProperty("airsonic.defaultPodcastFolder")); defaultFolderConfig.setDefaultPlaylistFolder(env.getProperty("airsonic.defaultPlaylistFolder")); @@ -434,6 +438,9 @@ private boolean isExecutableInstalled(String executable) { */ public String resolveTranscodeExecutable(String executable, String defaultValue) { try { + if (executable == null) { + return defaultValue; + } return transcodeExecutableCache.get(executable); } catch (Exception e) { return defaultValue; @@ -1079,12 +1086,12 @@ public void setEnableCueIndexing(boolean b) { setBoolean(KEY_ENABLE_CUE_INDEXING, b); } - public boolean getHideIndexedFiles() { - return getBoolean(KEY_HIDE_INDEXED_FILES, cueConfig.isHideIndexedFiles()); + public boolean getHideVirtualTracks() { + return getBoolean(KEY_HIDE_VIRTUAL_TRACKS, defaultFolderConfig.isHideVirtualTracks()); } - public void setHideIndexedFiles(boolean b) { - setBoolean(KEY_HIDE_INDEXED_FILES, b); + public void setHideVirtualTracks(boolean b) { + setBoolean(KEY_HIDE_VIRTUAL_TRACKS, b); } public String getExcludePatternString() { @@ -1503,7 +1510,7 @@ protected void setAirsonicConfig(AirsonicHomeConfig homeConfig) { this.homeConfig = homeConfig; } - protected void setAirsonicDefaultFolderConfig(AirsonicDefaultFolderConfig airsonicDefaultFolderConfig) { + protected void setAirsonicDefaultFolderConfig(AirsonicFolderConfig airsonicDefaultFolderConfig) { this.defaultFolderConfig = airsonicDefaultFolderConfig; } diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/metadata/Chapter.java b/airsonic-main/src/main/java/org/airsonic/player/service/metadata/Chapter.java new file mode 100644 index 000000000..0acd2c254 --- /dev/null +++ b/airsonic-main/src/main/java/org/airsonic/player/service/metadata/Chapter.java @@ -0,0 +1,120 @@ +package org.airsonic.player.service.metadata; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.commons.lang3.StringUtils; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class Chapter { + + private Integer id; + + @JsonProperty("time_base") + private String timeBase; + + private Long start; + + @JsonProperty("start_time") + private String startTime; + + private Long end; + + @JsonProperty("end_time") + private String endTime; + + private String title; + + private Map tags = new HashMap<>(); + + public Chapter() { + } + + public Chapter(Integer id, String timeBase, Long start, String startTime, Long end, String endTime, String title, + Map tags) { + this.id = id; + this.timeBase = StringUtils.trimToNull(timeBase); + this.start = start; + this.startTime = StringUtils.trimToNull(startTime); + this.end = end; + this.endTime = StringUtils.trimToNull(endTime); + this.title = StringUtils.trimToNull(title); + this.tags = tags == null ? new HashMap<>() : tags; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getTimeBase() { + return timeBase; + } + + public void setTimeBase(String timeBase) { + this.timeBase = timeBase; + } + + public Long getStart() { + return start; + } + + public void setStart(Long start) { + this.start = start; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public Double getStartTimeSeconds() { + return Optional.ofNullable(startTime).map(Double::parseDouble).orElse(null); + } + + public Long getEnd() { + return end; + } + + public void setEnd(Long end) { + this.end = end; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public Double getEndTimeSeconds() { + return Optional.ofNullable(endTime).map(Double::parseDouble).orElse(null); + } + + public String getTitle() { + if (title == null) { + return this.tags.get("title"); + } + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public Map getTags() { + return tags; + } + + public void setTags(Map tags) { + this.tags = tags; + } +} diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/metadata/FFmpegParser.java b/airsonic-main/src/main/java/org/airsonic/player/service/metadata/FFmpegParser.java index 2ae528a12..0b9eb5bbc 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/metadata/FFmpegParser.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/metadata/FFmpegParser.java @@ -21,6 +21,7 @@ package org.airsonic.player.service.metadata; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; import org.airsonic.player.domain.MediaFile; import org.airsonic.player.service.MediaFolderService; @@ -56,7 +57,7 @@ public class FFmpegParser extends MetaDataParser { private static final Logger LOG = LoggerFactory.getLogger(FFmpegParser.class); private static final String[] FFPROBE_OPTIONS = { - "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams" + "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters" }; @Autowired @@ -124,6 +125,11 @@ public MetaData getRawMetaData(Path file) { metaData.setHeight(stream.get("height").asInt()); } } + ObjectMapper mapper = Util.getObjectMapper(); + for (JsonNode chapterJson : result.at("/chapters")) { + Chapter chapter = mapper.convertValue(chapterJson, Chapter.class); + metaData.addChapter(chapter); + } } catch (Throwable x) { LOG.warn("Error when parsing metadata in {}", file, x); } diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/metadata/JaudiotaggerParser.java b/airsonic-main/src/main/java/org/airsonic/player/service/metadata/JaudiotaggerParser.java index bb0ad8bf2..57a34c8b1 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/metadata/JaudiotaggerParser.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/metadata/JaudiotaggerParser.java @@ -14,15 +14,16 @@ You should have received a copy of the GNU General Public License along with Airsonic. If not, see . + Copyright 2024 (C) Y.Tory Copyright 2016 (C) Airsonic Authors Based upon Subsonic, Copyright 2009 (C) Sindre Mehus */ package org.airsonic.player.service.metadata; import com.google.common.collect.ImmutableSet; -import com.google.common.io.MoreFiles; import org.airsonic.player.domain.MediaFile; import org.airsonic.player.service.MediaFolderService; +import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang.StringUtils; import org.jaudiotagger.audio.AudioFile; import org.jaudiotagger.audio.AudioFileIO; @@ -185,7 +186,8 @@ public boolean isEditingSupported() { return true; } - private static Set applicableFormats = ImmutableSet.of("mp3", "m4a", "m4b", "m4p", "aac", "ogg", "flac", "wav", "aif", "dsf", "aiff", "wma"); + private static Set imageAvailableFormats = ImmutableSet.of("mp3", "m4a", "m4b", "m4p", "aac", "ogg", "flac", "wav", "aif", "dsf", "aiff", "wma"); + private static Set applicableFormats = ImmutableSet.of("mp3", "m4a", "m4p", "aac", "ogg", "flac", "wav", "aif", "dsf", "aiff", "wma"); /** * Returns whether this parser is applicable to the given file. @@ -195,7 +197,7 @@ public boolean isEditingSupported() { */ @Override public boolean isApplicable(Path path) { - return Files.isRegularFile(path) && applicableFormats.contains(MoreFiles.getFileExtension(path).toLowerCase()); + return Files.isRegularFile(path) && applicableFormats.contains(FilenameUtils.getExtension(path.toString()).toLowerCase()); } /** @@ -206,7 +208,9 @@ public boolean isApplicable(Path path) { */ public static boolean isImageAvailable(Path file) { try { - return getArtwork(file) != null; + return Files.isRegularFile(file) + && imageAvailableFormats.contains(FilenameUtils.getExtension(file.toString()).toLowerCase()) + && getArtwork(file) != null; } catch (Throwable x) { LOG.info("Failed to find cover art tag in {}", file, x); return false; diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/metadata/MetaData.java b/airsonic-main/src/main/java/org/airsonic/player/service/metadata/MetaData.java index b9100e96b..7bc7b102e 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/metadata/MetaData.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/metadata/MetaData.java @@ -14,6 +14,7 @@ You should have received a copy of the GNU General Public License along with Airsonic. If not, see . + Copyright 2024 (C) Y.Tory Copyright 2016 (C) Airsonic Authors Based upon Subsonic, Copyright 2009 (C) Sindre Mehus */ @@ -46,6 +47,7 @@ public class MetaData { private String musicBrainzReleaseId; private String musicBrainzRecordingId; private final List tracks = new ArrayList<>(); + private final List chapters = new ArrayList<>(); public Integer getDiscNumber() { return discNumber; @@ -186,4 +188,12 @@ public List getVideoTracks() { public List getSubtitleTracks() { return this.getTracks().stream().filter(i -> i.isSubtitle()).collect(Collectors.toList()); } + + public List getChapters() { + return Collections.unmodifiableList(this.chapters); + } + + public void addChapter(Chapter chapter) { + this.chapters.add(chapter); + } } diff --git a/airsonic-main/src/main/java/org/airsonic/player/spring/LoggingExceptionResolver.java b/airsonic-main/src/main/java/org/airsonic/player/spring/LoggingExceptionResolver.java index d33e5a334..5def1a85b 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/spring/LoggingExceptionResolver.java +++ b/airsonic-main/src/main/java/org/airsonic/player/spring/LoggingExceptionResolver.java @@ -25,7 +25,8 @@ public ModelAndView resolveException( // This happens often and outside of the control of the server, so // we catch Tomcat/Jetty "connection aborted by client" exceptions // and display a short error message. - boolean shouldCatch = Util.isInstanceOfClassName(e, "org.apache.catalina.connector.ClientAbortException"); + boolean shouldCatch = Util.isInstanceOfClassName(e, "org.apache.catalina.connector.ClientAbortException") + || Util.isInstanceOfClassName(e.getCause(), "org.apache.catalina.connector.ClientAbortException"); if (shouldCatch) { LOG.info("{}: Client unexpectedly closed connection while loading {} ({})", request.getRemoteAddr(), Util.getAnonymizedURLForRequest(request), e.getCause().toString()); return null; diff --git a/airsonic-main/src/main/resources/application.properties b/airsonic-main/src/main/resources/application.properties index 9aec23866..f97da1391 100644 --- a/airsonic-main/src/main/resources/application.properties +++ b/airsonic-main/src/main/resources/application.properties @@ -26,7 +26,7 @@ spring.task.scheduling.pool.size=10 # airsonic config airsonic.cue.enabled=true -airsonic.cue.hide-indexed-files=true +airsonic.hide-virtual-tracks=true server.servlet.session.persistent= true spring.thymeleaf.mode= HTML diff --git a/airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle.properties b/airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle.properties index 4fd66cab5..c5f51d89a 100644 --- a/airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle.properties +++ b/airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle.properties @@ -493,8 +493,8 @@ musicfoldersettings.access.description=Configure which folders each user is allo musicfoldersettings.ignoresymlinks=Ignore Symbolic Links musicfoldersettings.enablecueindexing=Enable cue indexing musicfoldersettings.enablecueindexing.description=Enable indexing by cue files. This option only affects cue-indexed files. -musicfoldersettings.hideindexedfiles=Hide cue-indexed files -musicfoldersettings.hideindexedfiles.description=Do not show cue-indexed base files, only show tracks indexed from such files. This option only affects cue-indexed files. +musicfoldersettings.hidevirtualtracks=Hide virtual tracks +musicfoldersettings.hidevirtualtracks.description=Do not show virtual tracks. This option only affects virtual tracks from CUE sheets or M4B audio books. musicfoldersettings.excludepattern=Exclude pattern musicfoldersettings.fastcache=Fast access mode musicfoldersettings.fastcache.description=Use this option to minimize disk access, for instance if your media files are located on a network share. Note: Changed or added files will only be visible after your media folders are scanned. @@ -853,8 +853,8 @@ helppopup.playlistfolder.title=Import playlist from helppopup.playlistfolder.text=Playlists in this folder will be imported reqularly. helppopup.enablecueindexing.title=Enable cue-indexing helppopup.enablecueindexing.text=Enable indexing by cue files. If this option is unset, cue files are ignored. -helppopup.hideindexedfiles.title=Hide cue-indexed files -helppopup.hideindexedfiles.text=Hide files for which corresponding cue (index) files are found, only show the tracks indexed from such files. If this option is unset such files are shown as well as tracks indexed from those files. This option only affects cue-indexed files. +helppopup.hidevirtualtracks.title=Hide virtual tracks +helppopup.hidevirtualtracks.text=Hide virtual tracks genrated from CUE sheets or M4B audio book. If this option is unset original files are shown. helppopup.fullscan.title=Full Scan helppopup.fullscan.text=Makes the system scan every file and retrieve data from it again regardless of previous scan status of the file. Normally (if this setting is not set) the system does a "smart scan" where it looks at file timestamps and only updates file data for newer files. helppopup.clearfullscan.title=Clear Full Scan After Next Scan diff --git a/airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle_en.properties b/airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle_en.properties index db9951e30..25824d200 100644 --- a/airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle_en.properties +++ b/airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle_en.properties @@ -492,8 +492,8 @@ musicfoldersettings.access.description=Configure which folders each user is allo musicfoldersettings.ignoresymlinks=Ignore Symbolic Links musicfoldersettings.enablecueindexing=Enable cue indexing musicfoldersettings.enablecueindexing.description=Enable indexing by cue files. This option only affects cue-indexed files. -musicfoldersettings.hideindexedfiles=Hide cue-indexed files -musicfoldersettings.hideindexedfiles.description=Do not show cue-indexed base files, only show tracks indexed from such files. This option only affects cue-indexed files. +musicfoldersettings.hidevirtualtracks=Hide virtual tracks +musicfoldersettings.hidevirtualtracks.description=Do not show virtual tracks. This option only affects virtual tracks from CUE sheets or M4B audio books. musicfoldersettings.excludepattern=Exclude pattern musicfoldersettings.fastcache=Fast access mode musicfoldersettings.fastcache.description=Use this option to minimize disk access, for instance if your media files are located on a network share. Note: Changed or added files will only be visible after your media folders are scanned. @@ -852,8 +852,8 @@ helppopup.playlistfolder.title=Import playlist from helppopup.playlistfolder.text=Playlists in this folder will be imported reqularly. helppopup.enablecueindexing.title=Enable cue-indexing helppopup.enablecueindexing.text=Enable indexing by cue files. If this option is unset, cue files are ignored. -helppopup.hideindexedfiles.title=Hide cue-indexed files -helppopup.hideindexedfiles.text=Hide files for which corresponding cue (index) files are found, only show the tracks indexed from such files. If this option is unset such files are shown as well as tracks indexed from those files. This option only affects cue-indexed files. +helppopup.hidevirtualtracks.title=Hide virtual tracks +helppopup.hidevirtualtracks.text=Hide virtual tracks genrated from CUE sheets or M4B audio book. If this option is unset original files are shown. helppopup.fullscan.title=Full Scan helppopup.fullscan.text=Makes the system scan every file and retrieve data from it again regardless of previous scan status of the file. Normally (if this setting is not set) the system does a "smart scan" where it looks at file timestamps and only updates file data for newer files. helppopup.clearfullscan.title=Clear Full Scan After Next Scan diff --git a/airsonic-main/src/main/resources/templates/musicFolderSettings.html b/airsonic-main/src/main/resources/templates/musicFolderSettings.html index 541e1c24b..91fa89fd4 100644 --- a/airsonic-main/src/main/resources/templates/musicFolderSettings.html +++ b/airsonic-main/src/main/resources/templates/musicFolderSettings.html @@ -14,12 +14,10 @@ parent.frames.left.location.href="left"; } - updateEnableCueIndexing(); updateClearFullScan(); /*[+ $([[|#${#ids.next('fullScan')}|]]).change(function() {updateClearFullScan();}); - $([[|#${#ids.next('enableCueIndexing')}|]]).change(function() {updateEnableCueIndexing();}); +]*/ } @@ -34,17 +32,6 @@ +]*/ } - function updateEnableCueIndexing() { - /*[+ - if (!$([[|#${#ids.next('enableCueIndexing')}|]]).prop("checked")) { - $([[|#${#ids.next('hideIndexedFiles')}|]]).prop("disabled", true) - $([[|#${#ids.next('hideIndexedFiles')}|]]).prop("checked", false) - } else { - $([[|#${#ids.next('hideIndexedFiles')}|]]).prop("disabled", false) - } - +]*/ - } - function podcastEnabler(event, el) { $('.podcast-enable-radio[value="false"]').prop("checked", true); $(el).prop("checked", true); @@ -132,7 +119,7 @@ -

+

@@ -157,10 +144,10 @@
-
- - - +
+ + +
diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/MediaScannerServiceTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/MediaScannerServiceTestCase.java index 16b7978df..120a997e5 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/MediaScannerServiceTestCase.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/MediaScannerServiceTestCase.java @@ -497,4 +497,42 @@ public void testMpcAudioTest() { assertTrue(listMusicChildren.get(0).getDuration() > 0.0); } + + @Test + public void testM4bAudioTest() { + + Path m4bAudioFile = MusicFolderTestData.resolveM4bAudioPath(); + MusicFolder musicFolder = new MusicFolder(m4bAudioFile, "m4b", Type.MEDIA, true, + Instant.now().truncatedTo(ChronoUnit.MICROS)); + testFolders.add(musicFolder); + musicFolderRepository.saveAll(testFolders); + TestCaseUtils.execScan(mediaScannerService); + + musicFolder = musicFolderRepository.findById(musicFolder.getId()).get(); + List folders = new ArrayList<>(); + folders.add(musicFolder); + + List listMusicChildren = mediaFileRepository.findByFolderAndParentPath(musicFolder, "", + Sort.by("startPosition")); + assertEquals(3, listMusicChildren.size()); + MediaFile base = listMusicChildren.get(0); + assertEquals(-1.0d, base.getStartPosition(), 0.01); + assertEquals("m4btestbook", base.getTitle()); + assertEquals("m4btestartist", base.getArtist()); + assertEquals("m4btestartist", base.getAlbumArtist()); + assertEquals("m4btest", base.getAlbumName()); + + MediaFile chapter1 = listMusicChildren.get(1); + assertEquals(0.0d, chapter1.getStartPosition(), 0.01); + assertEquals(2.665d, chapter1.getDuration(), 0.01); + assertEquals(" Chapter 001 - 00:00:02", chapter1.getTitle()); + assertEquals("m4btest", chapter1.getAlbumName()); + + MediaFile chapter2 = listMusicChildren.get(2); + assertEquals(2.665d, chapter2.getStartPosition(), 0.01); + assertEquals(3.715d, chapter2.getDuration(), 0.01); + assertEquals(" Chapter 002 - 00:00:03", chapter2.getTitle()); + assertEquals("m4btest", chapter2.getAlbumName()); + + } } diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/SettingsServiceTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/SettingsServiceTestCase.java index ef8e3f37e..f82bab594 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/SettingsServiceTestCase.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/SettingsServiceTestCase.java @@ -23,7 +23,7 @@ import com.google.common.collect.ImmutableMap; import org.airsonic.player.TestCaseUtils; import org.airsonic.player.config.AirsonicCueConfig; -import org.airsonic.player.config.AirsonicDefaultFolderConfig; +import org.airsonic.player.config.AirsonicFolderConfig; import org.airsonic.player.config.AirsonicHomeConfig; import org.apache.commons.configuration2.spring.ConfigurationPropertySource; import org.apache.commons.io.FileUtils; @@ -69,7 +69,7 @@ public class SettingsServiceTestCase { private StandardEnvironment env; @Autowired - private AirsonicDefaultFolderConfig defaultFolderConfig; + private AirsonicFolderConfig defaultFolderConfig; @Autowired private AirsonicCueConfig cueConfig; @@ -118,7 +118,7 @@ public void testDefaultValues() { assertEquals(false, settingsService.isLdapAutoShadowing()); assertEquals("30m", settingsService.getSessionDuration()); assertEquals(true, settingsService.getEnableCueIndexing()); - assertEquals(true, settingsService.getHideIndexedFiles()); + assertEquals(true, settingsService.getHideVirtualTracks()); } @Test @@ -146,7 +146,7 @@ public void testChangeSettings() { settingsService.setLdapSearchFilter("newLdapSearchFilter"); settingsService.setLdapAutoShadowing(true); settingsService.setEnableCueIndexing(false); - settingsService.setHideIndexedFiles(false); + settingsService.setHideVirtualTracks(false); verifySettings(settingsService); @@ -184,7 +184,7 @@ private void verifySettings(SettingsService ss) { assertEquals("newLdapSearchFilter", settingsService.getLdapSearchFilter()); assertTrue(settingsService.isLdapAutoShadowing()); assertFalse(settingsService.getEnableCueIndexing()); - assertFalse(settingsService.getHideIndexedFiles()); + assertFalse(settingsService.getHideVirtualTracks()); } @Test diff --git a/airsonic-main/src/test/java/org/airsonic/player/util/MusicFolderTestData.java b/airsonic-main/src/test/java/org/airsonic/player/util/MusicFolderTestData.java index 3c12a23c0..c6925aadc 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/util/MusicFolderTestData.java +++ b/airsonic-main/src/test/java/org/airsonic/player/util/MusicFolderTestData.java @@ -59,6 +59,10 @@ public static Path resolveMusicMpcFolderPath() { return resolveBaseMediaPath().resolve("mpc"); } + public static Path resolveM4bAudioPath() { + return resolveBaseMediaPath().resolve("m4baudiobook"); + } + public static Path resolveMusicLoopFolderPath() { return resolveBaseMediaPath().resolve("loop"); } diff --git a/airsonic-main/src/test/resources/MEDIAS/m4baudiobook/m4btest.m4b b/airsonic-main/src/test/resources/MEDIAS/m4baudiobook/m4btest.m4b new file mode 100644 index 000000000..8d0c5f7f1 Binary files /dev/null and b/airsonic-main/src/test/resources/MEDIAS/m4baudiobook/m4btest.m4b differ diff --git a/docs/configures/detail.md b/docs/configures/detail.md index 7b2dad9d3..b0ae94032 100644 --- a/docs/configures/detail.md +++ b/docs/configures/detail.md @@ -56,6 +56,22 @@ Docker image does not support this option. Docker image will use the `$AIRSONIC_ | configurable by | Java options, environment variables | | environment variable | AIRSONIC_DEFAULTPLAYLISTFOLDER | +## airsonic.hide-virtual-tracks + +If enabled, Airsonic will hide virtual tracks media file list. +This is only used when initializing the database for the first time. + +| item | description | +| --- | --- | +| type | boolean | +| default | true | +| example | airsonic.hide-virtual-tracks=false | +| configurable by | Java options, environment variables, airsonic.properties, web interface| +| environment variable | AIRSONIC_HIDEVIRTUALTRACKS | +| airsonic.properties | HIDE_VIRTUAL_TRACKS | +| web interface | Settings > Music Folder > Hide virtual tracks | +| support version | `>= v11.1.4` | + ## airsonic.cue.enabled If enabled, airsonic-advanced will look for cue sheets in the same directory as the audio file and automatically split the audio file into tracks. @@ -88,6 +104,7 @@ Configuration by Java options, environment variables are working as default valu | environment variable | AIRSONIC_CUE_HIDEINDEXEDFILES | | airsonic.properties | HIDE_INDEXED_FILES | | web interface | Settings > Music Folder > Hide cue-indexed files | +| support version | `<= v11.1.3` | ## airsonic.scan.full-timeout