From 5c3c5589233f1134f5657d0b5a48ab381cba37e2 Mon Sep 17 00:00:00 2001 From: tesshucom Date: Fri, 13 Sep 2019 04:07:50 +0900 Subject: [PATCH] Update Lucene from 3.0.3 to the current version 8.2.0 Signed-off-by: Andrew DeMaria --- airsonic-main/pom.xml | 14 +- .../player/service/MediaScannerService.java | 52 +- .../player/service/SearchService.java | 13 +- .../service/search/AnalyzerFactory.java | 299 ++++++---- .../service/search/DocumentFactory.java | 416 ++++++++------ .../player/service/search/FieldNames.java | 190 +++---- .../player/service/search/IndexManager.java | 492 +++++++++++------ .../player/service/search/IndexType.java | 279 +++++----- .../player/service/search/QueryFactory.java | 522 ++++++++++-------- .../service/search/SearchServiceImpl.java | 161 ++---- .../search/SearchServiceUtilities.java | 376 ++++++------- .../search/AbstractAirsonicHomeTest.java | 274 ++++----- .../service/search/AirsonicHomeTest.java | 90 +-- .../search/AnalyzerFactoryTestCase.java | 104 ++-- .../service/search/QueryFactoryTestCase.java | 110 ++-- .../SearchServiceSpecialGenreTestCase.java | 194 +++++-- ...archServiceStartWithStopwardsTestCase.java | 16 +- .../service/search/SearchServiceTestCase.java | 7 +- 18 files changed, 1994 insertions(+), 1615 deletions(-) diff --git a/airsonic-main/pom.xml b/airsonic-main/pom.xml index adcf126f..8793a73f 100755 --- a/airsonic-main/pom.xml +++ b/airsonic-main/pom.xml @@ -131,9 +131,13 @@ org.apache.lucene lucene-core - 3.0.3 + 8.2.0 + + + org.apache.lucene + lucene-analyzers-common + 8.2.0 - org.apache.ant ant @@ -197,6 +201,12 @@ com.google.guava guava + + org.checkerframework + checker-qual + compile + 2.5.2 + org.apache.httpcomponents diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/MediaScannerService.java b/airsonic-main/src/main/java/org/airsonic/player/service/MediaScannerService.java index db3d2587..e0b94806 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/MediaScannerService.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/MediaScannerService.java @@ -23,7 +23,7 @@ import org.airsonic.player.dao.AlbumDao; import org.airsonic.player.dao.ArtistDao; import org.airsonic.player.dao.MediaFileDao; import org.airsonic.player.domain.*; -import org.airsonic.player.util.FileUtil; +import org.airsonic.player.service.search.IndexManager; import org.apache.commons.lang.ObjectUtils; import org.apache.commons.lang3.time.DateUtils; import org.slf4j.Logger; @@ -44,7 +44,6 @@ import java.util.*; @Service public class MediaScannerService { - private static final int INDEX_VERSION = 15; private static final Logger LOG = LoggerFactory.getLogger(MediaScannerService.class); private MediaLibraryStatistics statistics; @@ -54,7 +53,7 @@ public class MediaScannerService { @Autowired private SettingsService settingsService; @Autowired - private SearchService searchService; + private IndexManager indexManager; @Autowired private PlaylistService playlistService; @Autowired @@ -69,13 +68,13 @@ public class MediaScannerService { @PostConstruct public void init() { - deleteOldIndexFiles(); + indexManager.initializeIndexDirectory(); statistics = settingsService.getMediaLibraryStatistics(); schedule(); } public void initNoSchedule() { - deleteOldIndexFiles(); + indexManager.deleteOldIndexFiles(); statistics = settingsService.getMediaLibraryStatistics(); } @@ -179,7 +178,7 @@ public class MediaScannerService { statistics.reset(); mediaFileService.setMemoryCacheEnabled(false); - searchService.startIndexing(); + indexManager.startIndexing(); mediaFileService.clearMemoryCache(); @@ -223,7 +222,7 @@ public class MediaScannerService { LOG.error("Failed to scan media library.", x); } finally { mediaFileService.setMemoryCacheEnabled(true); - searchService.stopIndexing(); + indexManager.stopIndexing(); scanning = false; } } @@ -243,7 +242,7 @@ public class MediaScannerService { mediaFileDao.createOrUpdateMediaFile(file); } - searchService.index(file); + indexManager.index(file); if (file.isDirectory()) { for (MediaFile child : mediaFileService.getChildrenOf(file, true, false, false, false)) { @@ -331,7 +330,7 @@ public class MediaScannerService { album.setPresent(true); albumDao.createOrUpdateAlbum(album); if (firstEncounter) { - searchService.index(album); + indexManager.index(album); } // Update the file's album artist, if necessary. @@ -370,7 +369,7 @@ public class MediaScannerService { artistDao.createOrUpdateArtist(artist); if (firstEncounter) { - searchService.index(artist, musicFolder); + indexManager.index(artist, musicFolder); } } @@ -383,43 +382,10 @@ public class MediaScannerService { return statistics; } - /** - * Deletes old versions of the index file. - */ - private void deleteOldIndexFiles() { - for (int i = 2; i < INDEX_VERSION; i++) { - File file = getIndexFile(i); - try { - if (FileUtil.exists(file)) { - if (file.delete()) { - LOG.info("Deleted old index file: " + file.getPath()); - } - } - } catch (Exception x) { - LOG.warn("Failed to delete old index file: " + file.getPath(), x); - } - } - } - - /** - * Returns the index file for the given index version. - * - * @param version The index version. - * @return The index file for the given index version. - */ - private File getIndexFile(int version) { - File home = SettingsService.getAirsonicHome(); - return new File(home, "airsonic" + version + ".index"); - } - public void setSettingsService(SettingsService settingsService) { this.settingsService = settingsService; } - public void setSearchService(SearchService searchService) { - this.searchService = searchService; - } - public void setMediaFileService(MediaFileService mediaFileService) { this.mediaFileService = mediaFileService; } diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java b/airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java index ec0a2c6e..5ebcceb5 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java @@ -21,7 +21,6 @@ package org.airsonic.player.service; import org.airsonic.player.domain.Album; -import org.airsonic.player.domain.Artist; import org.airsonic.player.domain.MediaFile; import org.airsonic.player.domain.MusicFolder; import org.airsonic.player.domain.ParamSearchResult; @@ -33,7 +32,7 @@ import org.airsonic.player.service.search.IndexType; import java.util.List; /** - * Performs Lucene-based searching and indexing. + * Performs Lucene-based searching. * * @author Sindre Mehus * @version $Id$ @@ -41,16 +40,6 @@ import java.util.List; */ public interface SearchService { - void startIndexing(); - - void index(MediaFile mediaFile); - - void index(Artist artist, MusicFolder musicFolder); - - void index(Album album); - - void stopIndexing(); - SearchResult search(SearchCriteria criteria, List musicFolders, IndexType indexType); diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java index 3e980e6e..eeae8b9a 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java @@ -1,121 +1,178 @@ -/* - This file is part of Airsonic. - - Airsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Airsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Airsonic. If not, see . - - Copyright 2016 (C) Airsonic Authors - Based upon Subsonic, Copyright 2009 (C) Sindre Mehus - */ - -package org.airsonic.player.service.search; - -import org.apache.lucene.analysis.ASCIIFoldingFilter; -import org.apache.lucene.analysis.Analyzer; -import org.apache.lucene.analysis.LowerCaseFilter; -import org.apache.lucene.analysis.StopFilter; -import org.apache.lucene.analysis.TokenStream; -import org.apache.lucene.analysis.standard.StandardAnalyzer; -import org.apache.lucene.analysis.standard.StandardFilter; -import org.apache.lucene.analysis.standard.StandardTokenizer; -import org.apache.lucene.util.Version; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.io.Reader; - -/** - * Analyzer provider. - * This class is a division of what was once part of SearchService and added functionality. - * This class provides Analyzer which is used at index generation - * and QueryAnalyzer which analyzes the specified query at search time. - * Analyzer can be closed but is a reuse premise. - * It is held in this class. - */ -@Component -public final class AnalyzerFactory { - - private Analyzer analyzer; - - private Analyzer queryAnalyzer; - - /** - * Return analyzer. - * - * @return analyzer for index - */ - public Analyzer getAnalyzer() { - if (null == analyzer) { - analyzer = new CustomAnalyzer(); - } - return analyzer; - } - - /** - * Return analyzer. - * - * @return analyzer for index - */ - public Analyzer getQueryAnalyzer() { - if (null == queryAnalyzer) { - queryAnalyzer = new CustomAnalyzer(); - } - return queryAnalyzer; - } - - /* - * The legacy CustomAnalyzer implementation is kept as it is. - */ - private class CustomAnalyzer extends StandardAnalyzer { - private CustomAnalyzer() { - /* - * Version.LUCENE_30 - * It is a transient description and will be deleted when upgrading the version. - * SearchService variables are not used because the reference direction conflicts. - */ - super(Version.LUCENE_30); - } - - @Override - public TokenStream tokenStream(String fieldName, Reader reader) { - TokenStream result = super.tokenStream(fieldName, reader); - return new ASCIIFoldingFilter(result); - } - - @Override - public TokenStream reusableTokenStream(String fieldName, Reader reader) throws IOException { - class SavedStreams { - StandardTokenizer tokenStream; - TokenStream filteredTokenStream; - } - - SavedStreams streams = (SavedStreams) getPreviousTokenStream(); - if (streams == null) { - streams = new SavedStreams(); - setPreviousTokenStream(streams); - streams.tokenStream = new StandardTokenizer(Version.LUCENE_30, reader); - streams.filteredTokenStream = new StandardFilter(streams.tokenStream); - streams.filteredTokenStream = new LowerCaseFilter(streams.filteredTokenStream); - streams.filteredTokenStream = new StopFilter(true, streams.filteredTokenStream, - STOP_WORDS_SET); - streams.filteredTokenStream = new ASCIIFoldingFilter(streams.filteredTokenStream); - } else { - streams.tokenStream.reset(reader); - } - streams.tokenStream.setMaxTokenLength(DEFAULT_MAX_TOKEN_LENGTH); - - return streams.filteredTokenStream; - } - } - -} +/* + This file is part of Airsonic. + + Airsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Airsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Airsonic. If not, see . + + Copyright 2016 (C) Airsonic Authors + Based upon Subsonic, Copyright 2009 (C) Sindre Mehus + */ + +package org.airsonic.player.service.search; + +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.cjk.CJKWidthFilterFactory; +import org.apache.lucene.analysis.core.KeywordTokenizerFactory; +import org.apache.lucene.analysis.core.LowerCaseFilterFactory; +import org.apache.lucene.analysis.core.StopFilterFactory; +import org.apache.lucene.analysis.custom.CustomAnalyzer; +import org.apache.lucene.analysis.custom.CustomAnalyzer.Builder; +import org.apache.lucene.analysis.en.EnglishPossessiveFilterFactory; +import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilterFactory; +import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper; +import org.apache.lucene.analysis.pattern.PatternReplaceFilterFactory; +import org.apache.lucene.analysis.standard.StandardTokenizerFactory; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.util.ObjectUtils.isEmpty; + +/** + * Analyzer provider. + * This class is a division of what was once part of SearchService and added functionality. + * This class provides Analyzer which is used at index generation + * and QueryAnalyzer which analyzes the specified query at search time. + * Analyzer can be closed but is a reuse premise. + * It is held in this class. + */ +@Component +public final class AnalyzerFactory { + + private Analyzer analyzer; + + private Analyzer queryAnalyzer; + + /* + * XXX 3.x -> 8.x : Convert UAX#29 Underscore Analysis to Legacy Analysis + * + * Because changes in underscores before and after words + * have a major effect on user's forward match search. + * + * @see AnalyzerFactoryTestCase + */ + private void addTokenFilterForUnderscoreRemovalAroundToken(Builder builder) throws IOException { + builder + .addTokenFilter(PatternReplaceFilterFactory.class, + "pattern", "^\\_", "replacement", "", "replace", "all") + .addTokenFilter(PatternReplaceFilterFactory.class, + "pattern", "\\_$", "replacement", "", "replace", "all"); + } + + /* + * XXX 3.x -> 8.x : Handle brackets correctly + * + * Process the input value of Genre search for search of domain value. + * + * The tag parser performs special character conversion + * when converting input values ​​from a file. + * Therefore, the domain value may be different from the original value. + * This filter allows searching by user readable value (file tag value). + * + * @see org.jaudiotagger.tag.id3.framebody.FrameBodyTCON#convertID3v23GenreToGeneric + * (TCON stands for Genre with ID3 v2.3-v2.4) + * Such processing exists because brackets in the Gener string have a special meaning. + */ + private void addTokenFilterForTokenToDomainValue(Builder builder) throws IOException { + builder + .addTokenFilter(PatternReplaceFilterFactory.class, + "pattern", "\\(", "replacement", "", "replace", "all") + .addTokenFilter(PatternReplaceFilterFactory.class, + "pattern", "\\)$", "replacement", "", "replace", "all") + .addTokenFilter(PatternReplaceFilterFactory.class, + "pattern", "\\)", "replacement", " ", "replace", "all") + .addTokenFilter(PatternReplaceFilterFactory.class, + "pattern", "\\{\\}", "replacement", "\\{ \\}", "replace", "all") + .addTokenFilter(PatternReplaceFilterFactory.class, + "pattern", "\\[\\]", "replacement", "\\[ \\]", "replace", "all"); + } + + private Builder createDefaultAnalyzerBuilder() throws IOException { + Builder builder = CustomAnalyzer.builder() + .withTokenizer(StandardTokenizerFactory.class) + .addTokenFilter(CJKWidthFilterFactory.class) + .addTokenFilter(ASCIIFoldingFilterFactory.class, "preserveOriginal", "false") + .addTokenFilter(LowerCaseFilterFactory.class) + .addTokenFilter(StopFilterFactory.class) + .addTokenFilter(EnglishPossessiveFilterFactory.class); + addTokenFilterForUnderscoreRemovalAroundToken(builder); + return builder; + } + + private Builder createKeywordAnalyzerBuilder() throws IOException { + return CustomAnalyzer.builder() + .withTokenizer(KeywordTokenizerFactory.class); + } + + private Builder createGenreAnalyzerBuilder() throws IOException { + Builder builder = createKeywordAnalyzerBuilder(); + addTokenFilterForTokenToDomainValue(builder); + return builder; + } + + /** + * Returns the Analyzer to use when generating the index. + * + * Whether this analyzer is applied to input values ​​depends on + * the definition of the document's fields. + * + * @return analyzer for index + * @see DocumentFactory + */ + public Analyzer getAnalyzer() throws IOException { + if (isEmpty(analyzer)) { + try { + analyzer = createDefaultAnalyzerBuilder().build(); + } catch (IOException e) { + throw new IOException("Error when initializing Analyzer.", e); + } + } + return analyzer; + } + + /** + * Returns the analyzer to use when generating a query for index search. + * + * String processing handled by QueryFactory + * is limited to Lucene's modifier. + * + * The processing of the operands is expressed + * in the AnalyzerFactory implementation. + * Rules for tokenizing/converting input values ​ + * should not be described in QueryFactory. + * + * @return analyzer for query + * @see QueryFactory + */ + public Analyzer getQueryAnalyzer() throws IOException { + if (isEmpty(queryAnalyzer)) { + try { + + Analyzer defaultAnalyzer = createDefaultAnalyzerBuilder().build(); + Analyzer genreAnalyzer = createGenreAnalyzerBuilder().build(); + + Map fieldAnalyzers = new HashMap<>(); + fieldAnalyzers.put(FieldNames.GENRE, genreAnalyzer); + + queryAnalyzer = new PerFieldAnalyzerWrapper(defaultAnalyzer, fieldAnalyzers); + + } catch (IOException e) { + throw new IOException("Error when initializing Analyzer.", e); + } + } + return queryAnalyzer; + } + +} diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java index e73d0a95..1f548b69 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java @@ -1,181 +1,235 @@ -/* - This file is part of Airsonic. - - Airsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Airsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Airsonic. If not, see . - - Copyright 2016 (C) Airsonic Authors - Based upon Subsonic, Copyright 2009 (C) Sindre Mehus - */ - -package org.airsonic.player.service.search; - -import org.airsonic.player.domain.Album; -import org.airsonic.player.domain.Artist; -import org.airsonic.player.domain.MediaFile; -import org.airsonic.player.domain.MusicFolder; -import org.apache.lucene.document.Document; -import org.apache.lucene.document.Field; -import org.apache.lucene.document.NumericField; -import org.springframework.stereotype.Component; - -/** - * A factory that generates the documents to be stored in the index. - */ -@Component -public class DocumentFactory { - - /** - * Normalize the genre string. - * - * @param genre genre string - * @return genre string normalized - * @deprecated should be resolved with tokenizer or filter - */ - @Deprecated - private String normalizeGenre(String genre) { - return genre.toLowerCase().replace(" ", "").replace("-", ""); - } - - /** - * Create a document. - * - * @param mediaFile target of document - * @return document - * @since legacy - */ - public Document createAlbumDocument(MediaFile mediaFile) { - Document doc = new Document(); - doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) - .setIntValue(mediaFile.getId())); - - if (mediaFile.getArtist() != null) { - doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES, - Field.Index.ANALYZED)); - } - if (mediaFile.getAlbumName() != null) { - doc.add(new Field(FieldNames.ALBUM, mediaFile.getAlbumName(), Field.Store.YES, - Field.Index.ANALYZED)); - } - if (mediaFile.getFolder() != null) { - doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO, - Field.Index.NOT_ANALYZED_NO_NORMS)); - } - return doc; - } - - /** - * Create a document. - * - * @param mediaFile target of document - * @return document - * @since legacy - */ - public Document createArtistDocument(MediaFile mediaFile) { - Document doc = new Document(); - doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) - .setIntValue(mediaFile.getId())); - - if (mediaFile.getArtist() != null) { - doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES, - Field.Index.ANALYZED)); - } - if (mediaFile.getFolder() != null) { - doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO, - Field.Index.NOT_ANALYZED_NO_NORMS)); - } - return doc; - } - - /** - * Create a document. - * - * @param album target of document - * @return document - * @since legacy - */ - public Document createAlbumId3Document(Album album) { - Document doc = new Document(); - doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false).setIntValue(album.getId())); - - if (album.getArtist() != null) { - doc.add(new Field(FieldNames.ARTIST, album.getArtist(), Field.Store.YES, - Field.Index.ANALYZED)); - } - if (album.getName() != null) { - doc.add(new Field(FieldNames.ALBUM, album.getName(), Field.Store.YES, - Field.Index.ANALYZED)); - } - if (album.getFolderId() != null) { - doc.add(new NumericField(FieldNames.FOLDER_ID, Field.Store.NO, true) - .setIntValue(album.getFolderId())); - } - return doc; - } - - /** - * Create a document. - * - * @param artist target of document - * @param musicFolder target folder exists - * @return document - * @since legacy - */ - public Document createArtistId3Document(Artist artist, MusicFolder musicFolder) { - Document doc = new Document(); - doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) - .setIntValue(artist.getId())); - doc.add(new Field(FieldNames.ARTIST, artist.getName(), Field.Store.YES, - Field.Index.ANALYZED)); - doc.add(new NumericField(FieldNames.FOLDER_ID, Field.Store.NO, true) - .setIntValue(musicFolder.getId())); - return doc; - } - - /** - * Create a document. - * - * @param mediaFile target of document - * @return document - * @since legacy - */ - public Document createSongDocument(MediaFile mediaFile) { - Document doc = new Document(); - doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) - .setIntValue(mediaFile.getId())); - doc.add(new Field(FieldNames.MEDIA_TYPE, mediaFile.getMediaType().name(), Field.Store.NO, - Field.Index.ANALYZED_NO_NORMS)); - if (mediaFile.getTitle() != null) { - doc.add(new Field(FieldNames.TITLE, mediaFile.getTitle(), Field.Store.YES, - Field.Index.ANALYZED)); - } - if (mediaFile.getArtist() != null) { - doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES, - Field.Index.ANALYZED)); - } - if (mediaFile.getGenre() != null) { - doc.add(new Field(FieldNames.GENRE, normalizeGenre(mediaFile.getGenre()), - Field.Store.NO, Field.Index.ANALYZED)); - } - if (mediaFile.getYear() != null) { - doc.add(new NumericField(FieldNames.YEAR, Field.Store.NO, true) - .setIntValue(mediaFile.getYear())); - } - if (mediaFile.getFolder() != null) { - doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO, - Field.Index.NOT_ANALYZED_NO_NORMS)); - } - return doc; - } - -} +/* + This file is part of Airsonic. + + Airsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Airsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Airsonic. If not, see . + + Copyright 2016 (C) Airsonic Authors + Based upon Subsonic, Copyright 2009 (C) Sindre Mehus + */ + +package org.airsonic.player.service.search; + +import org.airsonic.player.domain.Album; +import org.airsonic.player.domain.Artist; +import org.airsonic.player.domain.MediaFile; +import org.airsonic.player.domain.MusicFolder; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field.Store; +import org.apache.lucene.document.FieldType; +import org.apache.lucene.document.IntPoint; +import org.apache.lucene.document.SortedDocValuesField; +import org.apache.lucene.document.StoredField; +import org.apache.lucene.document.TextField; +import org.apache.lucene.index.IndexOptions; +import org.apache.lucene.index.Term; +import org.apache.lucene.util.BytesRef; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.stereotype.Component; + +import java.util.function.BiConsumer; + +import static org.springframework.util.ObjectUtils.isEmpty; + +/** + * A factory that generates the documents to be stored in the index. + */ +@Component +public class DocumentFactory { + + private static final FieldType TYPE_ID; + + private static final FieldType TYPE_ID_NO_STORE; + + private static final FieldType TYPE_KEY; + + static { + + TYPE_ID = new FieldType(); + TYPE_ID.setIndexOptions(IndexOptions.DOCS); + TYPE_ID.setTokenized(false); + TYPE_ID.setOmitNorms(true); + TYPE_ID.setStored(true); + TYPE_ID.freeze(); + + TYPE_ID_NO_STORE = new FieldType(); + TYPE_ID_NO_STORE.setIndexOptions(IndexOptions.DOCS); + TYPE_ID_NO_STORE.setTokenized(false); + TYPE_ID_NO_STORE.setOmitNorms(true); + TYPE_ID_NO_STORE.setStored(false); + TYPE_ID_NO_STORE.freeze(); + + TYPE_KEY = new FieldType(); + TYPE_KEY.setIndexOptions(IndexOptions.DOCS); + TYPE_KEY.setTokenized(false); + TYPE_KEY.setOmitNorms(true); + TYPE_KEY.setStored(false); + TYPE_KEY.freeze(); + + } + + @FunctionalInterface + private interface Consumer { + void accept(T t, U u, V v); + + } + + ; + + private BiConsumer<@NonNull Document, @NonNull Integer> fieldId = (doc, value) -> { + doc.add(new StoredField(FieldNames.ID, Integer.toString(value), TYPE_ID)); + }; + + private BiConsumer<@NonNull Document, @NonNull Integer> fieldFolderId = (doc, value) -> { + doc.add(new StoredField(FieldNames.FOLDER_ID, Integer.toString(value), TYPE_ID_NO_STORE)); + }; + + private Consumer<@NonNull Document, @NonNull String, @NonNull String> fieldKey = (doc, field, value) -> { + doc.add(new StoredField(field, value, TYPE_KEY)); + }; + + private BiConsumer<@NonNull Document, @NonNull String> fieldMediatype = (doc, value) -> + fieldKey.accept(doc, FieldNames.MEDIA_TYPE, value); + + private BiConsumer<@NonNull Document, @NonNull String> fieldFolderPath = (doc, value) -> + fieldKey.accept(doc, FieldNames.FOLDER, value); + + private BiConsumer<@NonNull Document, @Nullable String> fieldGenre = (doc, value) -> { + if (isEmpty(value)) { + return; + } + fieldKey.accept(doc, FieldNames.GENRE, value); + }; + + private Consumer<@NonNull Document, @NonNull String, @Nullable Integer> fieldYear = (doc, fieldName, value) -> { + if (isEmpty(value)) { + return; + } + doc.add(new IntPoint(fieldName, value)); + }; + + private Consumer<@NonNull Document, @NonNull String, @Nullable String> fieldWords = (doc, fieldName, value) -> { + if (isEmpty(value)) { + return; + } + doc.add(new TextField(fieldName, value, Store.NO)); + doc.add(new SortedDocValuesField(fieldName, new BytesRef(value))); + }; + + public final Term createPrimarykey(Album album) { + return new Term(FieldNames.ID, Integer.toString(album.getId())); + }; + + public final Term createPrimarykey(Artist artist) { + return new Term(FieldNames.ID, Integer.toString(artist.getId())); + }; + + public final Term createPrimarykey(MediaFile mediaFile) { + return new Term(FieldNames.ID, Integer.toString(mediaFile.getId())); + }; + + /** + * Create a document. + * + * @param mediaFile target of document + * @return document + * @since legacy + */ + public Document createAlbumDocument(MediaFile mediaFile) { + Document doc = new Document(); + fieldId.accept(doc, mediaFile.getId()); + fieldWords.accept(doc, FieldNames.ARTIST, mediaFile.getArtist()); + fieldWords.accept(doc, FieldNames.ALBUM, mediaFile.getAlbumName()); + fieldFolderPath.accept(doc, mediaFile.getFolder()); + return doc; + } + + /** + * Create a document. + * + * @param mediaFile target of document + * @return document + * @since legacy + */ + public Document createArtistDocument(MediaFile mediaFile) { + Document doc = new Document(); + fieldId.accept(doc, mediaFile.getId()); + fieldWords.accept(doc, FieldNames.ARTIST, mediaFile.getArtist()); + fieldFolderPath.accept(doc, mediaFile.getFolder()); + return doc; + } + + /** + * Create a document. + * + * @param album target of document + * @return document + * @since legacy + */ + public Document createAlbumId3Document(Album album) { + Document doc = new Document(); + fieldId.accept(doc, album.getId()); + fieldWords.accept(doc, FieldNames.ARTIST, album.getArtist()); + fieldWords.accept(doc, FieldNames.ALBUM, album.getName()); + fieldFolderId.accept(doc, album.getFolderId()); + return doc; + } + + /** + * Create a document. + * + * @param artist target of document + * @param musicFolder target folder exists + * @return document + * @since legacy + */ + /* + * XXX 3.x -> 8.x : + * Only null check specification of createArtistId3Document is different from legacy. + * (The reason is only to simplify the function.) + * + * Since the field of domain object Album is nonnull, + * null check was not performed. + * + * In implementation ARTIST and ALBUM became nullable, + * but null is not input at this point in data flow. + */ + public Document createArtistId3Document(Artist artist, MusicFolder musicFolder) { + Document doc = new Document(); + fieldId.accept(doc, artist.getId()); + fieldWords.accept(doc, FieldNames.ARTIST, artist.getName()); + fieldFolderId.accept(doc, musicFolder.getId()); + return doc; + } + + /** + * Create a document. + * + * @param mediaFile target of document + * @return document + * @since legacy + */ + public Document createSongDocument(MediaFile mediaFile) { + Document doc = new Document(); + fieldId.accept(doc, mediaFile.getId()); + fieldMediatype.accept(doc, mediaFile.getMediaType().name()); + fieldWords.accept(doc, FieldNames.TITLE, mediaFile.getTitle()); + fieldWords.accept(doc, FieldNames.ARTIST, mediaFile.getArtist()); + fieldGenre.accept(doc, mediaFile.getGenre()); + fieldYear.accept(doc, FieldNames.YEAR, mediaFile.getYear()); + fieldFolderPath.accept(doc, mediaFile.getFolder()); + return doc; + } + +} diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java index 88830515..81c23101 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java @@ -1,95 +1,95 @@ -/* - This file is part of Airsonic. - - Airsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Airsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Airsonic. If not, see . - - Copyright 2016 (C) Airsonic Authors - Based upon Subsonic, Copyright 2009 (C) Sindre Mehus - */ - -package org.airsonic.player.service.search; - -/** - * Enum that symbolizes the field name used for lucene index. - * This class is a division of what was once part of SearchService and added functionality. - */ -class FieldNames { - - private FieldNames() { - } - - /** - * A field same to a legacy server, id field. - * - * @since legacy - **/ - public static final String ID = "id"; - - /** - * A field same to a legacy server, id field. - * - * @since legacy - **/ - public static final String FOLDER_ID = "folderId"; - - /** - * A field same to a legacy server, numeric field. - * - * @since legacy - **/ - public static final String YEAR = "year"; - - /** - * A field same to a legacy server, key field. - * - * @since legacy - **/ - public static final String GENRE = "genre"; - - /** - * A field same to a legacy server, key field. - * - * @since legacy - **/ - public static final String MEDIA_TYPE = "mediaType"; - - /** - * A field same to a legacy server, key field. - * - * @since legacy - **/ - public static final String FOLDER = "folder"; - - /** - * A field same to a legacy server, usually with common word parsing. - * - * @since legacy - **/ - public static final String ARTIST = "artist"; - - /** - * A field same to a legacy server, usually with common word parsing. - * - * @since legacy - **/ - public static final String ALBUM = "album"; - - /** - * A field same to a legacy server, usually with common word parsing. - * - * @since legacy - **/ - public static final String TITLE = "title"; - -} +/* + This file is part of Airsonic. + + Airsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Airsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Airsonic. If not, see . + + Copyright 2016 (C) Airsonic Authors + Based upon Subsonic, Copyright 2009 (C) Sindre Mehus + */ + +package org.airsonic.player.service.search; + +/** + * Enum that symbolizes the field name used for lucene index. + * This class is a division of what was once part of SearchService and added functionality. + */ +class FieldNames { + + private FieldNames() { + } + + /** + * A field same to a legacy server, id field. + * + * @since legacy + **/ + public static final String ID = "id"; + + /** + * A field same to a legacy server, id field. + * + * @since legacy + **/ + public static final String FOLDER_ID = "folderId"; + + /** + * A field same to a legacy server, numeric field. + * + * @since legacy + **/ + public static final String YEAR = "year"; + + /** + * A field same to a legacy server, key field. + * + * @since legacy + **/ + public static final String GENRE = "genre"; + + /** + * A field same to a legacy server, key field. + * + * @since legacy + **/ + public static final String MEDIA_TYPE = "mediaType"; + + /** + * A field same to a legacy server, key field. + * + * @since legacy + **/ + public static final String FOLDER = "folder"; + + /** + * A field same to a legacy server, usually with common word parsing. + * + * @since legacy + **/ + public static final String ARTIST = "artist"; + + /** + * A field same to a legacy server, usually with common word parsing. + * + * @since legacy + **/ + public static final String ALBUM = "album"; + + /** + * A field same to a legacy server, usually with common word parsing. + * + * @since legacy + **/ + public static final String TITLE = "title"; + +} diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java index d0db125a..de5290fb 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java @@ -1,169 +1,323 @@ -/* - This file is part of Airsonic. - - Airsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Airsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Airsonic. If not, see . - - Copyright 2016 (C) Airsonic Authors - Based upon Subsonic, Copyright 2009 (C) Sindre Mehus - */ - -package org.airsonic.player.service.search; - -import org.airsonic.player.domain.Album; -import org.airsonic.player.domain.Artist; -import org.airsonic.player.domain.MediaFile; -import org.airsonic.player.domain.MusicFolder; -import org.airsonic.player.service.SettingsService; -import org.airsonic.player.util.FileUtil; -import org.apache.lucene.index.IndexReader; -import org.apache.lucene.index.IndexWriter; -import org.apache.lucene.store.FSDirectory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.io.File; -import java.io.IOException; - -import static org.airsonic.player.service.search.IndexType.ALBUM; -import static org.airsonic.player.service.search.IndexType.ALBUM_ID3; -import static org.airsonic.player.service.search.IndexType.ARTIST; -import static org.airsonic.player.service.search.IndexType.ARTIST_ID3; -import static org.airsonic.player.service.search.IndexType.SONG; - -/** - * Function class that is strongly linked to the lucene index implementation. - * Legacy has an implementation in SearchService. - * - * If the index CRUD and search functionality are in the same class, - * there is often a dependency conflict on the class used. - * Although the interface of SearchService is left to maintain the legacy implementation, - * it is desirable that methods of index operations other than search essentially use this class directly. - */ -@Component -public class IndexManager { - - private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class); - - @Autowired - private AnalyzerFactory analyzerFactory; - - @Autowired - private DocumentFactory documentFactory; - - private IndexWriter artistWriter; - private IndexWriter artistId3Writer; - private IndexWriter albumWriter; - private IndexWriter albumId3Writer; - private IndexWriter songWriter; - - public void index(Album album) { - try { - albumId3Writer.addDocument(documentFactory.createAlbumId3Document(album)); - } catch (Exception x) { - LOG.error("Failed to create search index for " + album, x); - } - } - - public void index(Artist artist, MusicFolder musicFolder) { - try { - artistId3Writer - .addDocument(documentFactory.createArtistId3Document(artist, musicFolder)); - } catch (Exception x) { - LOG.error("Failed to create search index for " + artist, x); - } - } - - public void index(MediaFile mediaFile) { - try { - if (mediaFile.isFile()) { - songWriter.addDocument(documentFactory.createSongDocument(mediaFile)); - } else if (mediaFile.isAlbum()) { - albumWriter.addDocument(documentFactory.createAlbumDocument(mediaFile)); - } else { - artistWriter.addDocument(documentFactory.createArtistDocument(mediaFile)); - } - } catch (Exception x) { - LOG.error("Failed to create search index for " + mediaFile, x); - } - } - - private static final String LUCENE_DIR = "lucene2"; - - public IndexReader createIndexReader(IndexType indexType) throws IOException { - File dir = getIndexDirectory(indexType); - return IndexReader.open(FSDirectory.open(dir), true); - } - - /** - * It is static as an intermediate response of the transition period. - * (It is called before injection because it is called by SearchService constructor) - * - * @return - */ - private static File getIndexRootDirectory() { - return new File(SettingsService.getAirsonicHome(), LUCENE_DIR); - } - - /** - * Make it public as an interim response of the transition period. - * (It is called before the injection because it is called in the SearchService constructor.) - * - * @param indexType - * @return - * @deprecated It should not be called from outside. - */ - @Deprecated - public static File getIndexDirectory(IndexType indexType) { - return new File(getIndexRootDirectory(), indexType.toString().toLowerCase()); - } - - private IndexWriter createIndexWriter(IndexType indexType) throws IOException { - File dir = getIndexDirectory(indexType); - return new IndexWriter(FSDirectory.open(dir), analyzerFactory.getAnalyzer(), true, - new IndexWriter.MaxFieldLength(10)); - } - - public final void startIndexing() { - try { - artistWriter = createIndexWriter(ARTIST); - artistId3Writer = createIndexWriter(ARTIST_ID3); - albumWriter = createIndexWriter(ALBUM); - albumId3Writer = createIndexWriter(ALBUM_ID3); - songWriter = createIndexWriter(SONG); - } catch (Exception x) { - LOG.error("Failed to create search index.", x); - } - } - - public void stopIndexing() { - try { - artistWriter.optimize(); - artistId3Writer.optimize(); - albumWriter.optimize(); - albumId3Writer.optimize(); - songWriter.optimize(); - } catch (Exception x) { - LOG.error("Failed to create search index.", x); - } finally { - FileUtil.closeQuietly(artistId3Writer); - FileUtil.closeQuietly(artistWriter); - FileUtil.closeQuietly(albumWriter); - FileUtil.closeQuietly(albumId3Writer); - FileUtil.closeQuietly(songWriter); - } - } - -} +/* + This file is part of Airsonic. + + Airsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Airsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Airsonic. If not, see . + + Copyright 2016 (C) Airsonic Authors + Based upon Subsonic, Copyright 2009 (C) Sindre Mehus + */ + +package org.airsonic.player.service.search; + +import org.airsonic.player.domain.Album; +import org.airsonic.player.domain.Artist; +import org.airsonic.player.domain.MediaFile; +import org.airsonic.player.domain.MusicFolder; +import org.airsonic.player.service.SettingsService; +import org.airsonic.player.util.FileUtil; +import org.apache.commons.io.FileUtils; +import org.apache.lucene.document.Document; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.SearcherManager; +import org.apache.lucene.store.FSDirectory; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +import static org.springframework.util.ObjectUtils.isEmpty; + +/** + * Function class that is strongly linked to the lucene index implementation. + * Legacy has an implementation in SearchService. + * + * If the index CRUD and search functionality are in the same class, + * there is often a dependency conflict on the class used. + * Although the interface of SearchService is left to maintain the legacy implementation, + * it is desirable that methods of index operations other than search essentially use this class directly. + */ +@Component +public class IndexManager { + + private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class); + + /** + * Schema version of Airsonic index. + * It may be incremented in the following cases: + * + * - Incompatible update case in Lucene index implementation + * - When schema definition is changed due to modification of AnalyzerFactory, + * DocumentFactory or the class that they use. + * + */ + private static final int INDEX_VERSION = 16; + + /** + * Literal name of index top directory. + */ + private static final String INDEX_ROOT_DIR_NAME = "index"; + + /** + * File supplier for index directory. + */ + private Supplier rootIndexDirectory = () -> + new File(SettingsService.getAirsonicHome(), INDEX_ROOT_DIR_NAME.concat(Integer.toString(INDEX_VERSION))); + + /** + * Returns the directory of the specified index + */ + private Function getIndexDirectory = (indexType) -> + new File(rootIndexDirectory.get(), indexType.toString().toLowerCase()); + + @Autowired + private AnalyzerFactory analyzerFactory; + + @Autowired + private DocumentFactory documentFactory; + + private Map searchers = new HashMap<>(); + + private Map writers = new HashMap<>(); + + public void index(Album album) { + Term primarykey = documentFactory.createPrimarykey(album); + Document document = documentFactory.createAlbumId3Document(album); + try { + writers.get(IndexType.ALBUM_ID3).updateDocument(primarykey, document); + } catch (Exception x) { + LOG.error("Failed to create search index for " + album, x); + } + } + + public void index(Artist artist, MusicFolder musicFolder) { + Term primarykey = documentFactory.createPrimarykey(artist); + Document document = documentFactory.createArtistId3Document(artist, musicFolder); + try { + writers.get(IndexType.ARTIST_ID3).updateDocument(primarykey, document); + } catch (Exception x) { + LOG.error("Failed to create search index for " + artist, x); + } + } + + public void index(MediaFile mediaFile) { + Term primarykey = documentFactory.createPrimarykey(mediaFile); + try { + if (mediaFile.isFile()) { + Document document = documentFactory.createSongDocument(mediaFile); + writers.get(IndexType.SONG).updateDocument(primarykey, document); + } else if (mediaFile.isAlbum()) { + Document document = documentFactory.createAlbumDocument(mediaFile); + writers.get(IndexType.ALBUM).updateDocument(primarykey, document); + } else { + Document document = documentFactory.createArtistDocument(mediaFile); + writers.get(IndexType.ARTIST).updateDocument(primarykey, document); + } + } catch (Exception x) { + LOG.error("Failed to create search index for " + mediaFile, x); + } + } + + public final void startIndexing() { + try { + for (IndexType IndexType : IndexType.values()) { + writers.put(IndexType, createIndexWriter(IndexType)); + } + } catch (IOException e) { + LOG.error("Failed to create search index.", e); + } + } + + /** + * + * @param indexType + * @return + * @throws IOException + */ + private IndexWriter createIndexWriter(IndexType indexType) throws IOException { + File indexDirectory = getIndexDirectory.apply(indexType); + IndexWriterConfig config = new IndexWriterConfig(analyzerFactory.getAnalyzer()); + return new IndexWriter(FSDirectory.open(indexDirectory.toPath()), config); + } + + /** + * Close Writer of all indexes and update SearcherManager. + * Called at the end of the Scan flow. + */ + public void stopIndexing() { + Arrays.asList(IndexType.values()).forEach(this::stopIndexing); + } + + /** + * Close Writer of specified index and refresh SearcherManager. + * @param type + */ + private void stopIndexing(IndexType type) { + + boolean isUpdate = false; + // close + try { + isUpdate = -1 != writers.get(type).commit(); + writers.get(type).close(); + writers.remove(type); + LOG.trace("Success to create or update search index : [" + type + "]"); + } catch (IOException e) { + LOG.error("Failed to create search index.", e); + } finally { + FileUtil.closeQuietly(writers.get(type)); + } + + // refresh reader as index may have been written + if (isUpdate && searchers.containsKey(type)) { + try { + searchers.get(type).maybeRefresh(); + LOG.trace("SearcherManager has been refreshed : [" + type + "]"); + } catch (IOException e) { + LOG.error("Failed to refresh SearcherManager : [" + type + "]", e); + searchers.remove(type); + } + } + + } + + /** + * Return the IndexSearcher of the specified index. + * At initial startup, it may return null + * if the user performs any search before performing a scan. + * + * @param indexType + * @return + */ + public @Nullable IndexSearcher getSearcher(IndexType indexType) { + if (!searchers.containsKey(indexType)) { + File indexDirectory = getIndexDirectory.apply(indexType); + try { + if (indexDirectory.exists()) { + SearcherManager manager = new SearcherManager(FSDirectory.open(indexDirectory.toPath()), null); + searchers.put(indexType, manager); + } else { + LOG.warn("{} does not exist. Please run a scan.", indexDirectory.getAbsolutePath()); + } + } catch (IOException e) { + LOG.error("Failed to initialize SearcherManager.", e); + } + } + try { + SearcherManager manager = searchers.get(indexType); + if (!isEmpty(manager)) { + return searchers.get(indexType).acquire(); + } + } catch (Exception e) { + LOG.warn("Failed to acquire IndexSearcher.", e); + } + return null; + } + + public void release(IndexType indexType, IndexSearcher indexSearcher) { + if (searchers.containsKey(indexType)) { + try { + searchers.get(indexType).release(indexSearcher); + } catch (IOException e) { + LOG.error("Failed to release IndexSearcher.", e); + searchers.remove(indexType); + } + } else { + // irregular case + try { + indexSearcher.getIndexReader().close(); + } catch (Exception e) { + LOG.warn("Failed to release. IndexSearcher has been closed.", e); + } + } + } + + /** + * Check the version of the index and clean it up if necessary. + * Legacy type indexes (files or directories starting with lucene) are deleted. + * If there is no index directory, initialize the directory. + * If the index directory exists and is not the current version, + * initialize the directory. + */ + public void deleteOldIndexFiles() { + + // Delete legacy files unconditionally + Arrays.stream(SettingsService.getAirsonicHome() + .listFiles((file, name) -> Pattern.compile("^lucene\\d+$").matcher(name).matches())).forEach(old -> { + if (FileUtil.exists(old)) { + LOG.info("Found legacy index file. Try to delete : {}", old.getAbsolutePath()); + try { + if (old.isFile()) { + FileUtils.deleteQuietly(old); + } else { + FileUtils.deleteDirectory(old); + } + } catch (IOException e) { + // Log only if failed + LOG.warn("Failed to delete the legacy Index : ".concat(old.getAbsolutePath()), e); + } + } + }); + + // Delete if not old index version + Arrays.stream(SettingsService.getAirsonicHome() + .listFiles((file, name) -> Pattern.compile("^index\\d+$").matcher(name).matches())) + .filter(dir -> !dir.getName().equals(rootIndexDirectory.get().getName())) + .forEach(old -> { + if (FileUtil.exists(old)) { + LOG.info("Found old index file. Try to delete : {}", old.getAbsolutePath()); + try { + if (old.isFile()) { + FileUtils.deleteQuietly(old); + } else { + FileUtils.deleteDirectory(old); + } + } catch (IOException e) { + // Log only if failed + LOG.warn("Failed to delete the old Index : ".concat(old.getAbsolutePath()), e); + } + } + }); + + } + + /** + * Create a directory corresponding to the current index version. + */ + public void initializeIndexDirectory() { + // Check if Index is current version + if (rootIndexDirectory.get().exists()) { + // Index of current version already exists + LOG.info("Index was found (index version {}). ", INDEX_VERSION); + } else { + if (rootIndexDirectory.get().mkdir()) { + LOG.info("Index directory was created (index version {}). ", INDEX_VERSION); + } else { + LOG.warn("Failed to create index directory : (index version {}). ", INDEX_VERSION); + } + } + } + +} diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java index 16ba0173..49673f02 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java @@ -1,137 +1,142 @@ -/* - This file is part of Airsonic. - - Airsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Airsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Airsonic. If not, see . - - Copyright 2016 (C) Airsonic Authors - Based upon Subsonic, Copyright 2009 (C) Sindre Mehus - */ - -package org.airsonic.player.service.search; - -import java.util.AbstractMap; -import java.util.AbstractMap.SimpleEntry; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -/** - * Enum that symbolizes the each lucene index entity. - * This class is a division of what was once part of SearchService and added functionality. - * @since legacy - */ -public enum IndexType { - - /* - * Boosts is a factor for search scores, which is 1 by default. - */ - SONG( - fieldNames( - FieldNames.TITLE, - FieldNames.ARTIST), - boosts( - entry(FieldNames.TITLE, 2F))), - - ALBUM( - fieldNames( - FieldNames.ALBUM, - FieldNames.ARTIST, - FieldNames.FOLDER), - boosts( - entry(FieldNames.ALBUM, 2F))), - - ALBUM_ID3( - fieldNames( - FieldNames.ALBUM, - FieldNames.ARTIST, - FieldNames.FOLDER_ID), - boosts( - entry(FieldNames.ALBUM, 2F))), - - ARTIST( - fieldNames( - FieldNames.ARTIST, - FieldNames.FOLDER), - boosts( - entry(FieldNames.ARTIST, 1F))), - - ARTIST_ID3( - fieldNames( - FieldNames.ARTIST), - boosts( - entry(FieldNames.ARTIST, 2F))), - ; - - @SafeVarargs - private static final Map boosts(SimpleEntry... entry) { - Map m = new HashMap<>(); - Arrays.stream(entry).forEach(kv -> m.put(kv.getKey(), kv.getValue())); - return Collections.unmodifiableMap(m); - } - - /* - * The current state is implemented to set the same value as legacy. - * However unlike legacy, it has been changed - * so that different values ​​can be set for each field. - * When setting two or more boost values, - * it is desirable to differentiate the values. - */ - private static final SimpleEntry entry(String k, float v) { - return new AbstractMap.SimpleEntry<>(k, v); - } - - private static final String[] fieldNames(String... names) { - return Arrays.stream(names).toArray(String[]::new); - } - - private final Map boosts; - - private final String[] fields; - - private IndexType(String[] fieldNames, Map boosts) { - this.fields = fieldNames; - this.boosts = boosts; - } - - /** - * Returns a map of fields and boost values. - * - * @return map of fields and boost values - * @since legacy - */ - /* - * See the lucene documentation for boost specifications. - */ - public Map getBoosts() { - return boosts; - } - - /** - * Return some of the fields defined in the index. - * - * @return Fields mainly used in multi-field search - * @since legacy - */ - /* - * It maintains a fairly early implementation - * and can be considered as an argument of MultiFieldQueryParser. - * In fact, the fields and boosts used in the search are difficult topics - * that can be determined by the search requirements. - */ - public String[] getFields() { - return fields; - } - -} +/* + This file is part of Airsonic. + + Airsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Airsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Airsonic. If not, see . + + Copyright 2016 (C) Airsonic Authors + Based upon Subsonic, Copyright 2009 (C) Sindre Mehus + */ + +package org.airsonic.player.service.search; + +import java.util.AbstractMap; +import java.util.AbstractMap.SimpleEntry; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Enum that symbolizes the each lucene index entity. + * This class is a division of what was once part of SearchService and added functionality. + * @since legacy + */ +public enum IndexType { + + SONG( + fieldNames( + FieldNames.TITLE, + FieldNames.ARTIST), + boosts( + entry(FieldNames.TITLE, 2F))), + + ALBUM( + fieldNames( + FieldNames.ALBUM, + FieldNames.ARTIST), + // FieldNames.FOLDER), // XXX 3.x -> 8.x : Remove folder from multi-field search condition + boosts( + entry(FieldNames.ALBUM, 2F))), + + ALBUM_ID3( + fieldNames( + FieldNames.ALBUM, + FieldNames.ARTIST), + // FieldNames.FOLDER_ID), // XXX 3.x -> 8.x : Remove folder from multi-field search condition + boosts( + entry(FieldNames.ALBUM, 2F))), + + ARTIST( + fieldNames( + FieldNames.ARTIST), + // FieldNames.FOLDER), // XXX 3.x -> 8.x : Remove folder from multi-field search condition + boosts( + entry(FieldNames.ARTIST, 1F))), + + ARTIST_ID3( + fieldNames( + FieldNames.ARTIST), + boosts( + entry(FieldNames.ARTIST, 2F))), + + ; + + /** + * Define the field's applied boost value when searching IndexType. + * + * @param entry {@link #entry(String, float)}. + * When specifying multiple values, enumerate entries. + * @return Map of boost values ​​to be applied to the field + */ + @SafeVarargs + private static final Map boosts(SimpleEntry... entry) { + Map m = new HashMap<>(); + Arrays.stream(entry).forEach(kv -> m.put(kv.getKey(), kv.getValue())); + return Collections.unmodifiableMap(m); + } + + /** + * Create an entry representing the boost value for the field. + * + * @param k Field name defined by FieldNames + * @param v Boost value + * @return + */ + private static final SimpleEntry entry(String k, float v) { + return new AbstractMap.SimpleEntry<>(k, v); + } + + /** + * Defines the field that the input value is to search for + * when searching IndexType. + * If you specify multiple values, list the field names. + * + * @param names + * @return + */ + private static final String[] fieldNames(String... names) { + return Arrays.stream(names).toArray(String[]::new); + } + + private final Map boosts; + + private final String[] fields; + + private IndexType(String[] fieldNames, Map boosts) { + this.fields = fieldNames; + this.boosts = boosts; + } + + /** + * Returns a map of fields and boost values. + * + * @return Map of fields and boost values + * @since legacy + * @see BoostQuery + */ + public Map getBoosts() { + return boosts; + } + + /** + * Return some of the fields defined in the index. + * + * @return Fields mainly used in multi-field search + * @since legacy + */ + public String[] getFields() { + return fields; + } + +} diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java index f2427c97..4fb79d1e 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java @@ -1,231 +1,291 @@ -/* - This file is part of Airsonic. - - Airsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Airsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Airsonic. If not, see . - - Copyright 2016 (C) Airsonic Authors - Based upon Subsonic, Copyright 2009 (C) Sindre Mehus - */ - -package org.airsonic.player.service.search; - -import org.airsonic.player.domain.MediaFile; -import org.airsonic.player.domain.MusicFolder; -import org.airsonic.player.domain.RandomSearchCriteria; -import org.airsonic.player.domain.SearchCriteria; -import org.apache.lucene.analysis.ASCIIFoldingFilter; -import org.apache.lucene.analysis.standard.StandardTokenizer; -import org.apache.lucene.analysis.tokenattributes.TermAttribute; -import org.apache.lucene.index.Term; -import org.apache.lucene.queryParser.MultiFieldQueryParser; -import org.apache.lucene.queryParser.ParseException; -import org.apache.lucene.queryParser.QueryParser; -import org.apache.lucene.search.BooleanClause; -import org.apache.lucene.search.BooleanQuery; -import org.apache.lucene.search.NumericRangeQuery; -import org.apache.lucene.search.Query; -import org.apache.lucene.search.TermQuery; -import org.apache.lucene.search.spans.SpanOrQuery; -import org.apache.lucene.search.spans.SpanQuery; -import org.apache.lucene.search.spans.SpanTermQuery; -import org.apache.lucene.util.NumericUtils; -import org.apache.lucene.util.Version; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.io.StringReader; -import java.util.ArrayList; -import java.util.List; - -import static org.airsonic.player.service.search.IndexType.ALBUM_ID3; -import static org.airsonic.player.service.search.IndexType.ARTIST_ID3; - -/** - * Factory class of Lucene Query. - * This class is an extract of the functionality that was once part of SearchService. - * It is for maintainability and verification. - * Each corresponds to the SearchService method. - * The API syntax for query generation depends on the lucene version. - * verification with query grammar is possible. - * On the other hand, the generated queries are relatively small by version. - * Therefore, test cases of this class are useful for large version upgrades. - **/ -@Component -public class QueryFactory { - - @Autowired - private AnalyzerFactory analyzerFactory; - - private String analyzeQuery(String query) throws IOException { - StringBuilder result = new StringBuilder(); - /* - * Version.LUCENE_30 - * It is a transient description and will be deleted when upgrading the version. - * SearchService variables are not used because the reference direction conflicts. - */ - @SuppressWarnings("resource") - ASCIIFoldingFilter filter = new ASCIIFoldingFilter( - new StandardTokenizer(Version.LUCENE_30, new StringReader(query))); - TermAttribute termAttribute = filter.getAttribute(TermAttribute.class); - while (filter.incrementToken()) { - result.append(termAttribute.term()).append("* "); - } - return result.toString(); - } - - /** - * Normalize the genre string. - * - * @param genre genre string - * @return genre string normalized - * @deprecated should be resolved with tokenizer or filter - */ - @Deprecated - private String normalizeGenre(String genre) { - return genre.toLowerCase().replace(" ", "").replace("-", ""); - } - - /** - * Query generation expression extracted from - * {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}. - * - * @param criteria criteria - * @param musicFolders musicFolders - * @param indexType {@link IndexType} - * @return Query - * @throws IOException When parsing of MultiFieldQueryParser fails - * @throws ParseException When parsing of MultiFieldQueryParser fails - */ - public Query search(SearchCriteria criteria, List musicFolders, - IndexType indexType) throws ParseException, IOException { - /* - * Version.LUCENE_30 - * It is a transient description and will be deleted when upgrading the version. - * SearchService variables are not used because the reference direction conflicts. - */ - MultiFieldQueryParser queryParser = new MultiFieldQueryParser(Version.LUCENE_30, - indexType.getFields(), analyzerFactory.getQueryAnalyzer(), indexType.getBoosts()); - - BooleanQuery query = new BooleanQuery(); - query.add(queryParser.parse(analyzeQuery(criteria.getQuery())), BooleanClause.Occur.MUST); - List musicFolderQueries = new ArrayList(); - for (MusicFolder musicFolder : musicFolders) { - if (indexType == ALBUM_ID3 || indexType == ARTIST_ID3) { - musicFolderQueries.add(new SpanTermQuery(new Term(FieldNames.FOLDER_ID, - NumericUtils.intToPrefixCoded(musicFolder.getId())))); - } else { - musicFolderQueries.add(new SpanTermQuery( - new Term(FieldNames.FOLDER, musicFolder.getPath().getPath()))); - } - } - query.add( - new SpanOrQuery( - musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])), - BooleanClause.Occur.MUST); - return query; - } - - /** - * Query generation expression extracted from - * {@link org.airsonic.player.service.SearchService#getRandomSongs(RandomSearchCriteria)}. - * - * @param criteria criteria - * @return Query - */ - public Query getRandomSongs(RandomSearchCriteria criteria) { - BooleanQuery query = new BooleanQuery(); - query.add(new TermQuery( - new Term(FieldNames.MEDIA_TYPE, MediaFile.MediaType.MUSIC.name().toLowerCase())), - BooleanClause.Occur.MUST); - if (criteria.getGenre() != null) { - String genre = normalizeGenre(criteria.getGenre()); - query.add(new TermQuery(new Term(FieldNames.GENRE, genre)), BooleanClause.Occur.MUST); - } - if (criteria.getFromYear() != null || criteria.getToYear() != null) { - NumericRangeQuery rangeQuery = NumericRangeQuery.newIntRange(FieldNames.YEAR, - criteria.getFromYear(), criteria.getToYear(), true, true); - query.add(rangeQuery, BooleanClause.Occur.MUST); - } - - List musicFolderQueries = new ArrayList(); - for (MusicFolder musicFolder : criteria.getMusicFolders()) { - musicFolderQueries.add(new SpanTermQuery( - new Term(FieldNames.FOLDER, musicFolder.getPath().getPath()))); - } - query.add( - new SpanOrQuery( - musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])), - BooleanClause.Occur.MUST); - return query; - } - - /** - * Query generation expression extracted from - * {@link org.airsonic.player.service.SearchService#searchByName( String, String, int, int, List, Class)}. - * - * @param fieldName {@link FieldNames} - * @return Query - * @throws ParseException When parsing of QueryParser fails - */ - public Query searchByName(String fieldName, String name) throws ParseException { - /* - * Version.LUCENE_30 - * It is a transient description and will be deleted when upgrading the version. - * SearchService variables are not used because the reference direction conflicts. - */ - QueryParser queryParser = new QueryParser(Version.LUCENE_30, fieldName, - analyzerFactory.getQueryAnalyzer()); - Query query = queryParser.parse(name + "*"); - return query; - } - - /** - * Query generation expression extracted from - * {@link org.airsonic.player.service.SearchService#getRandomAlbums(int, List)}. - * - * @param musicFolders musicFolders - * @return Query - */ - public Query getRandomAlbums(List musicFolders) { - List musicFolderQueries = new ArrayList(); - for (MusicFolder musicFolder : musicFolders) { - musicFolderQueries.add(new SpanTermQuery( - new Term(FieldNames.FOLDER, musicFolder.getPath().getPath()))); - } - Query query = new SpanOrQuery( - musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])); - return query; - } - - /** - * Query generation expression extracted from - * {@link org.airsonic.player.service.SearchService#getRandomAlbumsId3(int, List)}. - * - * @param musicFolders musicFolders - * @return Query - */ - public Query getRandomAlbumsId3(List musicFolders) { - - List musicFolderQueries = new ArrayList(); - for (MusicFolder musicFolder : musicFolders) { - musicFolderQueries.add(new SpanTermQuery(new Term(FieldNames.FOLDER_ID, - NumericUtils.intToPrefixCoded(musicFolder.getId())))); - } - Query query = new SpanOrQuery( - musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])); - return query; - } - -} +/* + This file is part of Airsonic. + + Airsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Airsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Airsonic. If not, see . + + Copyright 2016 (C) Airsonic Authors + Based upon Subsonic, Copyright 2009 (C) Sindre Mehus + */ + +package org.airsonic.player.service.search; + +import org.airsonic.player.domain.MediaFile.MediaType; +import org.airsonic.player.domain.MusicFolder; +import org.airsonic.player.domain.RandomSearchCriteria; +import org.airsonic.player.domain.SearchCriteria; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; +import org.apache.lucene.document.IntPoint; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.BooleanClause.Occur; +import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.TermQuery; +import org.apache.lucene.search.WildcardQuery; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; + +import static org.springframework.util.ObjectUtils.isEmpty; + +/** + * Factory class of Lucene Query. + * This class is an extract of the functionality that was once part of SearchService. + * It is for maintainability and verification. + * Each corresponds to the SearchService method. + * The API syntax for query generation depends on the lucene version. + * verification with query grammar is possible. + * On the other hand, the generated queries are relatively small by version. + * Therefore, test cases of this class are useful for large version upgrades. + **/ +@Component +public class QueryFactory { + + private static final String ASTERISK = "*"; + + @Autowired + private AnalyzerFactory analyzerFactory; + + private final Function toFolderIdQuery = (folder) -> { + // Unanalyzed field + return new TermQuery(new Term(FieldNames.FOLDER_ID, folder.getId().toString())); + }; + + private final Function toFolderPathQuery = (folder) -> { + // Unanalyzed field + return new TermQuery(new Term(FieldNames.FOLDER, folder.getPath().getPath())); + }; + + /* + * XXX 3.x -> 8.x : + * "SpanOr" has been changed to "Or". + * - Path comparison is more appropriate with "Or". + * - If "SpanOr" is maintained, the DOC design needs to be changed. + */ + private final BiFunction<@NonNull Boolean, @NonNull List, @NonNull Query> toFolderQuery = ( + isId3, folders) -> { + BooleanQuery.Builder mfQuery = new BooleanQuery.Builder(); + folders.stream() + .map(isId3 ? toFolderIdQuery : toFolderPathQuery) + .forEach(t -> mfQuery.add(t, Occur.SHOULD)); + return mfQuery.build(); + }; + + /* + * XXX 3.x -> 8.x : + * In order to support wildcards, + * MultiFieldQueryParser has been replaced by the following process. + * + * - There is also an override of MultiFieldQueryParser, but it is known to be high cost. + * - MultiFieldQueryParser was created before Java API was modernized. + * - The spec of Parser has changed from time to time. Using parser does not reduce library update risk. + * - Self made parser process reduces one library dependency. + * - It is easy to make corrections later when changing the query to improve search accuracy. + */ + private Query createMultiFieldWildQuery(@NonNull String[] fieldNames, @NonNull String queryString) + throws IOException { + + BooleanQuery.Builder mainQuery = new BooleanQuery.Builder(); + + List> fieldsQuerys = new ArrayList<>(); + Analyzer analyzer = analyzerFactory.getQueryAnalyzer(); + + /* Wildcard applies to all tokens. **/ + for (String fieldName : fieldNames) { + try (TokenStream stream = analyzer.tokenStream(fieldName, queryString)) { + stream.reset(); + List fieldQuerys = new ArrayList<>(); + while (stream.incrementToken()) { + String token = stream.getAttribute(CharTermAttribute.class).toString(); + WildcardQuery wildcardQuery = new WildcardQuery(new Term(fieldName, token.concat(ASTERISK))); + fieldQuerys.add(wildcardQuery); + } + fieldsQuerys.add(fieldQuerys); + } + } + + /* If Field's Tokenizer is different, token's length may not match. **/ + int maxTermLength = fieldsQuerys.stream() + .map(l -> l.size()) + .max(Integer::compare).get(); + + if (0 < fieldsQuerys.size()) { + for (int i = 0; i < maxTermLength; i++) { + BooleanQuery.Builder fieldsQuery = new BooleanQuery.Builder(); + for (List fieldQuerys : fieldsQuerys) { + if (i < fieldQuerys.size()) { + fieldsQuery.add(fieldQuerys.get(i), Occur.SHOULD); + } + } + mainQuery.add(fieldsQuery.build(), Occur.SHOULD); + } + } + + return mainQuery.build(); + + }; + + /* + * XXX 3.x -> 8.x : + * RangeQuery has been changed to not allow null. + */ + private final BiFunction<@Nullable Integer, @Nullable Integer, @NonNull Query> toYearRangeQuery = + (from, to) -> { + return IntPoint.newRangeQuery(FieldNames.YEAR, + isEmpty(from) ? Integer.MIN_VALUE : from, + isEmpty(to) ? Integer.MAX_VALUE : to); + }; + + /** + * Query generation expression extracted from + * {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}. + * + * @param criteria criteria + * @param musicFolders musicFolders + * @param indexType {@link IndexType} + * @return Query + * @throws IOException When parsing of MultiFieldQueryParser fails + */ + public Query search(SearchCriteria criteria, List musicFolders, + IndexType indexType) throws IOException { + + BooleanQuery.Builder mainQuery = new BooleanQuery.Builder(); + + Query multiFieldQuery = createMultiFieldWildQuery(indexType.getFields(), criteria.getQuery()); + mainQuery.add(multiFieldQuery, Occur.MUST); + + boolean isId3 = indexType == IndexType.ALBUM_ID3 || indexType == IndexType.ARTIST_ID3; + Query folderQuery = toFolderQuery.apply(isId3, musicFolders); + mainQuery.add(folderQuery, Occur.MUST); + + return mainQuery.build(); + + } + + /** + * Query generation expression extracted from + * {@link org.airsonic.player.service.SearchService#getRandomSongs(RandomSearchCriteria)}. + * + * @param criteria criteria + * @return Query + * @throws IOException + */ + public Query getRandomSongs(RandomSearchCriteria criteria) throws IOException { + + BooleanQuery.Builder query = new BooleanQuery.Builder(); + + Analyzer analyzer = analyzerFactory.getQueryAnalyzer(); + + // Unanalyzed field + query.add(new TermQuery(new Term(FieldNames.MEDIA_TYPE, MediaType.MUSIC.name())), Occur.MUST); + + if (!isEmpty(criteria.getGenre())) { + + // Unanalyzed field, but performs filtering according to id3 tag parser. + try (TokenStream stream = analyzer.tokenStream(FieldNames.GENRE, criteria.getGenre())) { + stream.reset(); + if (stream.incrementToken()) { + String token = stream.getAttribute(CharTermAttribute.class).toString(); + query.add(new TermQuery(new Term(FieldNames.GENRE, token)), Occur.MUST); + } + } + } + + if (!(isEmpty(criteria.getFromYear()) && isEmpty(criteria.getToYear()))) { + query.add(toYearRangeQuery.apply(criteria.getFromYear(), criteria.getToYear()), Occur.MUST); + } + + query.add(toFolderQuery.apply(false, criteria.getMusicFolders()), Occur.MUST); + + return query.build(); + + } + + /** + * Query generation expression extracted from + * {@link org.airsonic.player.service.SearchService#searchByName( String, String, int, int, List, Class)}. + * + * @param fieldName {@link FieldNames} + * @return Query + * @throws IOException When parsing of QueryParser fails + */ + public Query searchByName(String fieldName, String name) throws IOException { + + BooleanQuery.Builder mainQuery = new BooleanQuery.Builder(); + + Analyzer analyzer = analyzerFactory.getQueryAnalyzer(); + + try (TokenStream stream = analyzer.tokenStream(fieldName, name)) { + stream.reset(); + stream.incrementToken(); + + /* + * XXX 3.x -> 8.x : + * In order to support wildcards, + * QueryParser has been replaced by the following process. + */ + + /* Wildcards apply only to tail tokens **/ + while (true) { + String token = stream.getAttribute(CharTermAttribute.class).toString(); + if (stream.incrementToken()) { + mainQuery.add(new TermQuery(new Term(fieldName, token)), Occur.SHOULD); + } else { + WildcardQuery wildcardQuery = new WildcardQuery(new Term(fieldName, token.concat(ASTERISK))); + mainQuery.add(wildcardQuery, Occur.SHOULD); + break; + } + } + + } + + return mainQuery.build(); + + } + + /** + * Query generation expression extracted from + * {@link org.airsonic.player.service.SearchService#getRandomAlbums(int, List)}. + * + * @param musicFolders musicFolders + * @return Query + */ + public Query getRandomAlbums(List musicFolders) { + return new BooleanQuery.Builder() + .add(toFolderQuery.apply(false, musicFolders), Occur.SHOULD) + .build(); + } + + /** + * Query generation expression extracted from + * {@link org.airsonic.player.service.SearchService#getRandomAlbumsId3(int, List)}. + * + * @param musicFolders musicFolders + * @return Query + */ + public Query getRandomAlbumsId3(List musicFolders) { + return new BooleanQuery.Builder() + .add(toFolderQuery.apply(true, musicFolders), Occur.SHOULD) + .build(); + } + +} diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java index 36725ce2..269bea03 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java @@ -22,14 +22,8 @@ package org.airsonic.player.service.search; import org.airsonic.player.domain.*; import org.airsonic.player.service.SearchService; -import org.airsonic.player.util.FileUtil; import org.apache.lucene.document.Document; -import org.apache.lucene.index.IndexReader; -import org.apache.lucene.index.IndexWriter; -import org.apache.lucene.queryParser.ParseException; import org.apache.lucene.search.*; -import org.apache.lucene.store.Directory; -import org.apache.lucene.store.FSDirectory; import org.checkerframework.checker.nullness.qual.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,35 +53,6 @@ public class SearchServiceImpl implements SearchService { // TODO Should be changed to SecureRandom? private final Random random = new Random(System.currentTimeMillis()); - public SearchServiceImpl() { - removeLocks(); - } - - @Override - public void startIndexing() { - indexManager.startIndexing(); - } - - @Override - public void index(MediaFile mediaFile) { - indexManager.index(mediaFile); - } - - @Override - public void index(Artist artist, MusicFolder musicFolder) { - indexManager.index(artist, musicFolder); - } - - @Override - public void index(Album album) { - indexManager.index(album); - } - - @Override - public void stopIndexing() { - indexManager.stopIndexing(); - } - @Override public SearchResult search(SearchCriteria criteria, List musicFolders, IndexType indexType) { @@ -100,26 +65,28 @@ public class SearchServiceImpl implements SearchService { if (count <= 0) return result; - IndexReader reader = null; - try { + IndexSearcher searcher = indexManager.getSearcher(indexType); + if (isEmpty(searcher)) { + return result; + } - reader = indexManager.createIndexReader(indexType); - Searcher searcher = new IndexSearcher(reader); + try { Query query = queryFactory.search(criteria, musicFolders, indexType); - TopDocs topDocs = searcher.search(query, null, offset + count); - result.setTotalHits(topDocs.totalHits); - int start = Math.min(offset, topDocs.totalHits); - int end = Math.min(start + count, topDocs.totalHits); + TopDocs topDocs = searcher.search(query, offset + count); + int totalHits = util.round.apply(topDocs.totalHits.value); + result.setTotalHits(totalHits); + int start = Math.min(offset, totalHits); + int end = Math.min(start + count, totalHits); for (int i = start; i < end; i++) { util.addIfAnyMatch(result, indexType, searcher.doc(topDocs.scoreDocs[i].doc)); } - } catch (IOException | ParseException e) { + } catch (IOException e) { LOG.error("Failed to execute Lucene search.", e); } finally { - FileUtil.closeQuietly(reader); + indexManager.release(indexType, searcher); } return result; } @@ -135,7 +102,7 @@ public class SearchServiceImpl implements SearchService { * @throws IOException */ private final List createRandomDocsList( - int count, Searcher searcher, Query query, BiConsumer, Integer> id2ListCallBack) + int count, IndexSearcher searcher, Query query, BiConsumer, Integer> id2ListCallBack) throws IOException { List docs = Arrays @@ -157,14 +124,13 @@ public class SearchServiceImpl implements SearchService { @Override public List getRandomSongs(RandomSearchCriteria criteria) { - IndexReader reader = null; + IndexSearcher searcher = indexManager.getSearcher(SONG); + if (isEmpty(searcher)) { + // At first start + return Collections.emptyList(); + } + try { - reader = indexManager.createIndexReader(SONG); - Searcher searcher = new IndexSearcher(reader); - if (isEmpty(searcher)) { - // At first start - return Collections.emptyList(); - } Query query = queryFactory.getRandomSongs(criteria); return createRandomDocsList(criteria.getCount(), searcher, query, @@ -173,7 +139,7 @@ public class SearchServiceImpl implements SearchService { } catch (IOException e) { LOG.error("Failed to search or random songs.", e); } finally { - FileUtil.closeQuietly(reader); + indexManager.release(IndexType.SONG, searcher); } return Collections.emptyList(); } @@ -181,22 +147,22 @@ public class SearchServiceImpl implements SearchService { @Override public List getRandomAlbums(int count, List musicFolders) { - IndexReader reader = null; + IndexSearcher searcher = indexManager.getSearcher(IndexType.ALBUM); + if (isEmpty(searcher)) { + return Collections.emptyList(); + } + + Query query = queryFactory.getRandomAlbums(musicFolders); + try { - reader = indexManager.createIndexReader(ALBUM); - Searcher searcher = new IndexSearcher(reader); - if (isEmpty(searcher)) { - return Collections.emptyList(); - } - Query query = queryFactory.getRandomAlbums(musicFolders); return createRandomDocsList(count, searcher, query, (dist, id) -> util.addIgnoreNull(dist, ALBUM, id)); } catch (IOException e) { LOG.error("Failed to search for random albums.", e); } finally { - FileUtil.closeQuietly(reader); + indexManager.release(IndexType.ALBUM, searcher); } return Collections.emptyList(); } @@ -204,22 +170,22 @@ public class SearchServiceImpl implements SearchService { @Override public List getRandomAlbumsId3(int count, List musicFolders) { - IndexReader reader = null; + IndexSearcher searcher = indexManager.getSearcher(IndexType.ALBUM_ID3); + if (isEmpty(searcher)) { + return Collections.emptyList(); + } + + Query query = queryFactory.getRandomAlbumsId3(musicFolders); + try { - reader = indexManager.createIndexReader(ALBUM_ID3); - Searcher searcher = new IndexSearcher(reader); - if (isEmpty(searcher)) { - return Collections.emptyList(); - } - Query query = queryFactory.getRandomAlbumsId3(musicFolders); return createRandomDocsList(count, searcher, query, (dist, id) -> util.addIgnoreNull(dist, ALBUM_ID3, id)); } catch (IOException e) { LOG.error("Failed to search for random albums.", e); } finally { - FileUtil.closeQuietly(reader); + indexManager.release(IndexType.ALBUM_ID3, searcher); } return Collections.emptyList(); } @@ -241,58 +207,39 @@ public class SearchServiceImpl implements SearchService { return result; } - IndexReader reader = null; + IndexSearcher searcher = indexManager.getSearcher(indexType); + if (isEmpty(searcher)) { + return result; + } try { - reader = indexManager.createIndexReader(indexType); - Searcher searcher = new IndexSearcher(reader); + Query query = queryFactory.searchByName(fieldName, name); - Sort sort = new Sort(new SortField(fieldName, SortField.STRING)); - TopDocs topDocs = searcher.search(query, null, offset + count, sort); + SortField[] sortFields = Arrays + .stream(indexType.getFields()) + .map(n -> new SortField(n, SortField.Type.STRING)) + .toArray(i -> new SortField[i]); + Sort sort = new Sort(sortFields); + + TopDocs topDocs = searcher.search(query, offset + count, sort); - result.setTotalHits(topDocs.totalHits); - int start = Math.min(offset, topDocs.totalHits); - int end = Math.min(start + count, topDocs.totalHits); + int totalHits = util.round.apply(topDocs.totalHits.value); + result.setTotalHits(totalHits); + int start = Math.min(offset, totalHits); + int end = Math.min(start + count, totalHits); for (int i = start; i < end; i++) { Document doc = searcher.doc(topDocs.scoreDocs[i].doc); util.addIgnoreNull(result, indexType, util.getId.apply(doc), assignableClass); } - } catch (IOException | ParseException e) { + } catch (IOException e) { LOG.error("Failed to execute Lucene search.", e); } finally { - FileUtil.closeQuietly(reader); + indexManager.release(indexType, searcher); } return result; } - /** - * Locks are managed automatically by the framework. - * - * @deprecated It becomes unnecessary at the time of version upgrade. - */ - @Deprecated - public void removeLocks() { - for (IndexType indexType : IndexType.values()) { - Directory dir = null; - try { - /* - * Static access to the accompanying method is performed as a transition period. - * (Unnecessary processing after updating Lucene.) - */ - dir = FSDirectory.open(IndexManager.getIndexDirectory(indexType)); - if (IndexWriter.isLocked(dir)) { - IndexWriter.unlock(dir); - LOG.info("Removed Lucene lock file in " + dir); - } - } catch (Exception x) { - LOG.warn("Failed to remove Lucene lock file in " + dir, x); - } finally { - FileUtil.closeQuietly(dir); - } - } - } - } diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java index a6ba41f6..78bf45fd 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java @@ -1,200 +1,176 @@ -/* - This file is part of Airsonic. - - Airsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Airsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Airsonic. If not, see . - - Copyright 2016 (C) Airsonic Authors - Based upon Subsonic, Copyright 2009 (C) Sindre Mehus - */ - -package org.airsonic.player.service.search; - -import org.airsonic.player.dao.AlbumDao; -import org.airsonic.player.dao.ArtistDao; -import org.airsonic.player.domain.Album; -import org.airsonic.player.domain.Artist; -import org.airsonic.player.domain.MediaFile; -import org.airsonic.player.domain.ParamSearchResult; -import org.airsonic.player.domain.SearchResult; -import org.airsonic.player.service.MediaFileService; -import org.airsonic.player.service.SettingsService; -import org.apache.commons.collections.CollectionUtils; -import org.apache.lucene.document.Document; -import org.apache.lucene.index.Term; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.io.File; -import java.util.Collection; -import java.util.List; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; -import java.util.function.Function; - -import static org.springframework.util.ObjectUtils.isEmpty; - -/** - * Termination used by SearchService. - * - * Since SearchService operates as a proxy for storage (DB) using lucene, - * there are many redundant descriptions different from essential data processing. - * This class is a transfer class for saving those redundant descriptions. - * - * Exception handling is not termination, - * so do not include exception handling in this class. - */ -@Component -public class SearchServiceUtilities { - - /* Search by id only. */ - @Autowired - private ArtistDao artistDao; - - /* Search by id only. */ - @Autowired - private AlbumDao albumDao; - - /* - * Search by id only. - * Although there is no influence at present, - * mediaFileService has a caching mechanism. - * Service is used instead of Dao until you are sure you need to use mediaFileDao. - */ - @Autowired - private MediaFileService mediaFileService; - - public final Function round = (i) -> { - // return - // NumericUtils.floatToSortableInt(i); - return i.intValue(); - }; - - public final Function getId = d -> { - return Integer.valueOf(d.get(FieldNames.ID)); - }; - - public final BiConsumer, Integer> addMediaFileIfAnyMatch = (dist, id) -> { - if (!dist.stream().anyMatch(m -> id == m.getId())) { - MediaFile mediaFile = mediaFileService.getMediaFile(id); - if (!isEmpty(mediaFile)) { - dist.add(mediaFile); - } - } - }; - - public final BiConsumer, Integer> addArtistId3IfAnyMatch = (dist, id) -> { - if (!dist.stream().anyMatch(a -> id == a.getId())) { - Artist artist = artistDao.getArtist(id); - if (!isEmpty(artist)) { - dist.add(artist); - } - } - }; - - public final Function, @Nullable IndexType> getIndexType = (assignableClass) -> { - IndexType indexType = null; - if (assignableClass.isAssignableFrom(Album.class)) { - indexType = IndexType.ALBUM_ID3; - } else if (assignableClass.isAssignableFrom(Artist.class)) { - indexType = IndexType.ARTIST_ID3; - } else if (assignableClass.isAssignableFrom(MediaFile.class)) { - indexType = IndexType.SONG; - } - return indexType; - }; - - public final Function, @Nullable String> getFieldName = (assignableClass) -> { - String fieldName = null; - if (assignableClass.isAssignableFrom(Album.class)) { - fieldName = FieldNames.ALBUM; - } else if (assignableClass.isAssignableFrom(Artist.class)) { - fieldName = FieldNames.ARTIST; - } else if (assignableClass.isAssignableFrom(MediaFile.class)) { - fieldName = FieldNames.TITLE; - } - return fieldName; - }; - - public final BiConsumer, Integer> addAlbumId3IfAnyMatch = (dist, subjectId) -> { - if (!dist.stream().anyMatch(a -> subjectId == a.getId())) { - Album album = albumDao.getAlbum(subjectId); - if (!isEmpty(album)) { - dist.add(album); - } - } - }; - - private final Function getRootDirectory = (version) -> { - return new File(SettingsService.getAirsonicHome(), version); - }; - - public final BiFunction getDirectory = (version, indexType) -> { - return new File(getRootDirectory.apply(version), indexType.toString().toLowerCase()); - }; - - public final Term createPrimarykey(Album album) { - return new Term(FieldNames.ID, Integer.toString(album.getId())); - }; - - public final Term createPrimarykey(Artist artist) { - return new Term(FieldNames.ID, Integer.toString(artist.getId())); - }; - - public final Term createPrimarykey(MediaFile mediaFile) { - return new Term(FieldNames.ID, Integer.toString(mediaFile.getId())); - }; - - public final boolean addIgnoreNull(Collection collection, Object object) { - return CollectionUtils.addIgnoreNull(collection, object); - } - - public final boolean addIgnoreNull(Collection collection, IndexType indexType, - int subjectId) { - if (indexType == IndexType.ALBUM | indexType == IndexType.SONG) { - return addIgnoreNull(collection, mediaFileService.getMediaFile(subjectId)); - } else if (indexType == IndexType.ALBUM_ID3) { - return addIgnoreNull(collection, albumDao.getAlbum(subjectId)); - } - return false; - } - - public final void addIgnoreNull(ParamSearchResult dist, IndexType indexType, - int subjectId, Class subjectClass) { - if (indexType == IndexType.SONG) { - MediaFile mediaFile = mediaFileService.getMediaFile(subjectId); - addIgnoreNull(dist.getItems(), subjectClass.cast(mediaFile)); - } else if (indexType == IndexType.ARTIST_ID3) { - Artist artist = artistDao.getArtist(subjectId); - addIgnoreNull(dist.getItems(), subjectClass.cast(artist)); - } else if (indexType == IndexType.ALBUM_ID3) { - Album album = albumDao.getAlbum(subjectId); - addIgnoreNull(dist.getItems(), subjectClass.cast(album)); - } - } - - public final void addIfAnyMatch(SearchResult dist, IndexType subjectIndexType, - Document subject) { - int documentId = getId.apply(subject); - if (subjectIndexType == IndexType.ARTIST | subjectIndexType == IndexType.ALBUM - | subjectIndexType == IndexType.SONG) { - addMediaFileIfAnyMatch.accept(dist.getMediaFiles(), documentId); - } else if (subjectIndexType == IndexType.ARTIST_ID3) { - addArtistId3IfAnyMatch.accept(dist.getArtists(), documentId); - } else if (subjectIndexType == IndexType.ALBUM_ID3) { - addAlbumId3IfAnyMatch.accept(dist.getAlbums(), documentId); - } - } - -} +/* + This file is part of Airsonic. + + Airsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Airsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Airsonic. If not, see . + + Copyright 2016 (C) Airsonic Authors + Based upon Subsonic, Copyright 2009 (C) Sindre Mehus + */ + +package org.airsonic.player.service.search; + +import org.airsonic.player.dao.AlbumDao; +import org.airsonic.player.dao.ArtistDao; +import org.airsonic.player.domain.Album; +import org.airsonic.player.domain.Artist; +import org.airsonic.player.domain.MediaFile; +import org.airsonic.player.domain.ParamSearchResult; +import org.airsonic.player.domain.SearchResult; +import org.airsonic.player.service.MediaFileService; +import org.apache.commons.collections.CollectionUtils; +import org.apache.lucene.document.Document; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import static org.springframework.util.ObjectUtils.isEmpty; + +/** + * Termination used by SearchService. + * + * Since SearchService operates as a proxy for storage (DB) using lucene, + * there are many redundant descriptions different from essential data processing. + * This class is a transfer class for saving those redundant descriptions. + * + * Exception handling is not termination, + * so do not include exception handling in this class. + */ +@Component +public class SearchServiceUtilities { + + /* Search by id only. */ + @Autowired + private ArtistDao artistDao; + + /* Search by id only. */ + @Autowired + private AlbumDao albumDao; + + /* + * Search by id only. + * Although there is no influence at present, + * mediaFileService has a caching mechanism. + * Service is used instead of Dao until you are sure you need to use mediaFileDao. + */ + @Autowired + private MediaFileService mediaFileService; + + public final Function round = (i) -> { + // return + // NumericUtils.floatToSortableInt(i); + return i.intValue(); + }; + + public final Function getId = d -> { + return Integer.valueOf(d.get(FieldNames.ID)); + }; + + public final BiConsumer, Integer> addMediaFileIfAnyMatch = (dist, id) -> { + if (!dist.stream().anyMatch(m -> id == m.getId())) { + MediaFile mediaFile = mediaFileService.getMediaFile(id); + if (!isEmpty(mediaFile)) { + dist.add(mediaFile); + } + } + }; + + public final BiConsumer, Integer> addArtistId3IfAnyMatch = (dist, id) -> { + if (!dist.stream().anyMatch(a -> id == a.getId())) { + Artist artist = artistDao.getArtist(id); + if (!isEmpty(artist)) { + dist.add(artist); + } + } + }; + + public final Function, @Nullable IndexType> getIndexType = (assignableClass) -> { + IndexType indexType = null; + if (assignableClass.isAssignableFrom(Album.class)) { + indexType = IndexType.ALBUM_ID3; + } else if (assignableClass.isAssignableFrom(Artist.class)) { + indexType = IndexType.ARTIST_ID3; + } else if (assignableClass.isAssignableFrom(MediaFile.class)) { + indexType = IndexType.SONG; + } + return indexType; + }; + + public final Function, @Nullable String> getFieldName = (assignableClass) -> { + String fieldName = null; + if (assignableClass.isAssignableFrom(Album.class)) { + fieldName = FieldNames.ALBUM; + } else if (assignableClass.isAssignableFrom(Artist.class)) { + fieldName = FieldNames.ARTIST; + } else if (assignableClass.isAssignableFrom(MediaFile.class)) { + fieldName = FieldNames.TITLE; + } + return fieldName; + }; + + public final BiConsumer, Integer> addAlbumId3IfAnyMatch = (dist, subjectId) -> { + if (!dist.stream().anyMatch(a -> subjectId == a.getId())) { + Album album = albumDao.getAlbum(subjectId); + if (!isEmpty(album)) { + dist.add(album); + } + } + }; + + public final boolean addIgnoreNull(Collection collection, Object object) { + return CollectionUtils.addIgnoreNull(collection, object); + } + + public final boolean addIgnoreNull(Collection collection, IndexType indexType, + int subjectId) { + if (indexType == IndexType.ALBUM | indexType == IndexType.SONG) { + return addIgnoreNull(collection, mediaFileService.getMediaFile(subjectId)); + } else if (indexType == IndexType.ALBUM_ID3) { + return addIgnoreNull(collection, albumDao.getAlbum(subjectId)); + } + return false; + } + + public final void addIgnoreNull(ParamSearchResult dist, IndexType indexType, + int subjectId, Class subjectClass) { + if (indexType == IndexType.SONG) { + MediaFile mediaFile = mediaFileService.getMediaFile(subjectId); + addIgnoreNull(dist.getItems(), subjectClass.cast(mediaFile)); + } else if (indexType == IndexType.ARTIST_ID3) { + Artist artist = artistDao.getArtist(subjectId); + addIgnoreNull(dist.getItems(), subjectClass.cast(artist)); + } else if (indexType == IndexType.ALBUM_ID3) { + Album album = albumDao.getAlbum(subjectId); + addIgnoreNull(dist.getItems(), subjectClass.cast(album)); + } + } + + public final void addIfAnyMatch(SearchResult dist, IndexType subjectIndexType, + Document subject) { + int documentId = getId.apply(subject); + if (subjectIndexType == IndexType.ARTIST | subjectIndexType == IndexType.ALBUM + | subjectIndexType == IndexType.SONG) { + addMediaFileIfAnyMatch.accept(dist.getMediaFiles(), documentId); + } else if (subjectIndexType == IndexType.ARTIST_ID3) { + addArtistId3IfAnyMatch.accept(dist.getArtists(), documentId); + } else if (subjectIndexType == IndexType.ALBUM_ID3) { + addAlbumId3IfAnyMatch.accept(dist.getAlbums(), documentId); + } + } + +} diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/AbstractAirsonicHomeTest.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/AbstractAirsonicHomeTest.java index 0681f799..b2094863 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/search/AbstractAirsonicHomeTest.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/AbstractAirsonicHomeTest.java @@ -1,138 +1,138 @@ -package org.airsonic.player.service.search; - -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Function; - -import org.airsonic.player.TestCaseUtils; -import org.airsonic.player.dao.DaoHelper; -import org.airsonic.player.dao.MusicFolderDao; -import org.airsonic.player.service.MediaScannerService; -import org.airsonic.player.service.SettingsService; -import org.airsonic.player.util.HomeRule; -import org.airsonic.player.util.MusicFolderTestData; -import org.junit.ClassRule; -import org.junit.Rule; -import org.junit.rules.TemporaryFolder; -import org.junit.runner.Description; -import org.junit.runners.model.Statement; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.junit4.rules.SpringClassRule; -import org.springframework.test.context.junit4.rules.SpringMethodRule; - -@ContextConfiguration(locations = { - "/applicationContext-service.xml", - "/applicationContext-cache.xml", - "/applicationContext-testdb.xml"}) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) -@Component -/** - * Abstract class for scanning MusicFolder. - */ -public abstract class AbstractAirsonicHomeTest implements AirsonicHomeTest { - - @ClassRule - public static final SpringClassRule classRule = new SpringClassRule() { - HomeRule homeRule = new HomeRule(); - - @Override - public Statement apply(Statement base, Description description) { - Statement spring = super.apply(base, description); - return homeRule.apply(spring, description); - } - }; - - /* - * Currently, Maven is executing test classes in series, - * so this class can hold the state. - * When executing in parallel, subclasses should override this. - */ - private static AtomicBoolean dataBasePopulated = new AtomicBoolean(); - - // Above. - private static AtomicBoolean dataBaseReady = new AtomicBoolean(); - - protected final static Function resolveBaseMediaPath = (childPath) -> { - return MusicFolderTestData.resolveBaseMediaPath().concat(childPath); - }; - - @Autowired - protected DaoHelper daoHelper; - - @Autowired - protected MediaScannerService mediaScannerService; - - @Autowired - protected MusicFolderDao musicFolderDao; - - @Autowired - protected SettingsService settingsService; - - @Rule - public final SpringMethodRule springMethodRule = new SpringMethodRule(); - - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Override - public AtomicBoolean dataBasePopulated() { - return dataBasePopulated; - } - - @Override - public AtomicBoolean dataBaseReady() { - return dataBaseReady; - } - - @Override - public final void populateDatabaseOnlyOnce() { - if (!dataBasePopulated().get()) { - dataBasePopulated().set(true); - getMusicFolders().forEach(musicFolderDao::createMusicFolder); - settingsService.clearMusicFolderCache(); - try { - // Await time to avoid scan failure. - for (int i = 0; i < 10; i++) { - Thread.sleep(100); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - TestCaseUtils.execScan(mediaScannerService); - System.out.println("--- Report of records count per table ---"); - Map records = TestCaseUtils.recordsInAllTables(daoHelper); - records.keySet().stream().filter(s -> - s.equals("MEDIA_FILE") - | s.equals("ARTIST") - | s.equals("MUSIC_FOLDER") - | s.equals("ALBUM")) - .forEach(tableName -> - System.out.println("\t" + tableName + " : " + records.get(tableName).toString())); - System.out.println("--- *********************** ---"); - try { - // Await for Lucene to finish writing(asynchronous). - for (int i = 0; i < 5; i++) { - Thread.sleep(100); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - dataBaseReady().set(true); - } else { - while (!dataBaseReady().get()) { - try { - // The subsequent test method waits while reading DB data. - for (int i = 0; i < 10; i++) { - Thread.sleep(100); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - } - +package org.airsonic.player.service.search; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import org.airsonic.player.TestCaseUtils; +import org.airsonic.player.dao.DaoHelper; +import org.airsonic.player.dao.MusicFolderDao; +import org.airsonic.player.service.MediaScannerService; +import org.airsonic.player.service.SettingsService; +import org.airsonic.player.util.HomeRule; +import org.airsonic.player.util.MusicFolderTestData; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.rules.SpringClassRule; +import org.springframework.test.context.junit4.rules.SpringMethodRule; + +@ContextConfiguration(locations = { + "/applicationContext-service.xml", + "/applicationContext-cache.xml", + "/applicationContext-testdb.xml"}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Component +/** + * Abstract class for scanning MusicFolder. + */ +public abstract class AbstractAirsonicHomeTest implements AirsonicHomeTest { + + @ClassRule + public static final SpringClassRule classRule = new SpringClassRule() { + HomeRule homeRule = new HomeRule(); + + @Override + public Statement apply(Statement base, Description description) { + Statement spring = super.apply(base, description); + return homeRule.apply(spring, description); + } + }; + + /* + * Currently, Maven is executing test classes in series, + * so this class can hold the state. + * When executing in parallel, subclasses should override this. + */ + private static AtomicBoolean dataBasePopulated = new AtomicBoolean(); + + // Above. + private static AtomicBoolean dataBaseReady = new AtomicBoolean(); + + protected final static Function resolveBaseMediaPath = (childPath) -> { + return MusicFolderTestData.resolveBaseMediaPath().concat(childPath); + }; + + @Autowired + protected DaoHelper daoHelper; + + @Autowired + protected MediaScannerService mediaScannerService; + + @Autowired + protected MusicFolderDao musicFolderDao; + + @Autowired + protected SettingsService settingsService; + + @Rule + public final SpringMethodRule springMethodRule = new SpringMethodRule(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Override + public AtomicBoolean dataBasePopulated() { + return dataBasePopulated; + } + + @Override + public AtomicBoolean dataBaseReady() { + return dataBaseReady; + } + + @Override + public final void populateDatabaseOnlyOnce() { + if (!dataBasePopulated().get()) { + dataBasePopulated().set(true); + getMusicFolders().forEach(musicFolderDao::createMusicFolder); + settingsService.clearMusicFolderCache(); + try { + // Await time to avoid scan failure. + for (int i = 0; i < 10; i++) { + Thread.sleep(100); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + TestCaseUtils.execScan(mediaScannerService); + System.out.println("--- Report of records count per table ---"); + Map records = TestCaseUtils.recordsInAllTables(daoHelper); + records.keySet().stream().filter(s -> + s.equals("MEDIA_FILE") + | s.equals("ARTIST") + | s.equals("MUSIC_FOLDER") + | s.equals("ALBUM")) + .forEach(tableName -> + System.out.println("\t" + tableName + " : " + records.get(tableName).toString())); + System.out.println("--- *********************** ---"); + try { + // Await for Lucene to finish writing(asynchronous). + for (int i = 0; i < 5; i++) { + Thread.sleep(100); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + dataBaseReady().set(true); + } else { + while (!dataBaseReady().get()) { + try { + // The subsequent test method waits while reading DB data. + for (int i = 0; i < 10; i++) { + Thread.sleep(100); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + } + } \ No newline at end of file diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/AirsonicHomeTest.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/AirsonicHomeTest.java index b30116ef..acc129b5 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/search/AirsonicHomeTest.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/AirsonicHomeTest.java @@ -1,45 +1,45 @@ -package org.airsonic.player.service.search; - -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import org.airsonic.player.domain.MusicFolder; -import org.airsonic.player.util.MusicFolderTestData; - -/** - * Test case interface for scanning MusicFolder. - */ -public interface AirsonicHomeTest { - - /** - * MusicFolder used by test class. - * - * @return MusicFolder used by test class - */ - default List getMusicFolders() { - return MusicFolderTestData.getTestMusicFolders(); - }; - - /** - * Whether the data input has been completed. - * - * @return Static AtomicBoolean indicating whether the data injection has been - * completed - */ - abstract AtomicBoolean dataBasePopulated(); - - /** - * Whether the data input has been completed. - * - * @return Static AtomicBoolean indicating whether the data injection has been - * completed - */ - abstract AtomicBoolean dataBaseReady(); - - /** - * Populate the database only once. - * It is called in the @Before granted method. - */ - void populateDatabaseOnlyOnce(); - -} +package org.airsonic.player.service.search; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.airsonic.player.domain.MusicFolder; +import org.airsonic.player.util.MusicFolderTestData; + +/** + * Test case interface for scanning MusicFolder. + */ +public interface AirsonicHomeTest { + + /** + * MusicFolder used by test class. + * + * @return MusicFolder used by test class + */ + default List getMusicFolders() { + return MusicFolderTestData.getTestMusicFolders(); + }; + + /** + * Whether the data input has been completed. + * + * @return Static AtomicBoolean indicating whether the data injection has been + * completed + */ + abstract AtomicBoolean dataBasePopulated(); + + /** + * Whether the data input has been completed. + * + * @return Static AtomicBoolean indicating whether the data injection has been + * completed + */ + abstract AtomicBoolean dataBaseReady(); + + /** + * Populate the database only once. + * It is called in the @Before granted method. + */ + void populateDatabaseOnlyOnce(); + +} diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java index e9680023..18a35328 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java @@ -11,7 +11,7 @@ import java.util.Arrays; import java.util.List; import org.apache.lucene.analysis.TokenStream; -import org.apache.lucene.analysis.tokenattributes.TermAttribute; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; import org.junit.Test; import org.slf4j.LoggerFactory; @@ -85,9 +85,12 @@ public class AnalyzerFactoryTestCase { public void testPunctuation1() { String query = "B︴C"; - String expected1 = "b"; - String expected2 = "c"; + String expected = "b︴c"; + /* + * XXX 3.x -> 8.x : + * The definition of punctuation has changed. + */ Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> { List terms = toTermString(n, query); switch (n) { @@ -100,9 +103,8 @@ public class AnalyzerFactoryTestCase { case FieldNames.FOLDER: case FieldNames.GENRE: case FieldNames.MEDIA_TYPE: - assertEquals("tokenized : " + n, 2, terms.size()); - assertEquals("tokenized : " + n, expected1, terms.get(0)); - assertEquals("tokenized : " + n, expected2, terms.get(1)); + assertEquals("tokenized : " + n, 1, terms.size()); + assertEquals("tokenized : " + n, expected, terms.get(0)); break; /* @@ -112,9 +114,8 @@ public class AnalyzerFactoryTestCase { case FieldNames.ARTIST: case FieldNames.ALBUM: case FieldNames.TITLE: - assertEquals("tokenized : " + n, 2, terms.size()); - assertEquals("tokenized : " + n, expected1, terms.get(0)); - assertEquals("tokenized : " + n, expected2, terms.get(1)); + assertEquals("tokenized : " + n, 1, terms.size()); + assertEquals("tokenized : " + n, expected, terms.get(0)); break; /* * ID, FOLDER_ID, YEAR @@ -256,12 +257,17 @@ public class AnalyzerFactoryTestCase { */ String queryFullWidth = "THIS IS FULL-WIDTH SENTENCES."; terms = toTermString(queryFullWidth); - assertEquals(5, terms.size()); - assertEquals("this", terms.get(0));// removal target is ignored - assertEquals("is", terms.get(1)); - assertEquals("full", terms.get(2)); - assertEquals("width", terms.get(3)); - assertEquals("sentences", terms.get(4)); + /* + * XXX 3.x -> 8.x : + * + * This is not a change due to the library but an intentional change. + * The filter order has been changed properly + * as it is probably not a deliberate specification. + */ + assertEquals(3, terms.size()); + assertEquals("full", terms.get(0)); + assertEquals("width", terms.get(1)); + assertEquals("sentences", terms.get(2)); } @@ -428,6 +434,11 @@ public class AnalyzerFactoryTestCase { query = "_ID3_ARTIST_ Céline Frisch: Café Zimmermann"; terms = toTermString(query); assertEquals(5, terms.size()); + + /* + * XXX 3.x -> 8.x : _id3_artist_ in UAX#29. + * Since the effect is large, trim with Filter. + */ assertEquals("id3_artist", terms.get(0)); assertEquals("celine", terms.get(1)); assertEquals("frisch", terms.get(2)); @@ -438,6 +449,11 @@ public class AnalyzerFactoryTestCase { query = "_ID3_ARTIST_ Sarah Walker/Nash Ensemble"; terms = toTermString(query); assertEquals(5, terms.size()); + + /* + * XXX 3.x -> 8.x : _id3_artist_ in UAX#29. + * Since the effect is large, trim with Filter. + */ assertEquals("id3_artist", terms.get(0)); assertEquals("sarah", terms.get(1)); assertEquals("walker", terms.get(2)); @@ -464,14 +480,18 @@ public class AnalyzerFactoryTestCase { assertEquals(asList("abc", "def"), toTermString("~ABC~DEF~")); assertEquals(asList("abc", "def"), toTermString("*ABC*DEF*")); assertEquals(asList("abc", "def"), toTermString("?ABC?DEF?")); - assertEquals(asList("abc", "def"), toTermString(":ABC:DEF:")); + assertEquals(asList("abc:def"), toTermString(":ABC:DEF:")); // XXX 3.x -> 8.x : abc def -> abc:def assertEquals(asList("abc", "def"), toTermString("-ABC-DEF-")); assertEquals(asList("abc", "def"), toTermString("/ABC/DEF/")); - assertEquals(asList("abc", "def"), toTermString("_ABC_DEF_")); + /* + * XXX 3.x -> 8.x : _abc_def_ in UAX#29. + * Since the effect is large, trim with Filter. + */ + assertEquals(asList("abc_def"), toTermString("_ABC_DEF_")); // XXX 3.x -> 8.x : abc def -> abc_def assertEquals(asList("abc", "def"), toTermString(",ABC,DEF,")); assertEquals(asList("abc.def"), toTermString(".ABC.DEF.")); - assertEquals(asList("abc&def"), toTermString("&ABC&DEF&")); - assertEquals(asList("abc@def"), toTermString("@ABC@DEF@")); + assertEquals(asList("abc", "def"), toTermString("&ABC&DEF&")); // XXX 3.x -> 8.x : abc&def -> abc def + assertEquals(asList("abc", "def"), toTermString("@ABC@DEF@")); // XXX 3.x -> 8.x : abc@def -> abc def assertEquals(asList("abc'def"), toTermString("'ABC'DEF'")); // trim and delimiter and number @@ -491,11 +511,15 @@ public class AnalyzerFactoryTestCase { assertEquals(asList("abc1", "def"), toTermString("*ABC1*DEF*")); assertEquals(asList("abc1", "def"), toTermString("?ABC1?DEF?")); assertEquals(asList("abc1", "def"), toTermString(":ABC1:DEF:")); - assertEquals(asList("abc1,def"), toTermString(",ABC1,DEF,")); - assertEquals(asList("abc1-def"), toTermString("-ABC1-DEF-")); - assertEquals(asList("abc1/def"), toTermString("/ABC1/DEF/")); + assertEquals(asList("abc1", "def"), toTermString(",ABC1,DEF,")); // XXX 3.x -> 8.x : abc1,def -> abc1 def + assertEquals(asList("abc1", "def"), toTermString("-ABC1-DEF-")); // XXX 3.x -> 8.x : abc1-def -> abc1 def + assertEquals(asList("abc1", "def"), toTermString("/ABC1/DEF/")); // XXX 3.x -> 8.x : abc1/def -> abc1 def + /* + * XXX 3.x -> 8.x : _abc1_def_ in UAX#29. + * Since the effect is large, trim with Filter. + */ assertEquals(asList("abc1_def"), toTermString("_ABC1_DEF_")); - assertEquals(asList("abc1.def"), toTermString(".ABC1.DEF.")); + assertEquals(asList("abc1", "def"), toTermString(".ABC1.DEF.")); // XXX 3.x -> 8.x : abc1.def -> abc1 def assertEquals(asList("abc1", "def"), toTermString("&ABC1&DEF&")); assertEquals(asList("abc1", "def"), toTermString("@ABC1@DEF@")); assertEquals(asList("abc1", "def"), toTermString("'ABC1'DEF'")); @@ -517,14 +541,17 @@ public class AnalyzerFactoryTestCase { assertEquals("airsonic", terms.get(0)); assertEquals("analysis", terms.get(1)); + /* + * XXX 3.x -> 8.x : + * we ve -> we've + */ query = "We’ve been here before."; terms = toTermString(query); - assertEquals(5, terms.size()); - assertEquals("we", terms.get(0)); - assertEquals("ve", terms.get(1)); - assertEquals("been", terms.get(2)); - assertEquals("here", terms.get(3)); - assertEquals("before", terms.get(4)); + assertEquals(4, terms.size()); + assertEquals("we've", terms.get(0)); + assertEquals("been", terms.get(1)); + assertEquals("here", terms.get(2)); + assertEquals("before", terms.get(3)); query = "LʼHomme"; terms = toTermString(query); @@ -591,6 +618,21 @@ public class AnalyzerFactoryTestCase { assertEquals("glasses", terms.get(5)); } + @Test + public void testGenre() { + + /* + * Confirming no conversion to singular. + */ + + String query = "{}"; + List terms = toQueryTermString(FieldNames.GENRE, query); + assertEquals(1, terms.size()); + assertEquals("{ }", terms.get(0)); + } + + + private List toTermString(String str) { return toTermString(null, str); } @@ -602,7 +644,7 @@ public class AnalyzerFactoryTestCase { new StringReader(str)); stream.reset(); while (stream.incrementToken()) { - result.add(stream.getAttribute(TermAttribute.class).toString() + result.add(stream.getAttribute(CharTermAttribute.class).toString() .replaceAll("^term\\=", "")); } stream.close(); @@ -627,7 +669,7 @@ public class AnalyzerFactoryTestCase { new StringReader(str)); stream.reset(); while (stream.incrementToken()) { - result.add(stream.getAttribute(TermAttribute.class).toString() + result.add(stream.getAttribute(CharTermAttribute.class).toString() .replaceAll("^term\\=", "")); } stream.close(); diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java index 9d282708..5010b43c 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java @@ -8,9 +8,7 @@ import org.airsonic.player.domain.RandomSearchCriteria; import org.airsonic.player.domain.SearchCriteria; import org.airsonic.player.util.HomeRule; import org.apache.commons.lang.builder.ToStringBuilder; -import org.apache.lucene.queryParser.ParseException; import org.apache.lucene.search.Query; -import org.apache.lucene.util.NumericUtils; import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; @@ -77,8 +75,34 @@ public class QueryFactoryTestCase { private static final List SINGLE_FOLDERS = Arrays.asList(MUSIC_FOLDER1); private static final List MULTI_FOLDERS = Arrays.asList(MUSIC_FOLDER1, MUSIC_FOLDER2); + + /* + * XXX 3.x -> 8.x : + * It does not change the basic functional requirements for the query. + * However, some minor improvements are included. + * + * - Use 'Or' instead of 'SpanOr'. + * This is suitable for 8.x document definition and query grammar. + * A more rigorous comparison. + * + * - Removed comparison of input value and path from condition of search. + * It causes a false search that the user can not imagine. + * Originally unnecessary. + * + * - mediaType and genre changed to raw string key comparison. + * Currently, these are "key" strings, both in the requirements and in the implementation. + * The legacy "normalize" is dirty code that compensates for the incomplete analytics implementation + * and is not necessary as long as proper key comparison can be done. + * + * => Treating these strictly as keys enables DB reference. + * For example, can support multi-genre by creating a new genre field that implements another Tokenizer. + * + * - The method for comparing ranges of numbers has changed. + * This is suitable for 8.x. + */ + @Test - public void testSearchArtist() throws ParseException, IOException { + public void testSearchArtist() throws IOException { SearchCriteria criteria = new SearchCriteria(); criteria.setOffset(10); criteria.setCount(Integer.MAX_VALUE); @@ -86,16 +110,16 @@ public class QueryFactoryTestCase { Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ARTIST); assertEquals("SearchArtist", - "+((artist:abc* folder:abc*) (artist:def* folder:def*)) +spanOr([folder:" + PATH1 + "])", + "+((artist:abc*) (artist:def*)) +(folder:" + PATH1 + ")", query.toString()); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ARTIST); - assertEquals("SearchArtist", "+((artist:abc* folder:abc*) (artist:def* folder:def*)) +spanOr([folder:" + PATH1 - + ", folder:" + PATH2 + "])", query.toString()); + assertEquals("SearchArtist", "+((artist:abc*) (artist:def*)) +(folder:" + PATH1 + + " folder:" + PATH2 + ")", query.toString()); } @Test - public void testSearchAlbum() throws ParseException, IOException { + public void testSearchAlbum() throws IOException { SearchCriteria criteria = new SearchCriteria(); criteria.setOffset(10); criteria.setCount(Integer.MAX_VALUE); @@ -103,19 +127,19 @@ public class QueryFactoryTestCase { Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ALBUM); assertEquals("SearchAlbum", - "+((album:abc* artist:abc* folder:abc*) (album:def* artist:def* folder:def*)) +spanOr([folder:" + PATH1 - + "])", + "+((album:abc* artist:abc*) (album:def* artist:def*)) +(folder:" + PATH1 + + ")", query.toString()); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ALBUM); assertEquals("SearchAlbum", - "+((album:abc* artist:abc* folder:abc*) (album:def* artist:def* folder:def*)) +spanOr([folder:" + PATH1 - + ", folder:" + PATH2 + "])", + "+((album:abc* artist:abc*) (album:def* artist:def*)) +(folder:" + PATH1 + + " folder:" + PATH2 + ")", query.toString()); } @Test - public void testSearchSong() throws ParseException, IOException { + public void testSearchSong() throws IOException { SearchCriteria criteria = new SearchCriteria(); criteria.setOffset(10); criteria.setCount(Integer.MAX_VALUE); @@ -123,34 +147,34 @@ public class QueryFactoryTestCase { Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.SONG); assertEquals("SearchSong", - "+((title:abc* artist:abc*) (title:def* artist:def*)) +spanOr([folder:" + PATH1 + "])", + "+((title:abc* artist:abc*) (title:def* artist:def*)) +(folder:" + PATH1 + ")", query.toString()); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.SONG); - assertEquals("SearchSong", "+((title:abc* artist:abc*) (title:def* artist:def*)) +spanOr([folder:" + PATH1 - + ", folder:" + PATH2 + "])", query.toString()); + assertEquals("SearchSong", "+((title:abc* artist:abc*) (title:def* artist:def*)) +(folder:" + PATH1 + + " folder:" + PATH2 + ")", query.toString()); } @Test - public void testSearchArtistId3() throws ParseException, IOException { + public void testSearchArtistId3() throws IOException { SearchCriteria criteria = new SearchCriteria(); criteria.setOffset(10); criteria.setCount(Integer.MAX_VALUE); criteria.setQuery(QUERY_ENG_ONLY); Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ARTIST_ID3); - assertEquals("SearchSong", "+((artist:abc*) (artist:def*)) +spanOr([folderId:" - + NumericUtils.intToPrefixCoded(FID1) + "])", query.toString()); + assertEquals("SearchSong", "+((artist:abc*) (artist:def*)) +(folderId:" + + FID1 + ")", query.toString()); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ARTIST_ID3); assertEquals("SearchSong", - "+((artist:abc*) (artist:def*)) +spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) - + ", folderId:" + NumericUtils.intToPrefixCoded(FID2) + "])", + "+((artist:abc*) (artist:def*)) +(folderId:" + FID1 + + " folderId:" + FID2 + ")", query.toString()); } @Test - public void testSearchAlbumId3() throws ParseException, IOException { + public void testSearchAlbumId3() throws IOException { SearchCriteria criteria = new SearchCriteria(); criteria.setOffset(10); criteria.setCount(Integer.MAX_VALUE); @@ -158,74 +182,74 @@ public class QueryFactoryTestCase { Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ALBUM_ID3); assertEquals( - "SearchAlbumId3", "+((album:abc* artist:abc* folderId:abc*) (album:def* artist:def* folderId:def*)) " - + "+spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + "])", + "SearchAlbumId3", "+((album:abc* artist:abc*) (album:def* artist:def*)) " + + "+(folderId:" + FID1 + ")", query.toString()); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ALBUM_ID3); assertEquals("SearchAlbumId3", - "+((album:abc* artist:abc* folderId:abc*) (album:def* artist:def* folderId:def*)) +spanOr([folderId:" - + NumericUtils.intToPrefixCoded(FID1) + ", folderId:" - + NumericUtils.intToPrefixCoded(FID2) + "])", + "+((album:abc* artist:abc*) (album:def* artist:def*)) +(folderId:" + + FID1 + " folderId:" + + FID2 + ")", query.toString()); } @Test - public void testSearchByNameArtist() throws ParseException { + public void testSearchByNameArtist() throws IOException { Query query = queryFactory.searchByName(FieldNames.ARTIST, QUERY_ENG_ONLY); assertEquals("SearchByNameArtist", "artist:abc artist:def*", query.toString()); } @Test - public void testSearchByNameAlbum() throws ParseException { + public void testSearchByNameAlbum() throws IOException { Query query = queryFactory.searchByName(FieldNames.ALBUM, QUERY_ENG_ONLY); assertEquals("SearchByNameAlbum", "album:abc album:def*", query.toString()); } @Test - public void testSearchByNameTitle() throws ParseException { + public void testSearchByNameTitle() throws IOException { Query query = queryFactory.searchByName(FieldNames.TITLE, QUERY_ENG_ONLY); assertEquals("SearchByNameTitle", "title:abc title:def*", query.toString()); } @Test - public void testGetRandomSongs() { + public void testGetRandomSongs() throws IOException { RandomSearchCriteria criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900), Integer.valueOf(2000), SINGLE_FOLDERS); Query query = queryFactory.getRandomSongs(criteria); assertEquals(ToStringBuilder.reflectionToString(criteria), - "+mediaType:music +genre:classicrock +year:[1900 TO 2000] +spanOr([folder:" + PATH1 + "])", + "+mediaType:MUSIC +genre:Classic Rock +year:[1900 TO 2000] +(folder:" + PATH1 + ")", query.toString()); criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900), Integer.valueOf(2000), MULTI_FOLDERS); query = queryFactory.getRandomSongs(criteria); assertEquals(ToStringBuilder.reflectionToString(criteria), - "+mediaType:music +genre:classicrock +year:[1900 TO 2000] +spanOr([folder:" + PATH1 + ", folder:" + PATH2 - + "])", + "+mediaType:MUSIC +genre:Classic Rock +year:[1900 TO 2000] +(folder:" + PATH1 + " folder:" + PATH2 + + ")", query.toString()); criteria = new RandomSearchCriteria(50, "Classic Rock", null, null, MULTI_FOLDERS); query = queryFactory.getRandomSongs(criteria); assertEquals(ToStringBuilder.reflectionToString(criteria), - "+mediaType:music +genre:classicrock +spanOr([folder:" + PATH1 + ", folder:" + PATH2 + "])", + "+mediaType:MUSIC +genre:Classic Rock +(folder:" + PATH1 + " folder:" + PATH2 + ")", query.toString()); criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900), null, MULTI_FOLDERS); query = queryFactory.getRandomSongs(criteria); assertEquals(ToStringBuilder.reflectionToString(criteria), - "+mediaType:music +genre:classicrock +year:[1900 TO *] +spanOr([folder:" + PATH1 + ", folder:" + PATH2 - + "])", + "+mediaType:MUSIC +genre:Classic Rock +year:[1900 TO 2147483647] +(folder:" + PATH1 + " folder:" + PATH2 + + ")", query.toString()); criteria = new RandomSearchCriteria(50, "Classic Rock", null, Integer.valueOf(2000), MULTI_FOLDERS); query = queryFactory.getRandomSongs(criteria); assertEquals(ToStringBuilder.reflectionToString(criteria), - "+mediaType:music +genre:classicrock +year:[* TO 2000] +spanOr([folder:" + PATH1 + ", folder:" + PATH2 - + "])", + "+mediaType:MUSIC +genre:Classic Rock +year:[-2147483648 TO 2000] +(folder:" + PATH1 + " folder:" + PATH2 + + ")", query.toString()); } @@ -233,23 +257,23 @@ public class QueryFactoryTestCase { public void testGetRandomAlbums() { Query query = queryFactory.getRandomAlbums(SINGLE_FOLDERS); assertEquals(ToStringBuilder.reflectionToString(SINGLE_FOLDERS), - "spanOr([folder:" + PATH1 + "])", query.toString()); + "(folder:" + PATH1 + ")", query.toString()); query = queryFactory.getRandomAlbums(MULTI_FOLDERS); assertEquals(ToStringBuilder.reflectionToString(MULTI_FOLDERS), - "spanOr([folder:" + PATH1 + ", folder:" + PATH2 + "])", query.toString()); + "(folder:" + PATH1 + " folder:" + PATH2 + ")", query.toString()); } @Test public void testGetRandomAlbumsId3() { Query query = queryFactory.getRandomAlbumsId3(SINGLE_FOLDERS); assertEquals(ToStringBuilder.reflectionToString(SINGLE_FOLDERS), - "spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + "])", query.toString()); + "(folderId:" + FID1 + ")", query.toString()); query = queryFactory.getRandomAlbumsId3(MULTI_FOLDERS); assertEquals(ToStringBuilder.reflectionToString(MULTI_FOLDERS), - "spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + ", folderId:" - + NumericUtils.intToPrefixCoded(FID2) + "])", + "(folderId:" + FID1 + " folderId:" + + FID2 + ")", query.toString()); } diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceSpecialGenreTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceSpecialGenreTestCase.java index e4e09b6c..c8c9f10a 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceSpecialGenreTestCase.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceSpecialGenreTestCase.java @@ -52,103 +52,182 @@ public class SearchServiceSpecialGenreTestCase extends AbstractAirsonicHomeTest * * Legacy can not search all these genres. * (Strictly speaking, the genre field is not created at index creation.) + * + * // XXX 3.x -> 8.x : Do the process more strictly. + * + * - Values ​​that can be cross-referenced with DB are stored in the index. + * - Search is also possible with user's readable value (file tag value). + * - However, there is an exception in parentheses. */ @Test public void testQueryEscapeRequires() { - List folders = getMusicFolders(); - Function simpleStringCriteria = s -> - new RandomSearchCriteria(Integer.MAX_VALUE, // count - s, // genre, - null, // fromYear - null, // toYear - folders // musicFolders - ); + new RandomSearchCriteria(Integer.MAX_VALUE, // count + s, // genre, + null, // fromYear + null, // toYear + getMusicFolders() // musicFolders + ); List songs = searchService.getRandomSongs(simpleStringCriteria.apply("+")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("+", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 1", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("-")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("-", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 2", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("&&")); - Assert.assertEquals(0, songs.size()); - - songs = searchService.getRandomSongs(simpleStringCriteria.apply("+")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("&&", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 3", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("||")); - Assert.assertEquals(0, songs.size()); - + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("||", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 4", songs.get(0).getTitle()); + + /* + * // XXX 3.x -> 8.x : Brackets () + * + * Lucene can handle these. + * However, brackets are specially parsed before the index creation process. + * + * This string is never stored in the index. + * This is the only exception. + */ songs = searchService.getRandomSongs(simpleStringCriteria.apply(" ("));// space & bracket Assert.assertEquals(0, songs.size()); songs = searchService.getRandomSongs(simpleStringCriteria.apply(")")); Assert.assertEquals(0, songs.size()); + /* + * // XXX 3.x -> 8.x : Brackets {}[] + * + * Lucene can handle these. + * However, brackets are specially parsed before the index creation process. + * + * This can be done with a filter that performs the reverse process + * on the input values ​​when searching. + * As a result, the values ​​stored in the file can be retrieved by search. + * + * @see AnalyzerFactory + * + * >>>>> + */ songs = searchService.getRandomSongs(simpleStringCriteria.apply("{}")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + /* + * This is the result of the tag parser and domain value. + * It is different from the tag value in file. + */ + Assert.assertEquals("{ }", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 7", songs.get(0).getTitle()); + songs = searchService.getRandomSongs(simpleStringCriteria.apply("{ }")); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("Query Escape Requires 7", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("[]")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + /* + * This is the result of the tag parser and domain value. + * It is different from the tag value in file. + */ + Assert.assertEquals("[ ]", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 8", songs.get(0).getTitle()); + songs = searchService.getRandomSongs(simpleStringCriteria.apply("[ ]")); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("Query Escape Requires 8", songs.get(0).getTitle()); + // <<<<< songs = searchService.getRandomSongs(simpleStringCriteria.apply("^")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("^", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 9", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("\"")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("\"", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 10", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("~")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("~", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 11", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("*")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("*", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 12", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("?")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("?", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 13", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply(":")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals(":", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 14", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("\\")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("\\", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 15", songs.get(0).getTitle()); songs = searchService.getRandomSongs(simpleStringCriteria.apply("/")); - Assert.assertEquals(0, songs.size()); + Assert.assertEquals(1, songs.size());// XXX 3.x -> 8.x : Searchable + Assert.assertEquals("/", songs.get(0).getGenre()); + Assert.assertEquals("Query Escape Requires 16", songs.get(0).getTitle()); } /* * Jaudiotagger applies special treatment to bracket (FILE17). - * + * XXX 3.x -> 8.x : Specification of genre search became more natural. */ @Test public void testBrackets() { - List folders = getMusicFolders(); - - RandomSearchCriteria criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count - "-(GENRE)-", // genre, + Function simpleStringCriteria = s -> + new RandomSearchCriteria(Integer.MAX_VALUE, // count + s, // genre, null, // fromYear null, // toYear - folders // musicFolders + getMusicFolders() // musicFolders ); - List songs = searchService.getRandomSongs(criteria); - Assert.assertEquals(0, songs.size()); - - criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count - " genre", // genre, - null, // fromYear - null, // toYear - folders // musicFolders - ); + // -(GENRE)- is registered as genre of FILE17. - songs = searchService.getRandomSongs(criteria); + /* + * Search by genre string registered in file. + * + * The value stored in the index is different from legacy. + * Domain value is kept as it is. + */ + List songs = searchService.getRandomSongs(simpleStringCriteria.apply("-(GENRE)-")); Assert.assertEquals(1, songs.size()); + Assert.assertEquals("-GENRE -", songs.get(0).getGenre()); Assert.assertEquals("Consistency with Tag Parser 1", songs.get(0).getTitle()); + + /* + * Search by Domain value. + */ + songs = searchService.getRandomSongs(simpleStringCriteria.apply("-GENRE -")); + Assert.assertEquals(1, songs.size()); Assert.assertEquals("-GENRE -", songs.get(0).getGenre()); + Assert.assertEquals("Consistency with Tag Parser 1", songs.get(0).getTitle()); + + /* + * Legacy genre search + */ + songs = searchService.getRandomSongs(simpleStringCriteria.apply(" genre")); + // Strong unique parsing rules have been removed. + Assert.assertEquals(0, songs.size()); } @@ -188,29 +267,28 @@ public class SearchServiceSpecialGenreTestCase extends AbstractAirsonicHomeTest @Test public void testOthers() { - List folders = getMusicFolders(); - - RandomSearchCriteria criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count - "{'“『【【】】[︴○◎@ $〒→+]FULL-WIDTHCæsar's", // genre, - null, // fromYear - null, // toYear - folders // musicFolders - ); - - List songs = searchService.getRandomSongs(criteria); - Assert.assertEquals(0, songs.size()); - - criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count - "widthcaesar", // genre, + Function simpleStringCriteria = s -> + new RandomSearchCriteria(Integer.MAX_VALUE, // count + s, // genre, null, // fromYear null, // toYear - folders // musicFolders + getMusicFolders() // musicFolders ); - songs = searchService.getRandomSongs(criteria); + // XXX 3.x -> 8.x : Do the process more strictly. + List songs = searchService + .getRandomSongs(simpleStringCriteria.apply("{'“『【【】】[︴○◎@ $〒→+]FULL-WIDTHCæsar's")); + Assert.assertEquals(1, songs.size()); Assert.assertEquals(1, songs.size()); Assert.assertEquals("Other special strings 1", songs.get(0).getTitle()); Assert.assertEquals("{'“『【【】】[︴○◎@ $〒→+]FULL-WIDTHCæsar's", songs.get(0).getGenre()); + /* + * Legacy kept "widthcaesar" using their own rules. + * The previous rule has been discarded. + */ + songs = searchService.getRandomSongs(simpleStringCriteria.apply("widthcaesar")); + Assert.assertEquals(0, songs.size()); + } } diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceStartWithStopwardsTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceStartWithStopwardsTestCase.java index 58854333..b7e750fb 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceStartWithStopwardsTestCase.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceStartWithStopwardsTestCase.java @@ -56,11 +56,23 @@ public class SearchServiceStartWithStopwardsTestCase extends AbstractAirsonicHom criteria.setQuery("will"); SearchResult result = searchService.search(criteria, folders, IndexType.ARTIST_ID3); - Assert.assertEquals("Williams hit by \"will\" ", 1, result.getTotalHits()); + // XXX 3.x -> 8.x : The filter is properly applied to the input(Stopward) + Assert.assertEquals("Williams hit by \"will\" ", 0, result.getTotalHits()); criteria.setQuery("the"); result = searchService.search(criteria, folders, IndexType.SONG); - Assert.assertEquals("Theater hit by \"the\" ", 1, result.getTotalHits()); + // XXX 3.x -> 8.x : The filter is properly applied to the input(Stopward) + Assert.assertEquals("Theater hit by \"the\" ", 0, result.getTotalHits()); + + criteria.setQuery("willi"); + result = searchService.search(criteria, folders, IndexType.ARTIST_ID3); + // XXX 3.x -> 8.x : Normal forward matching + Assert.assertEquals("Williams hit by \"Williams\" ", 1, result.getTotalHits()); + + criteria.setQuery("thea"); + result = searchService.search(criteria, folders, IndexType.SONG); + // XXX 3.x -> 8.x : Normal forward matching + Assert.assertEquals("Theater hit by \"thea\" ", 1, result.getTotalHits()); } diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java index 9d2691b9..bede64ab 100644 --- a/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java +++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java @@ -164,7 +164,12 @@ public class SearchServiceTestCase extends AbstractAirsonicHomeTest { artistId3Result.getItems().size()); ParamSearchResult artistResult = searchService.searchByName(query, 0, Integer.MAX_VALUE, allMusicFolders, Artist.class); - Assert.assertEquals("(21) Specify '" + query + "' as the name, and get an artist.", 0, + + /* + * // XXX 3.x -> 8.x : + * Hit 'Nash*' as ​​the slash becomes a delimiter. + */ + Assert.assertEquals("(21) Specify '" + query + "' as the name, and get an artist.", 1, artistResult.getItems().size()); // *** testGetRandomSongs() ***