Update Lucene from 3.0.3 to the current version 8.2.0

Signed-off-by: Andrew DeMaria <lostonamountain@gmail.com>
master
tesshucom 5 years ago committed by Andrew DeMaria
parent 8e279a2d2a
commit 5c3c558923
No known key found for this signature in database
GPG Key ID: 0A3F5E91F8364EDF
  1. 14
      airsonic-main/pom.xml
  2. 52
      airsonic-main/src/main/java/org/airsonic/player/service/MediaScannerService.java
  3. 13
      airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java
  4. 299
      airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java
  5. 416
      airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java
  6. 190
      airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java
  7. 492
      airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java
  8. 279
      airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java
  9. 522
      airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java
  10. 161
      airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java
  11. 376
      airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java
  12. 274
      airsonic-main/src/test/java/org/airsonic/player/service/search/AbstractAirsonicHomeTest.java
  13. 90
      airsonic-main/src/test/java/org/airsonic/player/service/search/AirsonicHomeTest.java
  14. 104
      airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java
  15. 110
      airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java
  16. 194
      airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceSpecialGenreTestCase.java
  17. 16
      airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceStartWithStopwardsTestCase.java
  18. 7
      airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java

@ -131,9 +131,13 @@
<dependency> <dependency>
<groupId>org.apache.lucene</groupId> <groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId> <artifactId>lucene-core</artifactId>
<version>3.0.3</version> <version>8.2.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>8.2.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.ant</groupId> <groupId>org.apache.ant</groupId>
<artifactId>ant</artifactId> <artifactId>ant</artifactId>
@ -197,6 +201,12 @@
<groupId>com.google.guava</groupId> <groupId>com.google.guava</groupId>
<artifactId>guava</artifactId> <artifactId>guava</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-qual</artifactId>
<scope>compile</scope>
<version>2.5.2</version>
</dependency>
<dependency> <dependency>
<groupId>org.apache.httpcomponents</groupId> <groupId>org.apache.httpcomponents</groupId>

@ -23,7 +23,7 @@ import org.airsonic.player.dao.AlbumDao;
import org.airsonic.player.dao.ArtistDao; import org.airsonic.player.dao.ArtistDao;
import org.airsonic.player.dao.MediaFileDao; import org.airsonic.player.dao.MediaFileDao;
import org.airsonic.player.domain.*; 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.lang.ObjectUtils;
import org.apache.commons.lang3.time.DateUtils; import org.apache.commons.lang3.time.DateUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -44,7 +44,6 @@ import java.util.*;
@Service @Service
public class MediaScannerService { public class MediaScannerService {
private static final int INDEX_VERSION = 15;
private static final Logger LOG = LoggerFactory.getLogger(MediaScannerService.class); private static final Logger LOG = LoggerFactory.getLogger(MediaScannerService.class);
private MediaLibraryStatistics statistics; private MediaLibraryStatistics statistics;
@ -54,7 +53,7 @@ public class MediaScannerService {
@Autowired @Autowired
private SettingsService settingsService; private SettingsService settingsService;
@Autowired @Autowired
private SearchService searchService; private IndexManager indexManager;
@Autowired @Autowired
private PlaylistService playlistService; private PlaylistService playlistService;
@Autowired @Autowired
@ -69,13 +68,13 @@ public class MediaScannerService {
@PostConstruct @PostConstruct
public void init() { public void init() {
deleteOldIndexFiles(); indexManager.initializeIndexDirectory();
statistics = settingsService.getMediaLibraryStatistics(); statistics = settingsService.getMediaLibraryStatistics();
schedule(); schedule();
} }
public void initNoSchedule() { public void initNoSchedule() {
deleteOldIndexFiles(); indexManager.deleteOldIndexFiles();
statistics = settingsService.getMediaLibraryStatistics(); statistics = settingsService.getMediaLibraryStatistics();
} }
@ -179,7 +178,7 @@ public class MediaScannerService {
statistics.reset(); statistics.reset();
mediaFileService.setMemoryCacheEnabled(false); mediaFileService.setMemoryCacheEnabled(false);
searchService.startIndexing(); indexManager.startIndexing();
mediaFileService.clearMemoryCache(); mediaFileService.clearMemoryCache();
@ -223,7 +222,7 @@ public class MediaScannerService {
LOG.error("Failed to scan media library.", x); LOG.error("Failed to scan media library.", x);
} finally { } finally {
mediaFileService.setMemoryCacheEnabled(true); mediaFileService.setMemoryCacheEnabled(true);
searchService.stopIndexing(); indexManager.stopIndexing();
scanning = false; scanning = false;
} }
} }
@ -243,7 +242,7 @@ public class MediaScannerService {
mediaFileDao.createOrUpdateMediaFile(file); mediaFileDao.createOrUpdateMediaFile(file);
} }
searchService.index(file); indexManager.index(file);
if (file.isDirectory()) { if (file.isDirectory()) {
for (MediaFile child : mediaFileService.getChildrenOf(file, true, false, false, false)) { for (MediaFile child : mediaFileService.getChildrenOf(file, true, false, false, false)) {
@ -331,7 +330,7 @@ public class MediaScannerService {
album.setPresent(true); album.setPresent(true);
albumDao.createOrUpdateAlbum(album); albumDao.createOrUpdateAlbum(album);
if (firstEncounter) { if (firstEncounter) {
searchService.index(album); indexManager.index(album);
} }
// Update the file's album artist, if necessary. // Update the file's album artist, if necessary.
@ -370,7 +369,7 @@ public class MediaScannerService {
artistDao.createOrUpdateArtist(artist); artistDao.createOrUpdateArtist(artist);
if (firstEncounter) { if (firstEncounter) {
searchService.index(artist, musicFolder); indexManager.index(artist, musicFolder);
} }
} }
@ -383,43 +382,10 @@ public class MediaScannerService {
return statistics; 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) { public void setSettingsService(SettingsService settingsService) {
this.settingsService = settingsService; this.settingsService = settingsService;
} }
public void setSearchService(SearchService searchService) {
this.searchService = searchService;
}
public void setMediaFileService(MediaFileService mediaFileService) { public void setMediaFileService(MediaFileService mediaFileService) {
this.mediaFileService = mediaFileService; this.mediaFileService = mediaFileService;
} }

@ -21,7 +21,6 @@
package org.airsonic.player.service; package org.airsonic.player.service;
import org.airsonic.player.domain.Album; import org.airsonic.player.domain.Album;
import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile; import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.MusicFolder; import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.domain.ParamSearchResult; import org.airsonic.player.domain.ParamSearchResult;
@ -33,7 +32,7 @@ import org.airsonic.player.service.search.IndexType;
import java.util.List; import java.util.List;
/** /**
* Performs Lucene-based searching and indexing. * Performs Lucene-based searching.
* *
* @author Sindre Mehus * @author Sindre Mehus
* @version $Id$ * @version $Id$
@ -41,16 +40,6 @@ import java.util.List;
*/ */
public interface SearchService { 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<MusicFolder> musicFolders, SearchResult search(SearchCriteria criteria, List<MusicFolder> musicFolders,
IndexType indexType); IndexType indexType);

@ -1,121 +1,178 @@
/* /*
This file is part of Airsonic. This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
Airsonic is distributed in the hope that it will be useful, Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>. along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/ */
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
import org.apache.lucene.analysis.ASCIIFoldingFilter; import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.analysis.cjk.CJKWidthFilterFactory;
import org.apache.lucene.analysis.LowerCaseFilter; import org.apache.lucene.analysis.core.KeywordTokenizerFactory;
import org.apache.lucene.analysis.StopFilter; import org.apache.lucene.analysis.core.LowerCaseFilterFactory;
import org.apache.lucene.analysis.TokenStream; import org.apache.lucene.analysis.core.StopFilterFactory;
import org.apache.lucene.analysis.standard.StandardAnalyzer; import org.apache.lucene.analysis.custom.CustomAnalyzer;
import org.apache.lucene.analysis.standard.StandardFilter; import org.apache.lucene.analysis.custom.CustomAnalyzer.Builder;
import org.apache.lucene.analysis.standard.StandardTokenizer; import org.apache.lucene.analysis.en.EnglishPossessiveFilterFactory;
import org.apache.lucene.util.Version; import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilterFactory;
import org.springframework.stereotype.Component; import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
import org.apache.lucene.analysis.pattern.PatternReplaceFilterFactory;
import java.io.IOException; import org.apache.lucene.analysis.standard.StandardTokenizerFactory;
import java.io.Reader; import org.springframework.stereotype.Component;
/** import java.io.IOException;
* Analyzer provider. import java.util.HashMap;
* This class is a division of what was once part of SearchService and added functionality. import java.util.Map;
* This class provides Analyzer which is used at index generation
* and QueryAnalyzer which analyzes the specified query at search time. import static org.springframework.util.ObjectUtils.isEmpty;
* Analyzer can be closed but is a reuse premise.
* It is held in this class. /**
*/ * Analyzer provider.
@Component * This class is a division of what was once part of SearchService and added functionality.
public final class AnalyzerFactory { * This class provides Analyzer which is used at index generation
* and QueryAnalyzer which analyzes the specified query at search time.
private Analyzer analyzer; * Analyzer can be closed but is a reuse premise.
* It is held in this class.
private Analyzer queryAnalyzer; */
@Component
/** public final class AnalyzerFactory {
* Return analyzer.
* private Analyzer analyzer;
* @return analyzer for index
*/ private Analyzer queryAnalyzer;
public Analyzer getAnalyzer() {
if (null == analyzer) { /*
analyzer = new CustomAnalyzer(); * XXX 3.x -> 8.x : Convert UAX#29 Underscore Analysis to Legacy Analysis
} *
return analyzer; * Because changes in underscores before and after words
} * have a major effect on user's forward match search.
*
/** * @see AnalyzerFactoryTestCase
* Return analyzer. */
* private void addTokenFilterForUnderscoreRemovalAroundToken(Builder builder) throws IOException {
* @return analyzer for index builder
*/ .addTokenFilter(PatternReplaceFilterFactory.class,
public Analyzer getQueryAnalyzer() { "pattern", "^\\_", "replacement", "", "replace", "all")
if (null == queryAnalyzer) { .addTokenFilter(PatternReplaceFilterFactory.class,
queryAnalyzer = new CustomAnalyzer(); "pattern", "\\_$", "replacement", "", "replace", "all");
} }
return queryAnalyzer;
} /*
* XXX 3.x -> 8.x : Handle brackets correctly
/* *
* The legacy CustomAnalyzer implementation is kept as it is. * Process the input value of Genre search for search of domain value.
*/ *
private class CustomAnalyzer extends StandardAnalyzer { * The tag parser performs special character conversion
private CustomAnalyzer() { * when converting input values from a file.
/* * Therefore, the domain value may be different from the original value.
* Version.LUCENE_30 * This filter allows searching by user readable value (file tag value).
* It is a transient description and will be deleted when upgrading the version. *
* SearchService variables are not used because the reference direction conflicts. * @see org.jaudiotagger.tag.id3.framebody.FrameBodyTCON#convertID3v23GenreToGeneric
*/ * (TCON stands for Genre with ID3 v2.3-v2.4)
super(Version.LUCENE_30); * Such processing exists because brackets in the Gener string have a special meaning.
} */
private void addTokenFilterForTokenToDomainValue(Builder builder) throws IOException {
@Override builder
public TokenStream tokenStream(String fieldName, Reader reader) { .addTokenFilter(PatternReplaceFilterFactory.class,
TokenStream result = super.tokenStream(fieldName, reader); "pattern", "\\(", "replacement", "", "replace", "all")
return new ASCIIFoldingFilter(result); .addTokenFilter(PatternReplaceFilterFactory.class,
} "pattern", "\\)$", "replacement", "", "replace", "all")
.addTokenFilter(PatternReplaceFilterFactory.class,
@Override "pattern", "\\)", "replacement", " ", "replace", "all")
public TokenStream reusableTokenStream(String fieldName, Reader reader) throws IOException { .addTokenFilter(PatternReplaceFilterFactory.class,
class SavedStreams { "pattern", "\\{\\}", "replacement", "\\{ \\}", "replace", "all")
StandardTokenizer tokenStream; .addTokenFilter(PatternReplaceFilterFactory.class,
TokenStream filteredTokenStream; "pattern", "\\[\\]", "replacement", "\\[ \\]", "replace", "all");
} }
SavedStreams streams = (SavedStreams) getPreviousTokenStream(); private Builder createDefaultAnalyzerBuilder() throws IOException {
if (streams == null) { Builder builder = CustomAnalyzer.builder()
streams = new SavedStreams(); .withTokenizer(StandardTokenizerFactory.class)
setPreviousTokenStream(streams); .addTokenFilter(CJKWidthFilterFactory.class)
streams.tokenStream = new StandardTokenizer(Version.LUCENE_30, reader); .addTokenFilter(ASCIIFoldingFilterFactory.class, "preserveOriginal", "false")
streams.filteredTokenStream = new StandardFilter(streams.tokenStream); .addTokenFilter(LowerCaseFilterFactory.class)
streams.filteredTokenStream = new LowerCaseFilter(streams.filteredTokenStream); .addTokenFilter(StopFilterFactory.class)
streams.filteredTokenStream = new StopFilter(true, streams.filteredTokenStream, .addTokenFilter(EnglishPossessiveFilterFactory.class);
STOP_WORDS_SET); addTokenFilterForUnderscoreRemovalAroundToken(builder);
streams.filteredTokenStream = new ASCIIFoldingFilter(streams.filteredTokenStream); return builder;
} else { }
streams.tokenStream.reset(reader);
} private Builder createKeywordAnalyzerBuilder() throws IOException {
streams.tokenStream.setMaxTokenLength(DEFAULT_MAX_TOKEN_LENGTH); return CustomAnalyzer.builder()
.withTokenizer(KeywordTokenizerFactory.class);
return streams.filteredTokenStream; }
}
} 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<String, Analyzer> 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;
}
}

@ -1,181 +1,235 @@
/* /*
This file is part of Airsonic. This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
Airsonic is distributed in the hope that it will be useful, Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>. along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/ */
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
import org.airsonic.player.domain.Album; import org.airsonic.player.domain.Album;
import org.airsonic.player.domain.Artist; import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile; import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.MusicFolder; import org.airsonic.player.domain.MusicFolder;
import org.apache.lucene.document.Document; import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field; import org.apache.lucene.document.Field.Store;
import org.apache.lucene.document.NumericField; import org.apache.lucene.document.FieldType;
import org.springframework.stereotype.Component; import org.apache.lucene.document.IntPoint;
import org.apache.lucene.document.SortedDocValuesField;
/** import org.apache.lucene.document.StoredField;
* A factory that generates the documents to be stored in the index. import org.apache.lucene.document.TextField;
*/ import org.apache.lucene.index.IndexOptions;
@Component import org.apache.lucene.index.Term;
public class DocumentFactory { import org.apache.lucene.util.BytesRef;
import org.checkerframework.checker.nullness.qual.NonNull;
/** import org.checkerframework.checker.nullness.qual.Nullable;
* Normalize the genre string. import org.springframework.stereotype.Component;
*
* @param genre genre string import java.util.function.BiConsumer;
* @return genre string normalized
* @deprecated should be resolved with tokenizer or filter import static org.springframework.util.ObjectUtils.isEmpty;
*/
@Deprecated /**
private String normalizeGenre(String genre) { * A factory that generates the documents to be stored in the index.
return genre.toLowerCase().replace(" ", "").replace("-", ""); */
} @Component
public class DocumentFactory {
/**
* Create a document. private static final FieldType TYPE_ID;
*
* @param mediaFile target of document private static final FieldType TYPE_ID_NO_STORE;
* @return document
* @since legacy private static final FieldType TYPE_KEY;
*/
public Document createAlbumDocument(MediaFile mediaFile) { static {
Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) TYPE_ID = new FieldType();
.setIntValue(mediaFile.getId())); TYPE_ID.setIndexOptions(IndexOptions.DOCS);
TYPE_ID.setTokenized(false);
if (mediaFile.getArtist() != null) { TYPE_ID.setOmitNorms(true);
doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES, TYPE_ID.setStored(true);
Field.Index.ANALYZED)); TYPE_ID.freeze();
}
if (mediaFile.getAlbumName() != null) { TYPE_ID_NO_STORE = new FieldType();
doc.add(new Field(FieldNames.ALBUM, mediaFile.getAlbumName(), Field.Store.YES, TYPE_ID_NO_STORE.setIndexOptions(IndexOptions.DOCS);
Field.Index.ANALYZED)); TYPE_ID_NO_STORE.setTokenized(false);
} TYPE_ID_NO_STORE.setOmitNorms(true);
if (mediaFile.getFolder() != null) { TYPE_ID_NO_STORE.setStored(false);
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO, TYPE_ID_NO_STORE.freeze();
Field.Index.NOT_ANALYZED_NO_NORMS));
} TYPE_KEY = new FieldType();
return doc; TYPE_KEY.setIndexOptions(IndexOptions.DOCS);
} TYPE_KEY.setTokenized(false);
TYPE_KEY.setOmitNorms(true);
/** TYPE_KEY.setStored(false);
* Create a document. TYPE_KEY.freeze();
*
* @param mediaFile target of document }
* @return document
* @since legacy @FunctionalInterface
*/ private interface Consumer<T, U, V> {
public Document createArtistDocument(MediaFile mediaFile) { void accept(T t, U u, V v);
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, private BiConsumer<@NonNull Document, @NonNull Integer> fieldId = (doc, value) -> {
Field.Index.ANALYZED)); doc.add(new StoredField(FieldNames.ID, Integer.toString(value), TYPE_ID));
} };
if (mediaFile.getFolder() != null) {
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO, private BiConsumer<@NonNull Document, @NonNull Integer> fieldFolderId = (doc, value) -> {
Field.Index.NOT_ANALYZED_NO_NORMS)); doc.add(new StoredField(FieldNames.FOLDER_ID, Integer.toString(value), TYPE_ID_NO_STORE));
} };
return doc;
} private Consumer<@NonNull Document, @NonNull String, @NonNull String> fieldKey = (doc, field, value) -> {
doc.add(new StoredField(field, value, TYPE_KEY));
/** };
* Create a document.
* private BiConsumer<@NonNull Document, @NonNull String> fieldMediatype = (doc, value) ->
* @param album target of document fieldKey.accept(doc, FieldNames.MEDIA_TYPE, value);
* @return document
* @since legacy private BiConsumer<@NonNull Document, @NonNull String> fieldFolderPath = (doc, value) ->
*/ fieldKey.accept(doc, FieldNames.FOLDER, value);
public Document createAlbumId3Document(Album album) {
Document doc = new Document(); private BiConsumer<@NonNull Document, @Nullable String> fieldGenre = (doc, value) -> {
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false).setIntValue(album.getId())); if (isEmpty(value)) {
return;
if (album.getArtist() != null) { }
doc.add(new Field(FieldNames.ARTIST, album.getArtist(), Field.Store.YES, fieldKey.accept(doc, FieldNames.GENRE, value);
Field.Index.ANALYZED)); };
}
if (album.getName() != null) { private Consumer<@NonNull Document, @NonNull String, @Nullable Integer> fieldYear = (doc, fieldName, value) -> {
doc.add(new Field(FieldNames.ALBUM, album.getName(), Field.Store.YES, if (isEmpty(value)) {
Field.Index.ANALYZED)); return;
} }
if (album.getFolderId() != null) { doc.add(new IntPoint(fieldName, value));
doc.add(new NumericField(FieldNames.FOLDER_ID, Field.Store.NO, true) };
.setIntValue(album.getFolderId()));
} private Consumer<@NonNull Document, @NonNull String, @Nullable String> fieldWords = (doc, fieldName, value) -> {
return doc; if (isEmpty(value)) {
} return;
}
/** doc.add(new TextField(fieldName, value, Store.NO));
* Create a document. doc.add(new SortedDocValuesField(fieldName, new BytesRef(value)));
* };
* @param artist target of document
* @param musicFolder target folder exists public final Term createPrimarykey(Album album) {
* @return document return new Term(FieldNames.ID, Integer.toString(album.getId()));
* @since legacy };
*/
public Document createArtistId3Document(Artist artist, MusicFolder musicFolder) { public final Term createPrimarykey(Artist artist) {
Document doc = new Document(); return new Term(FieldNames.ID, Integer.toString(artist.getId()));
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) };
.setIntValue(artist.getId()));
doc.add(new Field(FieldNames.ARTIST, artist.getName(), Field.Store.YES, public final Term createPrimarykey(MediaFile mediaFile) {
Field.Index.ANALYZED)); return new Term(FieldNames.ID, Integer.toString(mediaFile.getId()));
doc.add(new NumericField(FieldNames.FOLDER_ID, Field.Store.NO, true) };
.setIntValue(musicFolder.getId()));
return doc; /**
} * Create a document.
*
/** * @param mediaFile target of document
* Create a document. * @return document
* * @since legacy
* @param mediaFile target of document */
* @return document public Document createAlbumDocument(MediaFile mediaFile) {
* @since legacy Document doc = new Document();
*/ fieldId.accept(doc, mediaFile.getId());
public Document createSongDocument(MediaFile mediaFile) { fieldWords.accept(doc, FieldNames.ARTIST, mediaFile.getArtist());
Document doc = new Document(); fieldWords.accept(doc, FieldNames.ALBUM, mediaFile.getAlbumName());
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) fieldFolderPath.accept(doc, mediaFile.getFolder());
.setIntValue(mediaFile.getId())); return doc;
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, * Create a document.
Field.Index.ANALYZED)); *
} * @param mediaFile target of document
if (mediaFile.getArtist() != null) { * @return document
doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES, * @since legacy
Field.Index.ANALYZED)); */
} public Document createArtistDocument(MediaFile mediaFile) {
if (mediaFile.getGenre() != null) { Document doc = new Document();
doc.add(new Field(FieldNames.GENRE, normalizeGenre(mediaFile.getGenre()), fieldId.accept(doc, mediaFile.getId());
Field.Store.NO, Field.Index.ANALYZED)); fieldWords.accept(doc, FieldNames.ARTIST, mediaFile.getArtist());
} fieldFolderPath.accept(doc, mediaFile.getFolder());
if (mediaFile.getYear() != null) { return doc;
doc.add(new NumericField(FieldNames.YEAR, Field.Store.NO, true) }
.setIntValue(mediaFile.getYear()));
} /**
if (mediaFile.getFolder() != null) { * Create a document.
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO, *
Field.Index.NOT_ANALYZED_NO_NORMS)); * @param album target of document
} * @return document
return doc; * @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;
}
}

@ -1,95 +1,95 @@
/* /*
This file is part of Airsonic. This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
Airsonic is distributed in the hope that it will be useful, Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>. along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/ */
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
/** /**
* Enum that symbolizes the field name used for lucene index. * 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. * This class is a division of what was once part of SearchService and added functionality.
*/ */
class FieldNames { class FieldNames {
private FieldNames() { private FieldNames() {
} }
/** /**
* A field same to a legacy server, id field. * A field same to a legacy server, id field.
* *
* @since legacy * @since legacy
**/ **/
public static final String ID = "id"; public static final String ID = "id";
/** /**
* A field same to a legacy server, id field. * A field same to a legacy server, id field.
* *
* @since legacy * @since legacy
**/ **/
public static final String FOLDER_ID = "folderId"; public static final String FOLDER_ID = "folderId";
/** /**
* A field same to a legacy server, numeric field. * A field same to a legacy server, numeric field.
* *
* @since legacy * @since legacy
**/ **/
public static final String YEAR = "year"; public static final String YEAR = "year";
/** /**
* A field same to a legacy server, key field. * A field same to a legacy server, key field.
* *
* @since legacy * @since legacy
**/ **/
public static final String GENRE = "genre"; public static final String GENRE = "genre";
/** /**
* A field same to a legacy server, key field. * A field same to a legacy server, key field.
* *
* @since legacy * @since legacy
**/ **/
public static final String MEDIA_TYPE = "mediaType"; public static final String MEDIA_TYPE = "mediaType";
/** /**
* A field same to a legacy server, key field. * A field same to a legacy server, key field.
* *
* @since legacy * @since legacy
**/ **/
public static final String FOLDER = "folder"; public static final String FOLDER = "folder";
/** /**
* A field same to a legacy server, usually with common word parsing. * A field same to a legacy server, usually with common word parsing.
* *
* @since legacy * @since legacy
**/ **/
public static final String ARTIST = "artist"; public static final String ARTIST = "artist";
/** /**
* A field same to a legacy server, usually with common word parsing. * A field same to a legacy server, usually with common word parsing.
* *
* @since legacy * @since legacy
**/ **/
public static final String ALBUM = "album"; public static final String ALBUM = "album";
/** /**
* A field same to a legacy server, usually with common word parsing. * A field same to a legacy server, usually with common word parsing.
* *
* @since legacy * @since legacy
**/ **/
public static final String TITLE = "title"; public static final String TITLE = "title";
} }

@ -1,169 +1,323 @@
/* /*
This file is part of Airsonic. This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
Airsonic is distributed in the hope that it will be useful, Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>. along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/ */
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
import org.airsonic.player.domain.Album; import org.airsonic.player.domain.Album;
import org.airsonic.player.domain.Artist; import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile; import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.MusicFolder; import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.service.SettingsService; import org.airsonic.player.service.SettingsService;
import org.airsonic.player.util.FileUtil; import org.airsonic.player.util.FileUtil;
import org.apache.lucene.index.IndexReader; import org.apache.commons.io.FileUtils;
import org.apache.lucene.index.IndexWriter; import org.apache.lucene.document.Document;
import org.apache.lucene.store.FSDirectory; import org.apache.lucene.index.IndexWriter;
import org.slf4j.Logger; import org.apache.lucene.index.IndexWriterConfig;
import org.slf4j.LoggerFactory; import org.apache.lucene.index.Term;
import org.springframework.beans.factory.annotation.Autowired; import org.apache.lucene.search.IndexSearcher;
import org.springframework.stereotype.Component; import org.apache.lucene.search.SearcherManager;
import org.apache.lucene.store.FSDirectory;
import java.io.File; import org.checkerframework.checker.nullness.qual.Nullable;
import java.io.IOException; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.airsonic.player.service.search.IndexType.ALBUM; import org.springframework.beans.factory.annotation.Autowired;
import static org.airsonic.player.service.search.IndexType.ALBUM_ID3; import org.springframework.stereotype.Component;
import static org.airsonic.player.service.search.IndexType.ARTIST;
import static org.airsonic.player.service.search.IndexType.ARTIST_ID3; import java.io.File;
import static org.airsonic.player.service.search.IndexType.SONG; import java.io.IOException;
import java.util.Arrays;
/** import java.util.HashMap;
* Function class that is strongly linked to the lucene index implementation. import java.util.Map;
* Legacy has an implementation in SearchService. import java.util.function.Function;
* import java.util.function.Supplier;
* If the index CRUD and search functionality are in the same class, import java.util.regex.Pattern;
* there is often a dependency conflict on the class used.
* Although the interface of SearchService is left to maintain the legacy implementation, import static org.springframework.util.ObjectUtils.isEmpty;
* it is desirable that methods of index operations other than search essentially use this class directly.
*/ /**
@Component * Function class that is strongly linked to the lucene index implementation.
public class IndexManager { * Legacy has an implementation in SearchService.
*
private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class); * If the index CRUD and search functionality are in the same class,
* there is often a dependency conflict on the class used.
@Autowired * Although the interface of SearchService is left to maintain the legacy implementation,
private AnalyzerFactory analyzerFactory; * it is desirable that methods of index operations other than search essentially use this class directly.
*/
@Autowired @Component
private DocumentFactory documentFactory; public class IndexManager {
private IndexWriter artistWriter; private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class);
private IndexWriter artistId3Writer;
private IndexWriter albumWriter; /**
private IndexWriter albumId3Writer; * Schema version of Airsonic index.
private IndexWriter songWriter; * It may be incremented in the following cases:
*
public void index(Album album) { * - Incompatible update case in Lucene index implementation
try { * - When schema definition is changed due to modification of AnalyzerFactory,
albumId3Writer.addDocument(documentFactory.createAlbumId3Document(album)); * DocumentFactory or the class that they use.
} catch (Exception x) { *
LOG.error("Failed to create search index for " + album, x); */
} private static final int INDEX_VERSION = 16;
}
/**
public void index(Artist artist, MusicFolder musicFolder) { * Literal name of index top directory.
try { */
artistId3Writer private static final String INDEX_ROOT_DIR_NAME = "index";
.addDocument(documentFactory.createArtistId3Document(artist, musicFolder));
} catch (Exception x) { /**
LOG.error("Failed to create search index for " + artist, x); * File supplier for index directory.
} */
} private Supplier<File> rootIndexDirectory = () ->
new File(SettingsService.getAirsonicHome(), INDEX_ROOT_DIR_NAME.concat(Integer.toString(INDEX_VERSION)));
public void index(MediaFile mediaFile) {
try { /**
if (mediaFile.isFile()) { * Returns the directory of the specified index
songWriter.addDocument(documentFactory.createSongDocument(mediaFile)); */
} else if (mediaFile.isAlbum()) { private Function<IndexType, File> getIndexDirectory = (indexType) ->
albumWriter.addDocument(documentFactory.createAlbumDocument(mediaFile)); new File(rootIndexDirectory.get(), indexType.toString().toLowerCase());
} else {
artistWriter.addDocument(documentFactory.createArtistDocument(mediaFile)); @Autowired
} private AnalyzerFactory analyzerFactory;
} catch (Exception x) {
LOG.error("Failed to create search index for " + mediaFile, x); @Autowired
} private DocumentFactory documentFactory;
}
private Map<IndexType, SearcherManager> searchers = new HashMap<>();
private static final String LUCENE_DIR = "lucene2";
private Map<IndexType, IndexWriter> writers = new HashMap<>();
public IndexReader createIndexReader(IndexType indexType) throws IOException {
File dir = getIndexDirectory(indexType); public void index(Album album) {
return IndexReader.open(FSDirectory.open(dir), true); Term primarykey = documentFactory.createPrimarykey(album);
} Document document = documentFactory.createAlbumId3Document(album);
try {
/** writers.get(IndexType.ALBUM_ID3).updateDocument(primarykey, document);
* It is static as an intermediate response of the transition period. } catch (Exception x) {
* (It is called before injection because it is called by SearchService constructor) LOG.error("Failed to create search index for " + album, x);
* }
* @return }
*/
private static File getIndexRootDirectory() { public void index(Artist artist, MusicFolder musicFolder) {
return new File(SettingsService.getAirsonicHome(), LUCENE_DIR); Term primarykey = documentFactory.createPrimarykey(artist);
} Document document = documentFactory.createArtistId3Document(artist, musicFolder);
try {
/** writers.get(IndexType.ARTIST_ID3).updateDocument(primarykey, document);
* Make it public as an interim response of the transition period. } catch (Exception x) {
* (It is called before the injection because it is called in the SearchService constructor.) LOG.error("Failed to create search index for " + artist, x);
* }
* @param indexType }
* @return
* @deprecated It should not be called from outside. public void index(MediaFile mediaFile) {
*/ Term primarykey = documentFactory.createPrimarykey(mediaFile);
@Deprecated try {
public static File getIndexDirectory(IndexType indexType) { if (mediaFile.isFile()) {
return new File(getIndexRootDirectory(), indexType.toString().toLowerCase()); Document document = documentFactory.createSongDocument(mediaFile);
} writers.get(IndexType.SONG).updateDocument(primarykey, document);
} else if (mediaFile.isAlbum()) {
private IndexWriter createIndexWriter(IndexType indexType) throws IOException { Document document = documentFactory.createAlbumDocument(mediaFile);
File dir = getIndexDirectory(indexType); writers.get(IndexType.ALBUM).updateDocument(primarykey, document);
return new IndexWriter(FSDirectory.open(dir), analyzerFactory.getAnalyzer(), true, } else {
new IndexWriter.MaxFieldLength(10)); Document document = documentFactory.createArtistDocument(mediaFile);
} writers.get(IndexType.ARTIST).updateDocument(primarykey, document);
}
public final void startIndexing() { } catch (Exception x) {
try { LOG.error("Failed to create search index for " + mediaFile, x);
artistWriter = createIndexWriter(ARTIST); }
artistId3Writer = createIndexWriter(ARTIST_ID3); }
albumWriter = createIndexWriter(ALBUM);
albumId3Writer = createIndexWriter(ALBUM_ID3); public final void startIndexing() {
songWriter = createIndexWriter(SONG); try {
} catch (Exception x) { for (IndexType IndexType : IndexType.values()) {
LOG.error("Failed to create search index.", x); writers.put(IndexType, createIndexWriter(IndexType));
} }
} } catch (IOException e) {
LOG.error("Failed to create search index.", e);
public void stopIndexing() { }
try { }
artistWriter.optimize();
artistId3Writer.optimize(); /**
albumWriter.optimize(); *
albumId3Writer.optimize(); * @param indexType
songWriter.optimize(); * @return
} catch (Exception x) { * @throws IOException
LOG.error("Failed to create search index.", x); */
} finally { private IndexWriter createIndexWriter(IndexType indexType) throws IOException {
FileUtil.closeQuietly(artistId3Writer); File indexDirectory = getIndexDirectory.apply(indexType);
FileUtil.closeQuietly(artistWriter); IndexWriterConfig config = new IndexWriterConfig(analyzerFactory.getAnalyzer());
FileUtil.closeQuietly(albumWriter); return new IndexWriter(FSDirectory.open(indexDirectory.toPath()), config);
FileUtil.closeQuietly(albumId3Writer); }
FileUtil.closeQuietly(songWriter);
} /**
} * 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);
}
}
}
}

@ -1,137 +1,142 @@
/* /*
This file is part of Airsonic. This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
Airsonic is distributed in the hope that it will be useful, Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>. along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/ */
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
import java.util.AbstractMap; import java.util.AbstractMap;
import java.util.AbstractMap.SimpleEntry; import java.util.AbstractMap.SimpleEntry;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* Enum that symbolizes the each lucene index entity. * Enum that symbolizes the each lucene index entity.
* This class is a division of what was once part of SearchService and added functionality. * This class is a division of what was once part of SearchService and added functionality.
* @since legacy * @since legacy
*/ */
public enum IndexType { public enum IndexType {
/* SONG(
* Boosts is a factor for search scores, which is 1 by default. fieldNames(
*/ FieldNames.TITLE,
SONG( FieldNames.ARTIST),
fieldNames( boosts(
FieldNames.TITLE, entry(FieldNames.TITLE, 2F))),
FieldNames.ARTIST),
boosts( ALBUM(
entry(FieldNames.TITLE, 2F))), fieldNames(
FieldNames.ALBUM,
ALBUM( FieldNames.ARTIST),
fieldNames( // FieldNames.FOLDER), // XXX 3.x -> 8.x : Remove folder from multi-field search condition
FieldNames.ALBUM, boosts(
FieldNames.ARTIST, entry(FieldNames.ALBUM, 2F))),
FieldNames.FOLDER),
boosts( ALBUM_ID3(
entry(FieldNames.ALBUM, 2F))), fieldNames(
FieldNames.ALBUM,
ALBUM_ID3( FieldNames.ARTIST),
fieldNames( // FieldNames.FOLDER_ID), // XXX 3.x -> 8.x : Remove folder from multi-field search condition
FieldNames.ALBUM, boosts(
FieldNames.ARTIST, entry(FieldNames.ALBUM, 2F))),
FieldNames.FOLDER_ID),
boosts( ARTIST(
entry(FieldNames.ALBUM, 2F))), fieldNames(
FieldNames.ARTIST),
ARTIST( // FieldNames.FOLDER), // XXX 3.x -> 8.x : Remove folder from multi-field search condition
fieldNames( boosts(
FieldNames.ARTIST, entry(FieldNames.ARTIST, 1F))),
FieldNames.FOLDER),
boosts( ARTIST_ID3(
entry(FieldNames.ARTIST, 1F))), fieldNames(
FieldNames.ARTIST),
ARTIST_ID3( boosts(
fieldNames( entry(FieldNames.ARTIST, 2F))),
FieldNames.ARTIST),
boosts( ;
entry(FieldNames.ARTIST, 2F))),
; /**
* Define the field's applied boost value when searching IndexType.
@SafeVarargs *
private static final Map<String, Float> boosts(SimpleEntry<String, Float>... entry) { * @param entry {@link #entry(String, float)}.
Map<String, Float> m = new HashMap<>(); * When specifying multiple values, enumerate entries.
Arrays.stream(entry).forEach(kv -> m.put(kv.getKey(), kv.getValue())); * @return Map of boost values to be applied to the field
return Collections.unmodifiableMap(m); */
} @SafeVarargs
private static final Map<String, Float> boosts(SimpleEntry<String, Float>... entry) {
/* Map<String, Float> m = new HashMap<>();
* The current state is implemented to set the same value as legacy. Arrays.stream(entry).forEach(kv -> m.put(kv.getKey(), kv.getValue()));
* However unlike legacy, it has been changed return Collections.unmodifiableMap(m);
* so that different values can be set for each field. }
* When setting two or more boost values,
* it is desirable to differentiate the values. /**
*/ * Create an entry representing the boost value for the field.
private static final SimpleEntry<String, Float> entry(String k, float v) { *
return new AbstractMap.SimpleEntry<>(k, v); * @param k Field name defined by FieldNames
} * @param v Boost value
* @return
private static final String[] fieldNames(String... names) { */
return Arrays.stream(names).toArray(String[]::new); private static final SimpleEntry<String, Float> entry(String k, float v) {
} return new AbstractMap.SimpleEntry<>(k, v);
}
private final Map<String, Float> boosts;
/**
private final String[] fields; * Defines the field that the input value is to search for
* when searching IndexType.
private IndexType(String[] fieldNames, Map<String, Float> boosts) { * If you specify multiple values, list the field names.
this.fields = fieldNames; *
this.boosts = boosts; * @param names
} * @return
*/
/** private static final String[] fieldNames(String... names) {
* Returns a map of fields and boost values. return Arrays.stream(names).toArray(String[]::new);
* }
* @return map of fields and boost values
* @since legacy private final Map<String, Float> boosts;
*/
/* private final String[] fields;
* See the lucene documentation for boost specifications.
*/ private IndexType(String[] fieldNames, Map<String, Float> boosts) {
public Map<String, Float> getBoosts() { this.fields = fieldNames;
return boosts; this.boosts = boosts;
} }
/** /**
* Return some of the fields defined in the index. * Returns a map of fields and boost values.
* *
* @return Fields mainly used in multi-field search * @return Map of fields and boost values
* @since legacy * @since legacy
*/ * @see BoostQuery
/* */
* It maintains a fairly early implementation public Map<String, Float> getBoosts() {
* and can be considered as an argument of MultiFieldQueryParser. return boosts;
* 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 some of the fields defined in the index.
return fields; *
} * @return Fields mainly used in multi-field search
* @since legacy
} */
public String[] getFields() {
return fields;
}
}

@ -1,231 +1,291 @@
/* /*
This file is part of Airsonic. This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
Airsonic is distributed in the hope that it will be useful, Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>. along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/ */
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
import org.airsonic.player.domain.MediaFile; import org.airsonic.player.domain.MediaFile.MediaType;
import org.airsonic.player.domain.MusicFolder; import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.domain.RandomSearchCriteria; import org.airsonic.player.domain.RandomSearchCriteria;
import org.airsonic.player.domain.SearchCriteria; import org.airsonic.player.domain.SearchCriteria;
import org.apache.lucene.analysis.ASCIIFoldingFilter; import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardTokenizer; import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.TermAttribute; import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.index.Term; import org.apache.lucene.document.IntPoint;
import org.apache.lucene.queryParser.MultiFieldQueryParser; import org.apache.lucene.index.Term;
import org.apache.lucene.queryParser.ParseException; import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.queryParser.QueryParser; import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.Query;
import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.NumericRangeQuery; import org.apache.lucene.search.WildcardQuery;
import org.apache.lucene.search.Query; import org.checkerframework.checker.nullness.qual.NonNull;
import org.apache.lucene.search.TermQuery; import org.checkerframework.checker.nullness.qual.Nullable;
import org.apache.lucene.search.spans.SpanOrQuery; import org.springframework.beans.factory.annotation.Autowired;
import org.apache.lucene.search.spans.SpanQuery; import org.springframework.stereotype.Component;
import org.apache.lucene.search.spans.SpanTermQuery;
import org.apache.lucene.util.NumericUtils; import java.io.IOException;
import org.apache.lucene.util.Version; import java.util.ArrayList;
import org.springframework.beans.factory.annotation.Autowired; import java.util.List;
import org.springframework.stereotype.Component; import java.util.function.BiFunction;
import java.util.function.Function;
import java.io.IOException;
import java.io.StringReader; import static org.springframework.util.ObjectUtils.isEmpty;
import java.util.ArrayList;
import java.util.List; /**
* Factory class of Lucene Query.
import static org.airsonic.player.service.search.IndexType.ALBUM_ID3; * This class is an extract of the functionality that was once part of SearchService.
import static org.airsonic.player.service.search.IndexType.ARTIST_ID3; * It is for maintainability and verification.
* Each corresponds to the SearchService method.
/** * The API syntax for query generation depends on the lucene version.
* Factory class of Lucene Query. * verification with query grammar is possible.
* This class is an extract of the functionality that was once part of SearchService. * On the other hand, the generated queries are relatively small by version.
* It is for maintainability and verification. * Therefore, test cases of this class are useful for large version upgrades.
* Each corresponds to the SearchService method. **/
* The API syntax for query generation depends on the lucene version. @Component
* verification with query grammar is possible. public class QueryFactory {
* On the other hand, the generated queries are relatively small by version.
* Therefore, test cases of this class are useful for large version upgrades. private static final String ASTERISK = "*";
**/
@Component @Autowired
public class QueryFactory { private AnalyzerFactory analyzerFactory;
@Autowired private final Function<MusicFolder, Query> toFolderIdQuery = (folder) -> {
private AnalyzerFactory analyzerFactory; // Unanalyzed field
return new TermQuery(new Term(FieldNames.FOLDER_ID, folder.getId().toString()));
private String analyzeQuery(String query) throws IOException { };
StringBuilder result = new StringBuilder();
/* private final Function<MusicFolder, Query> toFolderPathQuery = (folder) -> {
* Version.LUCENE_30 // Unanalyzed field
* It is a transient description and will be deleted when upgrading the version. return new TermQuery(new Term(FieldNames.FOLDER, folder.getPath().getPath()));
* SearchService variables are not used because the reference direction conflicts. };
*/
@SuppressWarnings("resource") /*
ASCIIFoldingFilter filter = new ASCIIFoldingFilter( * XXX 3.x -> 8.x :
new StandardTokenizer(Version.LUCENE_30, new StringReader(query))); * "SpanOr" has been changed to "Or".
TermAttribute termAttribute = filter.getAttribute(TermAttribute.class); * - Path comparison is more appropriate with "Or".
while (filter.incrementToken()) { * - If "SpanOr" is maintained, the DOC design needs to be changed.
result.append(termAttribute.term()).append("* "); */
} private final BiFunction<@NonNull Boolean, @NonNull List<MusicFolder>, @NonNull Query> toFolderQuery = (
return result.toString(); isId3, folders) -> {
} BooleanQuery.Builder mfQuery = new BooleanQuery.Builder();
folders.stream()
/** .map(isId3 ? toFolderIdQuery : toFolderPathQuery)
* Normalize the genre string. .forEach(t -> mfQuery.add(t, Occur.SHOULD));
* return mfQuery.build();
* @param genre genre string };
* @return genre string normalized
* @deprecated should be resolved with tokenizer or filter /*
*/ * XXX 3.x -> 8.x :
@Deprecated * In order to support wildcards,
private String normalizeGenre(String genre) { * MultiFieldQueryParser has been replaced by the following process.
return genre.toLowerCase().replace(" ", "").replace("-", ""); *
} * - 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.
* Query generation expression extracted from * - Self made parser process reduces one library dependency.
* {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}. * - It is easy to make corrections later when changing the query to improve search accuracy.
* */
* @param criteria criteria private Query createMultiFieldWildQuery(@NonNull String[] fieldNames, @NonNull String queryString)
* @param musicFolders musicFolders throws IOException {
* @param indexType {@link IndexType}
* @return Query BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
* @throws IOException When parsing of MultiFieldQueryParser fails
* @throws ParseException When parsing of MultiFieldQueryParser fails List<List<Query>> fieldsQuerys = new ArrayList<>();
*/ Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
public Query search(SearchCriteria criteria, List<MusicFolder> musicFolders,
IndexType indexType) throws ParseException, IOException { /* Wildcard applies to all tokens. **/
/* for (String fieldName : fieldNames) {
* Version.LUCENE_30 try (TokenStream stream = analyzer.tokenStream(fieldName, queryString)) {
* It is a transient description and will be deleted when upgrading the version. stream.reset();
* SearchService variables are not used because the reference direction conflicts. List<Query> fieldQuerys = new ArrayList<>();
*/ while (stream.incrementToken()) {
MultiFieldQueryParser queryParser = new MultiFieldQueryParser(Version.LUCENE_30, String token = stream.getAttribute(CharTermAttribute.class).toString();
indexType.getFields(), analyzerFactory.getQueryAnalyzer(), indexType.getBoosts()); WildcardQuery wildcardQuery = new WildcardQuery(new Term(fieldName, token.concat(ASTERISK)));
fieldQuerys.add(wildcardQuery);
BooleanQuery query = new BooleanQuery(); }
query.add(queryParser.parse(analyzeQuery(criteria.getQuery())), BooleanClause.Occur.MUST); fieldsQuerys.add(fieldQuerys);
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); }
for (MusicFolder musicFolder : musicFolders) { }
if (indexType == ALBUM_ID3 || indexType == ARTIST_ID3) {
musicFolderQueries.add(new SpanTermQuery(new Term(FieldNames.FOLDER_ID, /* If Field's Tokenizer is different, token's length may not match. **/
NumericUtils.intToPrefixCoded(musicFolder.getId())))); int maxTermLength = fieldsQuerys.stream()
} else { .map(l -> l.size())
musicFolderQueries.add(new SpanTermQuery( .max(Integer::compare).get();
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath())));
} if (0 < fieldsQuerys.size()) {
} for (int i = 0; i < maxTermLength; i++) {
query.add( BooleanQuery.Builder fieldsQuery = new BooleanQuery.Builder();
new SpanOrQuery( for (List<Query> fieldQuerys : fieldsQuerys) {
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])), if (i < fieldQuerys.size()) {
BooleanClause.Occur.MUST); fieldsQuery.add(fieldQuerys.get(i), Occur.SHOULD);
return query; }
} }
mainQuery.add(fieldsQuery.build(), Occur.SHOULD);
/** }
* Query generation expression extracted from }
* {@link org.airsonic.player.service.SearchService#getRandomSongs(RandomSearchCriteria)}.
* return mainQuery.build();
* @param criteria criteria
* @return Query };
*/
public Query getRandomSongs(RandomSearchCriteria criteria) { /*
BooleanQuery query = new BooleanQuery(); * XXX 3.x -> 8.x :
query.add(new TermQuery( * RangeQuery has been changed to not allow null.
new Term(FieldNames.MEDIA_TYPE, MediaFile.MediaType.MUSIC.name().toLowerCase())), */
BooleanClause.Occur.MUST); private final BiFunction<@Nullable Integer, @Nullable Integer, @NonNull Query> toYearRangeQuery =
if (criteria.getGenre() != null) { (from, to) -> {
String genre = normalizeGenre(criteria.getGenre()); return IntPoint.newRangeQuery(FieldNames.YEAR,
query.add(new TermQuery(new Term(FieldNames.GENRE, genre)), BooleanClause.Occur.MUST); isEmpty(from) ? Integer.MIN_VALUE : from,
} isEmpty(to) ? Integer.MAX_VALUE : to);
if (criteria.getFromYear() != null || criteria.getToYear() != null) { };
NumericRangeQuery<Integer> rangeQuery = NumericRangeQuery.newIntRange(FieldNames.YEAR,
criteria.getFromYear(), criteria.getToYear(), true, true); /**
query.add(rangeQuery, BooleanClause.Occur.MUST); * Query generation expression extracted from
} * {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}.
*
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); * @param criteria criteria
for (MusicFolder musicFolder : criteria.getMusicFolders()) { * @param musicFolders musicFolders
musicFolderQueries.add(new SpanTermQuery( * @param indexType {@link IndexType}
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath()))); * @return Query
} * @throws IOException When parsing of MultiFieldQueryParser fails
query.add( */
new SpanOrQuery( public Query search(SearchCriteria criteria, List<MusicFolder> musicFolders,
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])), IndexType indexType) throws IOException {
BooleanClause.Occur.MUST);
return query; BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
}
Query multiFieldQuery = createMultiFieldWildQuery(indexType.getFields(), criteria.getQuery());
/** mainQuery.add(multiFieldQuery, Occur.MUST);
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#searchByName( String, String, int, int, List, Class)}. boolean isId3 = indexType == IndexType.ALBUM_ID3 || indexType == IndexType.ARTIST_ID3;
* Query folderQuery = toFolderQuery.apply(isId3, musicFolders);
* @param fieldName {@link FieldNames} mainQuery.add(folderQuery, Occur.MUST);
* @return Query
* @throws ParseException When parsing of QueryParser fails return mainQuery.build();
*/
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. * Query generation expression extracted from
* SearchService variables are not used because the reference direction conflicts. * {@link org.airsonic.player.service.SearchService#getRandomSongs(RandomSearchCriteria)}.
*/ *
QueryParser queryParser = new QueryParser(Version.LUCENE_30, fieldName, * @param criteria criteria
analyzerFactory.getQueryAnalyzer()); * @return Query
Query query = queryParser.parse(name + "*"); * @throws IOException
return query; */
} public Query getRandomSongs(RandomSearchCriteria criteria) throws IOException {
/** BooleanQuery.Builder query = new BooleanQuery.Builder();
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#getRandomAlbums(int, List)}. Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
*
* @param musicFolders musicFolders // Unanalyzed field
* @return Query query.add(new TermQuery(new Term(FieldNames.MEDIA_TYPE, MediaType.MUSIC.name())), Occur.MUST);
*/
public Query getRandomAlbums(List<MusicFolder> musicFolders) { if (!isEmpty(criteria.getGenre())) {
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : musicFolders) { // Unanalyzed field, but performs filtering according to id3 tag parser.
musicFolderQueries.add(new SpanTermQuery( try (TokenStream stream = analyzer.tokenStream(FieldNames.GENRE, criteria.getGenre())) {
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath()))); stream.reset();
} if (stream.incrementToken()) {
Query query = new SpanOrQuery( String token = stream.getAttribute(CharTermAttribute.class).toString();
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])); query.add(new TermQuery(new Term(FieldNames.GENRE, token)), Occur.MUST);
return query; }
} }
}
/**
* Query generation expression extracted from if (!(isEmpty(criteria.getFromYear()) && isEmpty(criteria.getToYear()))) {
* {@link org.airsonic.player.service.SearchService#getRandomAlbumsId3(int, List)}. query.add(toYearRangeQuery.apply(criteria.getFromYear(), criteria.getToYear()), Occur.MUST);
* }
* @param musicFolders musicFolders
* @return Query query.add(toFolderQuery.apply(false, criteria.getMusicFolders()), Occur.MUST);
*/
public Query getRandomAlbumsId3(List<MusicFolder> musicFolders) { return query.build();
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); }
for (MusicFolder musicFolder : musicFolders) {
musicFolderQueries.add(new SpanTermQuery(new Term(FieldNames.FOLDER_ID, /**
NumericUtils.intToPrefixCoded(musicFolder.getId())))); * Query generation expression extracted from
} * {@link org.airsonic.player.service.SearchService#searchByName( String, String, int, int, List, Class)}.
Query query = new SpanOrQuery( *
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])); * @param fieldName {@link FieldNames}
return query; * @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<MusicFolder> 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<MusicFolder> musicFolders) {
return new BooleanQuery.Builder()
.add(toFolderQuery.apply(true, musicFolders), Occur.SHOULD)
.build();
}
}

@ -22,14 +22,8 @@ package org.airsonic.player.service.search;
import org.airsonic.player.domain.*; import org.airsonic.player.domain.*;
import org.airsonic.player.service.SearchService; import org.airsonic.player.service.SearchService;
import org.airsonic.player.util.FileUtil;
import org.apache.lucene.document.Document; 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.search.*;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -59,35 +53,6 @@ public class SearchServiceImpl implements SearchService {
// TODO Should be changed to SecureRandom? // TODO Should be changed to SecureRandom?
private final Random random = new Random(System.currentTimeMillis()); 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 @Override
public SearchResult search(SearchCriteria criteria, List<MusicFolder> musicFolders, public SearchResult search(SearchCriteria criteria, List<MusicFolder> musicFolders,
IndexType indexType) { IndexType indexType) {
@ -100,26 +65,28 @@ public class SearchServiceImpl implements SearchService {
if (count <= 0) if (count <= 0)
return result; return result;
IndexReader reader = null; IndexSearcher searcher = indexManager.getSearcher(indexType);
try { if (isEmpty(searcher)) {
return result;
}
reader = indexManager.createIndexReader(indexType); try {
Searcher searcher = new IndexSearcher(reader);
Query query = queryFactory.search(criteria, musicFolders, indexType); Query query = queryFactory.search(criteria, musicFolders, indexType);
TopDocs topDocs = searcher.search(query, null, offset + count); TopDocs topDocs = searcher.search(query, offset + count);
result.setTotalHits(topDocs.totalHits); int totalHits = util.round.apply(topDocs.totalHits.value);
int start = Math.min(offset, topDocs.totalHits); result.setTotalHits(totalHits);
int end = Math.min(start + count, topDocs.totalHits); int start = Math.min(offset, totalHits);
int end = Math.min(start + count, totalHits);
for (int i = start; i < end; i++) { for (int i = start; i < end; i++) {
util.addIfAnyMatch(result, indexType, searcher.doc(topDocs.scoreDocs[i].doc)); 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); LOG.error("Failed to execute Lucene search.", e);
} finally { } finally {
FileUtil.closeQuietly(reader); indexManager.release(indexType, searcher);
} }
return result; return result;
} }
@ -135,7 +102,7 @@ public class SearchServiceImpl implements SearchService {
* @throws IOException * @throws IOException
*/ */
private final <D> List<D> createRandomDocsList( private final <D> List<D> createRandomDocsList(
int count, Searcher searcher, Query query, BiConsumer<List<D>, Integer> id2ListCallBack) int count, IndexSearcher searcher, Query query, BiConsumer<List<D>, Integer> id2ListCallBack)
throws IOException { throws IOException {
List<Integer> docs = Arrays List<Integer> docs = Arrays
@ -157,14 +124,13 @@ public class SearchServiceImpl implements SearchService {
@Override @Override
public List<MediaFile> getRandomSongs(RandomSearchCriteria criteria) { public List<MediaFile> getRandomSongs(RandomSearchCriteria criteria) {
IndexReader reader = null; IndexSearcher searcher = indexManager.getSearcher(SONG);
if (isEmpty(searcher)) {
// At first start
return Collections.emptyList();
}
try { try {
reader = indexManager.createIndexReader(SONG);
Searcher searcher = new IndexSearcher(reader);
if (isEmpty(searcher)) {
// At first start
return Collections.emptyList();
}
Query query = queryFactory.getRandomSongs(criteria); Query query = queryFactory.getRandomSongs(criteria);
return createRandomDocsList(criteria.getCount(), searcher, query, return createRandomDocsList(criteria.getCount(), searcher, query,
@ -173,7 +139,7 @@ public class SearchServiceImpl implements SearchService {
} catch (IOException e) { } catch (IOException e) {
LOG.error("Failed to search or random songs.", e); LOG.error("Failed to search or random songs.", e);
} finally { } finally {
FileUtil.closeQuietly(reader); indexManager.release(IndexType.SONG, searcher);
} }
return Collections.emptyList(); return Collections.emptyList();
} }
@ -181,22 +147,22 @@ public class SearchServiceImpl implements SearchService {
@Override @Override
public List<MediaFile> getRandomAlbums(int count, List<MusicFolder> musicFolders) { public List<MediaFile> getRandomAlbums(int count, List<MusicFolder> musicFolders) {
IndexReader reader = null; IndexSearcher searcher = indexManager.getSearcher(IndexType.ALBUM);
if (isEmpty(searcher)) {
return Collections.emptyList();
}
Query query = queryFactory.getRandomAlbums(musicFolders);
try { 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, return createRandomDocsList(count, searcher, query,
(dist, id) -> util.addIgnoreNull(dist, ALBUM, id)); (dist, id) -> util.addIgnoreNull(dist, ALBUM, id));
} catch (IOException e) { } catch (IOException e) {
LOG.error("Failed to search for random albums.", e); LOG.error("Failed to search for random albums.", e);
} finally { } finally {
FileUtil.closeQuietly(reader); indexManager.release(IndexType.ALBUM, searcher);
} }
return Collections.emptyList(); return Collections.emptyList();
} }
@ -204,22 +170,22 @@ public class SearchServiceImpl implements SearchService {
@Override @Override
public List<Album> getRandomAlbumsId3(int count, List<MusicFolder> musicFolders) { public List<Album> getRandomAlbumsId3(int count, List<MusicFolder> musicFolders) {
IndexReader reader = null; IndexSearcher searcher = indexManager.getSearcher(IndexType.ALBUM_ID3);
if (isEmpty(searcher)) {
return Collections.emptyList();
}
Query query = queryFactory.getRandomAlbumsId3(musicFolders);
try { 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, return createRandomDocsList(count, searcher, query,
(dist, id) -> util.addIgnoreNull(dist, ALBUM_ID3, id)); (dist, id) -> util.addIgnoreNull(dist, ALBUM_ID3, id));
} catch (IOException e) { } catch (IOException e) {
LOG.error("Failed to search for random albums.", e); LOG.error("Failed to search for random albums.", e);
} finally { } finally {
FileUtil.closeQuietly(reader); indexManager.release(IndexType.ALBUM_ID3, searcher);
} }
return Collections.emptyList(); return Collections.emptyList();
} }
@ -241,58 +207,39 @@ public class SearchServiceImpl implements SearchService {
return result; return result;
} }
IndexReader reader = null; IndexSearcher searcher = indexManager.getSearcher(indexType);
if (isEmpty(searcher)) {
return result;
}
try { try {
reader = indexManager.createIndexReader(indexType);
Searcher searcher = new IndexSearcher(reader);
Query query = queryFactory.searchByName(fieldName, name); Query query = queryFactory.searchByName(fieldName, name);
Sort sort = new Sort(new SortField(fieldName, SortField.STRING)); SortField[] sortFields = Arrays
TopDocs topDocs = searcher.search(query, null, offset + count, sort); .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 totalHits = util.round.apply(topDocs.totalHits.value);
int start = Math.min(offset, topDocs.totalHits); result.setTotalHits(totalHits);
int end = Math.min(start + count, topDocs.totalHits); int start = Math.min(offset, totalHits);
int end = Math.min(start + count, totalHits);
for (int i = start; i < end; i++) { for (int i = start; i < end; i++) {
Document doc = searcher.doc(topDocs.scoreDocs[i].doc); Document doc = searcher.doc(topDocs.scoreDocs[i].doc);
util.addIgnoreNull(result, indexType, util.getId.apply(doc), assignableClass); util.addIgnoreNull(result, indexType, util.getId.apply(doc), assignableClass);
} }
} catch (IOException | ParseException e) { } catch (IOException e) {
LOG.error("Failed to execute Lucene search.", e); LOG.error("Failed to execute Lucene search.", e);
} finally { } finally {
FileUtil.closeQuietly(reader); indexManager.release(indexType, searcher);
} }
return result; 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);
}
}
}
} }

@ -1,200 +1,176 @@
/* /*
This file is part of Airsonic. This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
Airsonic is distributed in the hope that it will be useful, Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>. along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/ */
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
import org.airsonic.player.dao.AlbumDao; import org.airsonic.player.dao.AlbumDao;
import org.airsonic.player.dao.ArtistDao; import org.airsonic.player.dao.ArtistDao;
import org.airsonic.player.domain.Album; import org.airsonic.player.domain.Album;
import org.airsonic.player.domain.Artist; import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile; import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.ParamSearchResult; import org.airsonic.player.domain.ParamSearchResult;
import org.airsonic.player.domain.SearchResult; import org.airsonic.player.domain.SearchResult;
import org.airsonic.player.service.MediaFileService; import org.airsonic.player.service.MediaFileService;
import org.airsonic.player.service.SettingsService; import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.collections.CollectionUtils; import org.apache.lucene.document.Document;
import org.apache.lucene.document.Document; import org.checkerframework.checker.nullness.qual.Nullable;
import org.apache.lucene.index.Term; import org.springframework.beans.factory.annotation.Autowired;
import org.checkerframework.checker.nullness.qual.Nullable; import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import java.util.Collection;
import java.util.List;
import java.io.File; import java.util.function.BiConsumer;
import java.util.Collection; import java.util.function.Function;
import java.util.List;
import java.util.function.BiConsumer; import static org.springframework.util.ObjectUtils.isEmpty;
import java.util.function.BiFunction;
import java.util.function.Function; /**
* Termination used by SearchService.
import static org.springframework.util.ObjectUtils.isEmpty; *
* Since SearchService operates as a proxy for storage (DB) using lucene,
/** * there are many redundant descriptions different from essential data processing.
* Termination used by SearchService. * This class is a transfer class for saving those redundant descriptions.
* *
* Since SearchService operates as a proxy for storage (DB) using lucene, * Exception handling is not termination,
* there are many redundant descriptions different from essential data processing. * so do not include exception handling in this class.
* This class is a transfer class for saving those redundant descriptions. */
* @Component
* Exception handling is not termination, public class SearchServiceUtilities {
* so do not include exception handling in this class.
*/ /* Search by id only. */
@Component @Autowired
public class SearchServiceUtilities { private ArtistDao artistDao;
/* Search by id only. */ /* Search by id only. */
@Autowired @Autowired
private ArtistDao artistDao; private AlbumDao albumDao;
/* Search by id only. */ /*
@Autowired * Search by id only.
private AlbumDao albumDao; * 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.
* Search by id only. */
* Although there is no influence at present, @Autowired
* mediaFileService has a caching mechanism. private MediaFileService mediaFileService;
* Service is used instead of Dao until you are sure you need to use mediaFileDao.
*/ public final Function<Long, Integer> round = (i) -> {
@Autowired // return
private MediaFileService mediaFileService; // NumericUtils.floatToSortableInt(i);
return i.intValue();
public final Function<Long, Integer> round = (i) -> { };
// return
// NumericUtils.floatToSortableInt(i); public final Function<Document, Integer> getId = d -> {
return i.intValue(); return Integer.valueOf(d.get(FieldNames.ID));
}; };
public final Function<Document, Integer> getId = d -> { public final BiConsumer<List<MediaFile>, Integer> addMediaFileIfAnyMatch = (dist, id) -> {
return Integer.valueOf(d.get(FieldNames.ID)); if (!dist.stream().anyMatch(m -> id == m.getId())) {
}; MediaFile mediaFile = mediaFileService.getMediaFile(id);
if (!isEmpty(mediaFile)) {
public final BiConsumer<List<MediaFile>, Integer> addMediaFileIfAnyMatch = (dist, id) -> { dist.add(mediaFile);
if (!dist.stream().anyMatch(m -> id == m.getId())) { }
MediaFile mediaFile = mediaFileService.getMediaFile(id); }
if (!isEmpty(mediaFile)) { };
dist.add(mediaFile);
} public final BiConsumer<List<Artist>, Integer> addArtistId3IfAnyMatch = (dist, id) -> {
} if (!dist.stream().anyMatch(a -> id == a.getId())) {
}; Artist artist = artistDao.getArtist(id);
if (!isEmpty(artist)) {
public final BiConsumer<List<Artist>, Integer> addArtistId3IfAnyMatch = (dist, id) -> { dist.add(artist);
if (!dist.stream().anyMatch(a -> id == a.getId())) { }
Artist artist = artistDao.getArtist(id); }
if (!isEmpty(artist)) { };
dist.add(artist);
} public final Function<Class<?>, @Nullable IndexType> getIndexType = (assignableClass) -> {
} IndexType indexType = null;
}; if (assignableClass.isAssignableFrom(Album.class)) {
indexType = IndexType.ALBUM_ID3;
public final Function<Class<?>, @Nullable IndexType> getIndexType = (assignableClass) -> { } else if (assignableClass.isAssignableFrom(Artist.class)) {
IndexType indexType = null; indexType = IndexType.ARTIST_ID3;
if (assignableClass.isAssignableFrom(Album.class)) { } else if (assignableClass.isAssignableFrom(MediaFile.class)) {
indexType = IndexType.ALBUM_ID3; indexType = IndexType.SONG;
} else if (assignableClass.isAssignableFrom(Artist.class)) { }
indexType = IndexType.ARTIST_ID3; return indexType;
} else if (assignableClass.isAssignableFrom(MediaFile.class)) { };
indexType = IndexType.SONG;
} public final Function<Class<?>, @Nullable String> getFieldName = (assignableClass) -> {
return indexType; String fieldName = null;
}; if (assignableClass.isAssignableFrom(Album.class)) {
fieldName = FieldNames.ALBUM;
public final Function<Class<?>, @Nullable String> getFieldName = (assignableClass) -> { } else if (assignableClass.isAssignableFrom(Artist.class)) {
String fieldName = null; fieldName = FieldNames.ARTIST;
if (assignableClass.isAssignableFrom(Album.class)) { } else if (assignableClass.isAssignableFrom(MediaFile.class)) {
fieldName = FieldNames.ALBUM; fieldName = FieldNames.TITLE;
} else if (assignableClass.isAssignableFrom(Artist.class)) { }
fieldName = FieldNames.ARTIST; return fieldName;
} else if (assignableClass.isAssignableFrom(MediaFile.class)) { };
fieldName = FieldNames.TITLE;
} public final BiConsumer<List<Album>, Integer> addAlbumId3IfAnyMatch = (dist, subjectId) -> {
return fieldName; if (!dist.stream().anyMatch(a -> subjectId == a.getId())) {
}; Album album = albumDao.getAlbum(subjectId);
if (!isEmpty(album)) {
public final BiConsumer<List<Album>, Integer> addAlbumId3IfAnyMatch = (dist, subjectId) -> { dist.add(album);
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);
}; }
private final Function<String, File> getRootDirectory = (version) -> { public final boolean addIgnoreNull(Collection<?> collection, IndexType indexType,
return new File(SettingsService.getAirsonicHome(), version); int subjectId) {
}; if (indexType == IndexType.ALBUM | indexType == IndexType.SONG) {
return addIgnoreNull(collection, mediaFileService.getMediaFile(subjectId));
public final BiFunction<String, IndexType, File> getDirectory = (version, indexType) -> { } else if (indexType == IndexType.ALBUM_ID3) {
return new File(getRootDirectory.apply(version), indexType.toString().toLowerCase()); return addIgnoreNull(collection, albumDao.getAlbum(subjectId));
}; }
return false;
public final Term createPrimarykey(Album album) { }
return new Term(FieldNames.ID, Integer.toString(album.getId()));
}; public final <T> void addIgnoreNull(ParamSearchResult<T> dist, IndexType indexType,
int subjectId, Class<T> subjectClass) {
public final Term createPrimarykey(Artist artist) { if (indexType == IndexType.SONG) {
return new Term(FieldNames.ID, Integer.toString(artist.getId())); MediaFile mediaFile = mediaFileService.getMediaFile(subjectId);
}; addIgnoreNull(dist.getItems(), subjectClass.cast(mediaFile));
} else if (indexType == IndexType.ARTIST_ID3) {
public final Term createPrimarykey(MediaFile mediaFile) { Artist artist = artistDao.getArtist(subjectId);
return new Term(FieldNames.ID, Integer.toString(mediaFile.getId())); addIgnoreNull(dist.getItems(), subjectClass.cast(artist));
}; } else if (indexType == IndexType.ALBUM_ID3) {
Album album = albumDao.getAlbum(subjectId);
public final boolean addIgnoreNull(Collection<?> collection, Object object) { addIgnoreNull(dist.getItems(), subjectClass.cast(album));
return CollectionUtils.addIgnoreNull(collection, object); }
} }
public final boolean addIgnoreNull(Collection<?> collection, IndexType indexType, public final void addIfAnyMatch(SearchResult dist, IndexType subjectIndexType,
int subjectId) { Document subject) {
if (indexType == IndexType.ALBUM | indexType == IndexType.SONG) { int documentId = getId.apply(subject);
return addIgnoreNull(collection, mediaFileService.getMediaFile(subjectId)); if (subjectIndexType == IndexType.ARTIST | subjectIndexType == IndexType.ALBUM
} else if (indexType == IndexType.ALBUM_ID3) { | subjectIndexType == IndexType.SONG) {
return addIgnoreNull(collection, albumDao.getAlbum(subjectId)); addMediaFileIfAnyMatch.accept(dist.getMediaFiles(), documentId);
} } else if (subjectIndexType == IndexType.ARTIST_ID3) {
return false; addArtistId3IfAnyMatch.accept(dist.getArtists(), documentId);
} } else if (subjectIndexType == IndexType.ALBUM_ID3) {
addAlbumId3IfAnyMatch.accept(dist.getAlbums(), documentId);
public final <T> void addIgnoreNull(ParamSearchResult<T> dist, IndexType indexType, }
int subjectId, Class<T> 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);
}
}
}

@ -1,138 +1,138 @@
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function; import java.util.function.Function;
import org.airsonic.player.TestCaseUtils; import org.airsonic.player.TestCaseUtils;
import org.airsonic.player.dao.DaoHelper; import org.airsonic.player.dao.DaoHelper;
import org.airsonic.player.dao.MusicFolderDao; import org.airsonic.player.dao.MusicFolderDao;
import org.airsonic.player.service.MediaScannerService; import org.airsonic.player.service.MediaScannerService;
import org.airsonic.player.service.SettingsService; import org.airsonic.player.service.SettingsService;
import org.airsonic.player.util.HomeRule; import org.airsonic.player.util.HomeRule;
import org.airsonic.player.util.MusicFolderTestData; import org.airsonic.player.util.MusicFolderTestData;
import org.junit.ClassRule; import org.junit.ClassRule;
import org.junit.Rule; import org.junit.Rule;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.junit.runner.Description; import org.junit.runner.Description;
import org.junit.runners.model.Statement; import org.junit.runners.model.Statement;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.rules.SpringClassRule; import org.springframework.test.context.junit4.rules.SpringClassRule;
import org.springframework.test.context.junit4.rules.SpringMethodRule; import org.springframework.test.context.junit4.rules.SpringMethodRule;
@ContextConfiguration(locations = { @ContextConfiguration(locations = {
"/applicationContext-service.xml", "/applicationContext-service.xml",
"/applicationContext-cache.xml", "/applicationContext-cache.xml",
"/applicationContext-testdb.xml"}) "/applicationContext-testdb.xml"})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@Component @Component
/** /**
* Abstract class for scanning MusicFolder. * Abstract class for scanning MusicFolder.
*/ */
public abstract class AbstractAirsonicHomeTest implements AirsonicHomeTest { public abstract class AbstractAirsonicHomeTest implements AirsonicHomeTest {
@ClassRule @ClassRule
public static final SpringClassRule classRule = new SpringClassRule() { public static final SpringClassRule classRule = new SpringClassRule() {
HomeRule homeRule = new HomeRule(); HomeRule homeRule = new HomeRule();
@Override @Override
public Statement apply(Statement base, Description description) { public Statement apply(Statement base, Description description) {
Statement spring = super.apply(base, description); Statement spring = super.apply(base, description);
return homeRule.apply(spring, description); return homeRule.apply(spring, description);
} }
}; };
/* /*
* Currently, Maven is executing test classes in series, * Currently, Maven is executing test classes in series,
* so this class can hold the state. * so this class can hold the state.
* When executing in parallel, subclasses should override this. * When executing in parallel, subclasses should override this.
*/ */
private static AtomicBoolean dataBasePopulated = new AtomicBoolean(); private static AtomicBoolean dataBasePopulated = new AtomicBoolean();
// Above. // Above.
private static AtomicBoolean dataBaseReady = new AtomicBoolean(); private static AtomicBoolean dataBaseReady = new AtomicBoolean();
protected final static Function<String, String> resolveBaseMediaPath = (childPath) -> { protected final static Function<String, String> resolveBaseMediaPath = (childPath) -> {
return MusicFolderTestData.resolveBaseMediaPath().concat(childPath); return MusicFolderTestData.resolveBaseMediaPath().concat(childPath);
}; };
@Autowired @Autowired
protected DaoHelper daoHelper; protected DaoHelper daoHelper;
@Autowired @Autowired
protected MediaScannerService mediaScannerService; protected MediaScannerService mediaScannerService;
@Autowired @Autowired
protected MusicFolderDao musicFolderDao; protected MusicFolderDao musicFolderDao;
@Autowired @Autowired
protected SettingsService settingsService; protected SettingsService settingsService;
@Rule @Rule
public final SpringMethodRule springMethodRule = new SpringMethodRule(); public final SpringMethodRule springMethodRule = new SpringMethodRule();
@Rule @Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder(); public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Override @Override
public AtomicBoolean dataBasePopulated() { public AtomicBoolean dataBasePopulated() {
return dataBasePopulated; return dataBasePopulated;
} }
@Override @Override
public AtomicBoolean dataBaseReady() { public AtomicBoolean dataBaseReady() {
return dataBaseReady; return dataBaseReady;
} }
@Override @Override
public final void populateDatabaseOnlyOnce() { public final void populateDatabaseOnlyOnce() {
if (!dataBasePopulated().get()) { if (!dataBasePopulated().get()) {
dataBasePopulated().set(true); dataBasePopulated().set(true);
getMusicFolders().forEach(musicFolderDao::createMusicFolder); getMusicFolders().forEach(musicFolderDao::createMusicFolder);
settingsService.clearMusicFolderCache(); settingsService.clearMusicFolderCache();
try { try {
// Await time to avoid scan failure. // Await time to avoid scan failure.
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
Thread.sleep(100); Thread.sleep(100);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
e.printStackTrace(); e.printStackTrace();
} }
TestCaseUtils.execScan(mediaScannerService); TestCaseUtils.execScan(mediaScannerService);
System.out.println("--- Report of records count per table ---"); System.out.println("--- Report of records count per table ---");
Map<String, Integer> records = TestCaseUtils.recordsInAllTables(daoHelper); Map<String, Integer> records = TestCaseUtils.recordsInAllTables(daoHelper);
records.keySet().stream().filter(s -> records.keySet().stream().filter(s ->
s.equals("MEDIA_FILE") s.equals("MEDIA_FILE")
| s.equals("ARTIST") | s.equals("ARTIST")
| s.equals("MUSIC_FOLDER") | s.equals("MUSIC_FOLDER")
| s.equals("ALBUM")) | s.equals("ALBUM"))
.forEach(tableName -> .forEach(tableName ->
System.out.println("\t" + tableName + " : " + records.get(tableName).toString())); System.out.println("\t" + tableName + " : " + records.get(tableName).toString()));
System.out.println("--- *********************** ---"); System.out.println("--- *********************** ---");
try { try {
// Await for Lucene to finish writing(asynchronous). // Await for Lucene to finish writing(asynchronous).
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {
Thread.sleep(100); Thread.sleep(100);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
e.printStackTrace(); e.printStackTrace();
} }
dataBaseReady().set(true); dataBaseReady().set(true);
} else { } else {
while (!dataBaseReady().get()) { while (!dataBaseReady().get()) {
try { try {
// The subsequent test method waits while reading DB data. // The subsequent test method waits while reading DB data.
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
Thread.sleep(100); Thread.sleep(100);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
e.printStackTrace(); e.printStackTrace();
} }
} }
} }
} }
} }

@ -1,45 +1,45 @@
package org.airsonic.player.service.search; package org.airsonic.player.service.search;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import org.airsonic.player.domain.MusicFolder; import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.util.MusicFolderTestData; import org.airsonic.player.util.MusicFolderTestData;
/** /**
* Test case interface for scanning MusicFolder. * Test case interface for scanning MusicFolder.
*/ */
public interface AirsonicHomeTest { public interface AirsonicHomeTest {
/** /**
* MusicFolder used by test class. * MusicFolder used by test class.
* *
* @return MusicFolder used by test class * @return MusicFolder used by test class
*/ */
default List<MusicFolder> getMusicFolders() { default List<MusicFolder> getMusicFolders() {
return MusicFolderTestData.getTestMusicFolders(); return MusicFolderTestData.getTestMusicFolders();
}; };
/** /**
* Whether the data input has been completed. * Whether the data input has been completed.
* *
* @return Static AtomicBoolean indicating whether the data injection has been * @return Static AtomicBoolean indicating whether the data injection has been
* completed * completed
*/ */
abstract AtomicBoolean dataBasePopulated(); abstract AtomicBoolean dataBasePopulated();
/** /**
* Whether the data input has been completed. * Whether the data input has been completed.
* *
* @return Static AtomicBoolean indicating whether the data injection has been * @return Static AtomicBoolean indicating whether the data injection has been
* completed * completed
*/ */
abstract AtomicBoolean dataBaseReady(); abstract AtomicBoolean dataBaseReady();
/** /**
* Populate the database only once. * Populate the database only once.
* It is called in the @Before granted method. * It is called in the @Before granted method.
*/ */
void populateDatabaseOnlyOnce(); void populateDatabaseOnlyOnce();
} }

@ -11,7 +11,7 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import org.apache.lucene.analysis.TokenStream; 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.junit.Test;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -85,9 +85,12 @@ public class AnalyzerFactoryTestCase {
public void testPunctuation1() { public void testPunctuation1() {
String query = "B︴C"; String query = "B︴C";
String expected1 = "b"; String expected = "b︴c";
String expected2 = "c";
/*
* XXX 3.x -> 8.x :
* The definition of punctuation has changed.
*/
Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> { Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> {
List<String> terms = toTermString(n, query); List<String> terms = toTermString(n, query);
switch (n) { switch (n) {
@ -100,9 +103,8 @@ public class AnalyzerFactoryTestCase {
case FieldNames.FOLDER: case FieldNames.FOLDER:
case FieldNames.GENRE: case FieldNames.GENRE:
case FieldNames.MEDIA_TYPE: case FieldNames.MEDIA_TYPE:
assertEquals("tokenized : " + n, 2, terms.size()); assertEquals("tokenized : " + n, 1, terms.size());
assertEquals("tokenized : " + n, expected1, terms.get(0)); assertEquals("tokenized : " + n, expected, terms.get(0));
assertEquals("tokenized : " + n, expected2, terms.get(1));
break; break;
/* /*
@ -112,9 +114,8 @@ public class AnalyzerFactoryTestCase {
case FieldNames.ARTIST: case FieldNames.ARTIST:
case FieldNames.ALBUM: case FieldNames.ALBUM:
case FieldNames.TITLE: case FieldNames.TITLE:
assertEquals("tokenized : " + n, 2, terms.size()); assertEquals("tokenized : " + n, 1, terms.size());
assertEquals("tokenized : " + n, expected1, terms.get(0)); assertEquals("tokenized : " + n, expected, terms.get(0));
assertEquals("tokenized : " + n, expected2, terms.get(1));
break; break;
/* /*
* ID, FOLDER_ID, YEAR * ID, FOLDER_ID, YEAR
@ -256,12 +257,17 @@ public class AnalyzerFactoryTestCase {
*/ */
String queryFullWidth = "THIS IS FULL-WIDTH SENTENCES."; String queryFullWidth = "THIS IS FULL-WIDTH SENTENCES.";
terms = toTermString(queryFullWidth); terms = toTermString(queryFullWidth);
assertEquals(5, terms.size()); /*
assertEquals("this", terms.get(0));// removal target is ignored * XXX 3.x -> 8.x :
assertEquals("is", terms.get(1)); *
assertEquals("full", terms.get(2)); * This is not a change due to the library but an intentional change.
assertEquals("width", terms.get(3)); * The filter order has been changed properly
assertEquals("sentences", terms.get(4)); * 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"; query = "_ID3_ARTIST_ Céline Frisch: Café Zimmermann";
terms = toTermString(query); terms = toTermString(query);
assertEquals(5, terms.size()); 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("id3_artist", terms.get(0));
assertEquals("celine", terms.get(1)); assertEquals("celine", terms.get(1));
assertEquals("frisch", terms.get(2)); assertEquals("frisch", terms.get(2));
@ -438,6 +449,11 @@ public class AnalyzerFactoryTestCase {
query = "_ID3_ARTIST_ Sarah Walker/Nash Ensemble"; query = "_ID3_ARTIST_ Sarah Walker/Nash Ensemble";
terms = toTermString(query); terms = toTermString(query);
assertEquals(5, terms.size()); 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("id3_artist", terms.get(0));
assertEquals("sarah", terms.get(1)); assertEquals("sarah", terms.get(1));
assertEquals("walker", terms.get(2)); 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?")); 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/")); 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&")); 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@")); // XXX 3.x -> 8.x : abc@def -> abc def
assertEquals(asList("abc'def"), toTermString("'ABC'DEF'")); assertEquals(asList("abc'def"), toTermString("'ABC'DEF'"));
// trim and delimiter and number // 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,")); 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-")); // XXX 3.x -> 8.x : abc1-def -> 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
/*
* 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.")); 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@")); 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("airsonic", terms.get(0));
assertEquals("analysis", terms.get(1)); assertEquals("analysis", terms.get(1));
/*
* XXX 3.x -> 8.x :
* we ve -> we've
*/
query = "We’ve been here before."; query = "We’ve been here before.";
terms = toTermString(query); terms = toTermString(query);
assertEquals(5, terms.size()); assertEquals(4, terms.size());
assertEquals("we", terms.get(0)); assertEquals("we've", terms.get(0));
assertEquals("ve", terms.get(1)); assertEquals("been", terms.get(1));
assertEquals("been", terms.get(2)); assertEquals("here", terms.get(2));
assertEquals("here", terms.get(3)); assertEquals("before", terms.get(3));
assertEquals("before", terms.get(4));
query = "LʼHomme"; query = "LʼHomme";
terms = toTermString(query); terms = toTermString(query);
@ -591,6 +618,21 @@ public class AnalyzerFactoryTestCase {
assertEquals("glasses", terms.get(5)); assertEquals("glasses", terms.get(5));
} }
@Test
public void testGenre() {
/*
* Confirming no conversion to singular.
*/
String query = "{}";
List<String> terms = toQueryTermString(FieldNames.GENRE, query);
assertEquals(1, terms.size());
assertEquals("{ }", terms.get(0));
}
private List<String> toTermString(String str) { private List<String> toTermString(String str) {
return toTermString(null, str); return toTermString(null, str);
} }
@ -602,7 +644,7 @@ public class AnalyzerFactoryTestCase {
new StringReader(str)); new StringReader(str));
stream.reset(); stream.reset();
while (stream.incrementToken()) { while (stream.incrementToken()) {
result.add(stream.getAttribute(TermAttribute.class).toString() result.add(stream.getAttribute(CharTermAttribute.class).toString()
.replaceAll("^term\\=", "")); .replaceAll("^term\\=", ""));
} }
stream.close(); stream.close();
@ -627,7 +669,7 @@ public class AnalyzerFactoryTestCase {
new StringReader(str)); new StringReader(str));
stream.reset(); stream.reset();
while (stream.incrementToken()) { while (stream.incrementToken()) {
result.add(stream.getAttribute(TermAttribute.class).toString() result.add(stream.getAttribute(CharTermAttribute.class).toString()
.replaceAll("^term\\=", "")); .replaceAll("^term\\=", ""));
} }
stream.close(); stream.close();

@ -8,9 +8,7 @@ import org.airsonic.player.domain.RandomSearchCriteria;
import org.airsonic.player.domain.SearchCriteria; import org.airsonic.player.domain.SearchCriteria;
import org.airsonic.player.util.HomeRule; import org.airsonic.player.util.HomeRule;
import org.apache.commons.lang.builder.ToStringBuilder; import org.apache.commons.lang.builder.ToStringBuilder;
import org.apache.lucene.queryParser.ParseException;
import org.apache.lucene.search.Query; import org.apache.lucene.search.Query;
import org.apache.lucene.util.NumericUtils;
import org.junit.ClassRule; import org.junit.ClassRule;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@ -77,8 +75,34 @@ public class QueryFactoryTestCase {
private static final List<MusicFolder> SINGLE_FOLDERS = Arrays.asList(MUSIC_FOLDER1); private static final List<MusicFolder> SINGLE_FOLDERS = Arrays.asList(MUSIC_FOLDER1);
private static final List<MusicFolder> MULTI_FOLDERS = Arrays.asList(MUSIC_FOLDER1, MUSIC_FOLDER2); private static final List<MusicFolder> 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 @Test
public void testSearchArtist() throws ParseException, IOException { public void testSearchArtist() throws IOException {
SearchCriteria criteria = new SearchCriteria(); SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10); criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE); criteria.setCount(Integer.MAX_VALUE);
@ -86,16 +110,16 @@ public class QueryFactoryTestCase {
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ARTIST); Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ARTIST);
assertEquals("SearchArtist", assertEquals("SearchArtist",
"+((artist:abc* folder:abc*) (artist:def* folder:def*)) +spanOr([folder:" + PATH1 + "])", "+((artist:abc*) (artist:def*)) +(folder:" + PATH1 + ")",
query.toString()); query.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ARTIST); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ARTIST);
assertEquals("SearchArtist", "+((artist:abc* folder:abc*) (artist:def* folder:def*)) +spanOr([folder:" + PATH1 assertEquals("SearchArtist", "+((artist:abc*) (artist:def*)) +(folder:" + PATH1
+ ", folder:" + PATH2 + "])", query.toString()); + " folder:" + PATH2 + ")", query.toString());
} }
@Test @Test
public void testSearchAlbum() throws ParseException, IOException { public void testSearchAlbum() throws IOException {
SearchCriteria criteria = new SearchCriteria(); SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10); criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE); criteria.setCount(Integer.MAX_VALUE);
@ -103,19 +127,19 @@ public class QueryFactoryTestCase {
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ALBUM); Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ALBUM);
assertEquals("SearchAlbum", 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.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ALBUM); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ALBUM);
assertEquals("SearchAlbum", 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
+ ", folder:" + PATH2 + "])", + " folder:" + PATH2 + ")",
query.toString()); query.toString());
} }
@Test @Test
public void testSearchSong() throws ParseException, IOException { public void testSearchSong() throws IOException {
SearchCriteria criteria = new SearchCriteria(); SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10); criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE); criteria.setCount(Integer.MAX_VALUE);
@ -123,34 +147,34 @@ public class QueryFactoryTestCase {
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.SONG); Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.SONG);
assertEquals("SearchSong", 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.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.SONG); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.SONG);
assertEquals("SearchSong", "+((title:abc* artist:abc*) (title:def* artist:def*)) +spanOr([folder:" + PATH1 assertEquals("SearchSong", "+((title:abc* artist:abc*) (title:def* artist:def*)) +(folder:" + PATH1
+ ", folder:" + PATH2 + "])", query.toString()); + " folder:" + PATH2 + ")", query.toString());
} }
@Test @Test
public void testSearchArtistId3() throws ParseException, IOException { public void testSearchArtistId3() throws IOException {
SearchCriteria criteria = new SearchCriteria(); SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10); criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE); criteria.setCount(Integer.MAX_VALUE);
criteria.setQuery(QUERY_ENG_ONLY); criteria.setQuery(QUERY_ENG_ONLY);
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ARTIST_ID3); Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ARTIST_ID3);
assertEquals("SearchSong", "+((artist:abc*) (artist:def*)) +spanOr([folderId:" assertEquals("SearchSong", "+((artist:abc*) (artist:def*)) +(folderId:"
+ NumericUtils.intToPrefixCoded(FID1) + "])", query.toString()); + FID1 + ")", query.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ARTIST_ID3); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ARTIST_ID3);
assertEquals("SearchSong", assertEquals("SearchSong",
"+((artist:abc*) (artist:def*)) +spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) "+((artist:abc*) (artist:def*)) +(folderId:" + FID1
+ ", folderId:" + NumericUtils.intToPrefixCoded(FID2) + "])", + " folderId:" + FID2 + ")",
query.toString()); query.toString());
} }
@Test @Test
public void testSearchAlbumId3() throws ParseException, IOException { public void testSearchAlbumId3() throws IOException {
SearchCriteria criteria = new SearchCriteria(); SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10); criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE); criteria.setCount(Integer.MAX_VALUE);
@ -158,74 +182,74 @@ public class QueryFactoryTestCase {
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ALBUM_ID3); Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ALBUM_ID3);
assertEquals( assertEquals(
"SearchAlbumId3", "+((album:abc* artist:abc* folderId:abc*) (album:def* artist:def* folderId:def*)) " "SearchAlbumId3", "+((album:abc* artist:abc*) (album:def* artist:def*)) "
+ "+spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + "])", + "+(folderId:" + FID1 + ")",
query.toString()); query.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ALBUM_ID3); query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ALBUM_ID3);
assertEquals("SearchAlbumId3", assertEquals("SearchAlbumId3",
"+((album:abc* artist:abc* folderId:abc*) (album:def* artist:def* folderId:def*)) +spanOr([folderId:" "+((album:abc* artist:abc*) (album:def* artist:def*)) +(folderId:"
+ NumericUtils.intToPrefixCoded(FID1) + ", folderId:" + FID1 + " folderId:"
+ NumericUtils.intToPrefixCoded(FID2) + "])", + FID2 + ")",
query.toString()); query.toString());
} }
@Test @Test
public void testSearchByNameArtist() throws ParseException { public void testSearchByNameArtist() throws IOException {
Query query = queryFactory.searchByName(FieldNames.ARTIST, QUERY_ENG_ONLY); Query query = queryFactory.searchByName(FieldNames.ARTIST, QUERY_ENG_ONLY);
assertEquals("SearchByNameArtist", "artist:abc artist:def*", query.toString()); assertEquals("SearchByNameArtist", "artist:abc artist:def*", query.toString());
} }
@Test @Test
public void testSearchByNameAlbum() throws ParseException { public void testSearchByNameAlbum() throws IOException {
Query query = queryFactory.searchByName(FieldNames.ALBUM, QUERY_ENG_ONLY); Query query = queryFactory.searchByName(FieldNames.ALBUM, QUERY_ENG_ONLY);
assertEquals("SearchByNameAlbum", "album:abc album:def*", query.toString()); assertEquals("SearchByNameAlbum", "album:abc album:def*", query.toString());
} }
@Test @Test
public void testSearchByNameTitle() throws ParseException { public void testSearchByNameTitle() throws IOException {
Query query = queryFactory.searchByName(FieldNames.TITLE, QUERY_ENG_ONLY); Query query = queryFactory.searchByName(FieldNames.TITLE, QUERY_ENG_ONLY);
assertEquals("SearchByNameTitle", "title:abc title:def*", query.toString()); assertEquals("SearchByNameTitle", "title:abc title:def*", query.toString());
} }
@Test @Test
public void testGetRandomSongs() { public void testGetRandomSongs() throws IOException {
RandomSearchCriteria criteria = new RandomSearchCriteria(50, "Classic Rock", RandomSearchCriteria criteria = new RandomSearchCriteria(50, "Classic Rock",
Integer.valueOf(1900), Integer.valueOf(2000), SINGLE_FOLDERS); Integer.valueOf(1900), Integer.valueOf(2000), SINGLE_FOLDERS);
Query query = queryFactory.getRandomSongs(criteria); Query query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(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()); query.toString());
criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900), criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900),
Integer.valueOf(2000), MULTI_FOLDERS); Integer.valueOf(2000), MULTI_FOLDERS);
query = queryFactory.getRandomSongs(criteria); query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(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()); query.toString());
criteria = new RandomSearchCriteria(50, "Classic Rock", null, null, MULTI_FOLDERS); criteria = new RandomSearchCriteria(50, "Classic Rock", null, null, MULTI_FOLDERS);
query = queryFactory.getRandomSongs(criteria); query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(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()); query.toString());
criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900), null, criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900), null,
MULTI_FOLDERS); MULTI_FOLDERS);
query = queryFactory.getRandomSongs(criteria); query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(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()); query.toString());
criteria = new RandomSearchCriteria(50, "Classic Rock", null, Integer.valueOf(2000), criteria = new RandomSearchCriteria(50, "Classic Rock", null, Integer.valueOf(2000),
MULTI_FOLDERS); MULTI_FOLDERS);
query = queryFactory.getRandomSongs(criteria); query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(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()); query.toString());
} }
@ -233,23 +257,23 @@ public class QueryFactoryTestCase {
public void testGetRandomAlbums() { public void testGetRandomAlbums() {
Query query = queryFactory.getRandomAlbums(SINGLE_FOLDERS); Query query = queryFactory.getRandomAlbums(SINGLE_FOLDERS);
assertEquals(ToStringBuilder.reflectionToString(SINGLE_FOLDERS), assertEquals(ToStringBuilder.reflectionToString(SINGLE_FOLDERS),
"spanOr([folder:" + PATH1 + "])", query.toString()); "(folder:" + PATH1 + ")", query.toString());
query = queryFactory.getRandomAlbums(MULTI_FOLDERS); query = queryFactory.getRandomAlbums(MULTI_FOLDERS);
assertEquals(ToStringBuilder.reflectionToString(MULTI_FOLDERS), assertEquals(ToStringBuilder.reflectionToString(MULTI_FOLDERS),
"spanOr([folder:" + PATH1 + ", folder:" + PATH2 + "])", query.toString()); "(folder:" + PATH1 + " folder:" + PATH2 + ")", query.toString());
} }
@Test @Test
public void testGetRandomAlbumsId3() { public void testGetRandomAlbumsId3() {
Query query = queryFactory.getRandomAlbumsId3(SINGLE_FOLDERS); Query query = queryFactory.getRandomAlbumsId3(SINGLE_FOLDERS);
assertEquals(ToStringBuilder.reflectionToString(SINGLE_FOLDERS), assertEquals(ToStringBuilder.reflectionToString(SINGLE_FOLDERS),
"spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + "])", query.toString()); "(folderId:" + FID1 + ")", query.toString());
query = queryFactory.getRandomAlbumsId3(MULTI_FOLDERS); query = queryFactory.getRandomAlbumsId3(MULTI_FOLDERS);
assertEquals(ToStringBuilder.reflectionToString(MULTI_FOLDERS), assertEquals(ToStringBuilder.reflectionToString(MULTI_FOLDERS),
"spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + ", folderId:" "(folderId:" + FID1 + " folderId:"
+ NumericUtils.intToPrefixCoded(FID2) + "])", + FID2 + ")",
query.toString()); query.toString());
} }

@ -52,103 +52,182 @@ public class SearchServiceSpecialGenreTestCase extends AbstractAirsonicHomeTest
* *
* Legacy can not search all these genres. * Legacy can not search all these genres.
* (Strictly speaking, the genre field is not created at index creation.) * (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 @Test
public void testQueryEscapeRequires() { public void testQueryEscapeRequires() {
List<MusicFolder> folders = getMusicFolders();
Function<String, RandomSearchCriteria> simpleStringCriteria = s -> Function<String, RandomSearchCriteria> simpleStringCriteria = s ->
new RandomSearchCriteria(Integer.MAX_VALUE, // count new RandomSearchCriteria(Integer.MAX_VALUE, // count
s, // genre, s, // genre,
null, // fromYear null, // fromYear
null, // toYear null, // toYear
folders // musicFolders getMusicFolders() // musicFolders
); );
List<MediaFile> songs = searchService.getRandomSongs(simpleStringCriteria.apply("+")); List<MediaFile> 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("-")); 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("&&")); 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());
songs = searchService.getRandomSongs(simpleStringCriteria.apply("+")); Assert.assertEquals("Query Escape Requires 3", songs.get(0).getTitle());
Assert.assertEquals(0, songs.size());
songs = searchService.getRandomSongs(simpleStringCriteria.apply("||")); 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 songs = searchService.getRandomSongs(simpleStringCriteria.apply(" ("));// space & bracket
Assert.assertEquals(0, songs.size()); Assert.assertEquals(0, songs.size());
songs = searchService.getRandomSongs(simpleStringCriteria.apply(")")); songs = searchService.getRandomSongs(simpleStringCriteria.apply(")"));
Assert.assertEquals(0, songs.size()); 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("{}")); 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("[]")); 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("^")); 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("\"")); 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("~")); 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("*")); 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("?")); 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(":")); 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("\\")); 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("/")); 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). * Jaudiotagger applies special treatment to bracket (FILE17).
* * XXX 3.x -> 8.x : Specification of genre search became more natural.
*/ */
@Test @Test
public void testBrackets() { public void testBrackets() {
List<MusicFolder> folders = getMusicFolders(); Function<String, RandomSearchCriteria> simpleStringCriteria = s ->
new RandomSearchCriteria(Integer.MAX_VALUE, // count
RandomSearchCriteria criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count s, // genre,
"-(GENRE)-", // genre,
null, // fromYear null, // fromYear
null, // toYear null, // toYear
folders // musicFolders getMusicFolders() // musicFolders
); );
List<MediaFile> songs = searchService.getRandomSongs(criteria); // -(GENRE)- is registered as genre of FILE17.
Assert.assertEquals(0, songs.size());
criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count
" genre", // genre,
null, // fromYear
null, // toYear
folders // musicFolders
);
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<MediaFile> songs = searchService.getRandomSongs(simpleStringCriteria.apply("-(GENRE)-"));
Assert.assertEquals(1, songs.size()); Assert.assertEquals(1, songs.size());
Assert.assertEquals("-GENRE -", songs.get(0).getGenre());
Assert.assertEquals("Consistency with Tag Parser 1", songs.get(0).getTitle()); 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("-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 @Test
public void testOthers() { public void testOthers() {
List<MusicFolder> folders = getMusicFolders(); Function<String, RandomSearchCriteria> simpleStringCriteria = s ->
new RandomSearchCriteria(Integer.MAX_VALUE, // count
RandomSearchCriteria criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count s, // genre,
"{'“『【【】】[︴○◎@ $〒→+]FULL-WIDTHCæsar's", // genre,
null, // fromYear
null, // toYear
folders // musicFolders
);
List<MediaFile> songs = searchService.getRandomSongs(criteria);
Assert.assertEquals(0, songs.size());
criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count
"widthcaesar", // genre,
null, // fromYear null, // fromYear
null, // toYear null, // toYear
folders // musicFolders getMusicFolders() // musicFolders
); );
songs = searchService.getRandomSongs(criteria); // XXX 3.x -> 8.x : Do the process more strictly.
List<MediaFile> songs = searchService
.getRandomSongs(simpleStringCriteria.apply("{'“『【【】】[︴○◎@ $〒→+]FULL-WIDTHCæsar's"));
Assert.assertEquals(1, songs.size());
Assert.assertEquals(1, songs.size()); Assert.assertEquals(1, songs.size());
Assert.assertEquals("Other special strings 1", songs.get(0).getTitle()); Assert.assertEquals("Other special strings 1", songs.get(0).getTitle());
Assert.assertEquals("{'“『【【】】[︴○◎@ $〒→+]FULL-WIDTHCæsar's", songs.get(0).getGenre()); 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());
} }
} }

@ -56,11 +56,23 @@ public class SearchServiceStartWithStopwardsTestCase extends AbstractAirsonicHom
criteria.setQuery("will"); criteria.setQuery("will");
SearchResult result = searchService.search(criteria, folders, IndexType.ARTIST_ID3); 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"); criteria.setQuery("the");
result = searchService.search(criteria, folders, IndexType.SONG); 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());
} }

@ -164,7 +164,12 @@ public class SearchServiceTestCase extends AbstractAirsonicHomeTest {
artistId3Result.getItems().size()); artistId3Result.getItems().size());
ParamSearchResult<Artist> artistResult = searchService.searchByName(query, 0, ParamSearchResult<Artist> artistResult = searchService.searchByName(query, 0,
Integer.MAX_VALUE, allMusicFolders, Artist.class); 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()); artistResult.getItems().size());
// *** testGetRandomSongs() *** // *** testGetRandomSongs() ***

Loading…
Cancel
Save