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. 173
      airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java
  5. 218
      airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java
  6. 270
      airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java
  7. 55
      airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java
  8. 286
      airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java
  9. 143
      airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java
  10. 24
      airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java
  11. 104
      airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java
  12. 110
      airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java
  13. 182
      airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceSpecialGenreTestCase.java
  14. 16
      airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceStartWithStopwardsTestCase.java
  15. 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);

@ -20,19 +20,25 @@
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.LowerCaseFilter; import org.apache.lucene.analysis.cjk.CJKWidthFilterFactory;
import org.apache.lucene.analysis.StopFilter; import org.apache.lucene.analysis.core.KeywordTokenizerFactory;
import org.apache.lucene.analysis.TokenStream; import org.apache.lucene.analysis.core.LowerCaseFilterFactory;
import org.apache.lucene.analysis.standard.StandardAnalyzer; import org.apache.lucene.analysis.core.StopFilterFactory;
import org.apache.lucene.analysis.standard.StandardFilter; import org.apache.lucene.analysis.custom.CustomAnalyzer;
import org.apache.lucene.analysis.standard.StandardTokenizer; import org.apache.lucene.analysis.custom.CustomAnalyzer.Builder;
import org.apache.lucene.util.Version; import org.apache.lucene.analysis.en.EnglishPossessiveFilterFactory;
import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilterFactory;
import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
import org.apache.lucene.analysis.pattern.PatternReplaceFilterFactory;
import org.apache.lucene.analysis.standard.StandardTokenizerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.IOException; import java.io.IOException;
import java.io.Reader; import java.util.HashMap;
import java.util.Map;
import static org.springframework.util.ObjectUtils.isEmpty;
/** /**
* Analyzer provider. * Analyzer provider.
@ -49,73 +55,124 @@ public final class AnalyzerFactory {
private Analyzer queryAnalyzer; private Analyzer queryAnalyzer;
/** /*
* Return analyzer. * XXX 3.x -> 8.x : Convert UAX#29 Underscore Analysis to Legacy Analysis
* *
* @return analyzer for index * Because changes in underscores before and after words
* have a major effect on user's forward match search.
*
* @see AnalyzerFactoryTestCase
*/ */
public Analyzer getAnalyzer() { private void addTokenFilterForUnderscoreRemovalAroundToken(Builder builder) throws IOException {
if (null == analyzer) { builder
analyzer = new CustomAnalyzer(); .addTokenFilter(PatternReplaceFilterFactory.class,
"pattern", "^\\_", "replacement", "", "replace", "all")
.addTokenFilter(PatternReplaceFilterFactory.class,
"pattern", "\\_$", "replacement", "", "replace", "all");
} }
return analyzer;
/*
* XXX 3.x -> 8.x : Handle brackets correctly
*
* Process the input value of Genre search for search of domain value.
*
* The tag parser performs special character conversion
* when converting input values from a file.
* Therefore, the domain value may be different from the original value.
* This filter allows searching by user readable value (file tag value).
*
* @see org.jaudiotagger.tag.id3.framebody.FrameBodyTCON#convertID3v23GenreToGeneric
* (TCON stands for Genre with ID3 v2.3-v2.4)
* Such processing exists because brackets in the Gener string have a special meaning.
*/
private void addTokenFilterForTokenToDomainValue(Builder builder) throws IOException {
builder
.addTokenFilter(PatternReplaceFilterFactory.class,
"pattern", "\\(", "replacement", "", "replace", "all")
.addTokenFilter(PatternReplaceFilterFactory.class,
"pattern", "\\)$", "replacement", "", "replace", "all")
.addTokenFilter(PatternReplaceFilterFactory.class,
"pattern", "\\)", "replacement", " ", "replace", "all")
.addTokenFilter(PatternReplaceFilterFactory.class,
"pattern", "\\{\\}", "replacement", "\\{ \\}", "replace", "all")
.addTokenFilter(PatternReplaceFilterFactory.class,
"pattern", "\\[\\]", "replacement", "\\[ \\]", "replace", "all");
}
private Builder createDefaultAnalyzerBuilder() throws IOException {
Builder builder = CustomAnalyzer.builder()
.withTokenizer(StandardTokenizerFactory.class)
.addTokenFilter(CJKWidthFilterFactory.class)
.addTokenFilter(ASCIIFoldingFilterFactory.class, "preserveOriginal", "false")
.addTokenFilter(LowerCaseFilterFactory.class)
.addTokenFilter(StopFilterFactory.class)
.addTokenFilter(EnglishPossessiveFilterFactory.class);
addTokenFilterForUnderscoreRemovalAroundToken(builder);
return builder;
}
private Builder createKeywordAnalyzerBuilder() throws IOException {
return CustomAnalyzer.builder()
.withTokenizer(KeywordTokenizerFactory.class);
}
private Builder createGenreAnalyzerBuilder() throws IOException {
Builder builder = createKeywordAnalyzerBuilder();
addTokenFilterForTokenToDomainValue(builder);
return builder;
} }
/** /**
* Return analyzer. * 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 * @return analyzer for index
* @see DocumentFactory
*/ */
public Analyzer getQueryAnalyzer() { public Analyzer getAnalyzer() throws IOException {
if (null == queryAnalyzer) { if (isEmpty(analyzer)) {
queryAnalyzer = new CustomAnalyzer(); try {
analyzer = createDefaultAnalyzerBuilder().build();
} catch (IOException e) {
throw new IOException("Error when initializing Analyzer.", e);
} }
return queryAnalyzer; }
return analyzer;
} }
/* /**
* The legacy CustomAnalyzer implementation is kept as it is. * Returns the analyzer to use when generating a query for index search.
*/ *
private class CustomAnalyzer extends StandardAnalyzer { * String processing handled by QueryFactory
private CustomAnalyzer() { * is limited to Lucene's modifier.
/* *
* Version.LUCENE_30 * The processing of the operands is expressed
* It is a transient description and will be deleted when upgrading the version. * in the AnalyzerFactory implementation.
* SearchService variables are not used because the reference direction conflicts. * Rules for tokenizing/converting input values
* should not be described in QueryFactory.
*
* @return analyzer for query
* @see QueryFactory
*/ */
super(Version.LUCENE_30); public Analyzer getQueryAnalyzer() throws IOException {
} if (isEmpty(queryAnalyzer)) {
try {
@Override Analyzer defaultAnalyzer = createDefaultAnalyzerBuilder().build();
public TokenStream tokenStream(String fieldName, Reader reader) { Analyzer genreAnalyzer = createGenreAnalyzerBuilder().build();
TokenStream result = super.tokenStream(fieldName, reader);
return new ASCIIFoldingFilter(result);
}
@Override Map<String, Analyzer> fieldAnalyzers = new HashMap<>();
public TokenStream reusableTokenStream(String fieldName, Reader reader) throws IOException { fieldAnalyzers.put(FieldNames.GENRE, genreAnalyzer);
class SavedStreams {
StandardTokenizer tokenStream;
TokenStream filteredTokenStream;
}
SavedStreams streams = (SavedStreams) getPreviousTokenStream(); queryAnalyzer = new PerFieldAnalyzerWrapper(defaultAnalyzer, fieldAnalyzers);
if (streams == null) {
streams = new SavedStreams();
setPreviousTokenStream(streams);
streams.tokenStream = new StandardTokenizer(Version.LUCENE_30, reader);
streams.filteredTokenStream = new StandardFilter(streams.tokenStream);
streams.filteredTokenStream = new LowerCaseFilter(streams.filteredTokenStream);
streams.filteredTokenStream = new StopFilter(true, streams.filteredTokenStream,
STOP_WORDS_SET);
streams.filteredTokenStream = new ASCIIFoldingFilter(streams.filteredTokenStream);
} else {
streams.tokenStream.reset(reader);
}
streams.tokenStream.setMaxTokenLength(DEFAULT_MAX_TOKEN_LENGTH);
return streams.filteredTokenStream; } catch (IOException e) {
throw new IOException("Error when initializing Analyzer.", e);
} }
} }
return queryAnalyzer;
}
} }

@ -25,27 +25,119 @@ 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.apache.lucene.document.IntPoint;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.Term;
import org.apache.lucene.util.BytesRef;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.function.BiConsumer;
import static org.springframework.util.ObjectUtils.isEmpty;
/** /**
* A factory that generates the documents to be stored in the index. * A factory that generates the documents to be stored in the index.
*/ */
@Component @Component
public class DocumentFactory { public class DocumentFactory {
/** private static final FieldType TYPE_ID;
* Normalize the genre string.
* private static final FieldType TYPE_ID_NO_STORE;
* @param genre genre string
* @return genre string normalized private static final FieldType TYPE_KEY;
* @deprecated should be resolved with tokenizer or filter
*/ static {
@Deprecated
private String normalizeGenre(String genre) { TYPE_ID = new FieldType();
return genre.toLowerCase().replace(" ", "").replace("-", ""); TYPE_ID.setIndexOptions(IndexOptions.DOCS);
TYPE_ID.setTokenized(false);
TYPE_ID.setOmitNorms(true);
TYPE_ID.setStored(true);
TYPE_ID.freeze();
TYPE_ID_NO_STORE = new FieldType();
TYPE_ID_NO_STORE.setIndexOptions(IndexOptions.DOCS);
TYPE_ID_NO_STORE.setTokenized(false);
TYPE_ID_NO_STORE.setOmitNorms(true);
TYPE_ID_NO_STORE.setStored(false);
TYPE_ID_NO_STORE.freeze();
TYPE_KEY = new FieldType();
TYPE_KEY.setIndexOptions(IndexOptions.DOCS);
TYPE_KEY.setTokenized(false);
TYPE_KEY.setOmitNorms(true);
TYPE_KEY.setStored(false);
TYPE_KEY.freeze();
}
@FunctionalInterface
private interface Consumer<T, U, V> {
void accept(T t, U u, V v);
}
;
private BiConsumer<@NonNull Document, @NonNull Integer> fieldId = (doc, value) -> {
doc.add(new StoredField(FieldNames.ID, Integer.toString(value), TYPE_ID));
};
private BiConsumer<@NonNull Document, @NonNull Integer> fieldFolderId = (doc, value) -> {
doc.add(new StoredField(FieldNames.FOLDER_ID, Integer.toString(value), TYPE_ID_NO_STORE));
};
private Consumer<@NonNull Document, @NonNull String, @NonNull String> fieldKey = (doc, field, value) -> {
doc.add(new StoredField(field, value, TYPE_KEY));
};
private BiConsumer<@NonNull Document, @NonNull String> fieldMediatype = (doc, value) ->
fieldKey.accept(doc, FieldNames.MEDIA_TYPE, value);
private BiConsumer<@NonNull Document, @NonNull String> fieldFolderPath = (doc, value) ->
fieldKey.accept(doc, FieldNames.FOLDER, value);
private BiConsumer<@NonNull Document, @Nullable String> fieldGenre = (doc, value) -> {
if (isEmpty(value)) {
return;
}
fieldKey.accept(doc, FieldNames.GENRE, value);
};
private Consumer<@NonNull Document, @NonNull String, @Nullable Integer> fieldYear = (doc, fieldName, value) -> {
if (isEmpty(value)) {
return;
} }
doc.add(new IntPoint(fieldName, value));
};
private Consumer<@NonNull Document, @NonNull String, @Nullable String> fieldWords = (doc, fieldName, value) -> {
if (isEmpty(value)) {
return;
}
doc.add(new TextField(fieldName, value, Store.NO));
doc.add(new SortedDocValuesField(fieldName, new BytesRef(value)));
};
public final Term createPrimarykey(Album album) {
return new Term(FieldNames.ID, Integer.toString(album.getId()));
};
public final Term createPrimarykey(Artist artist) {
return new Term(FieldNames.ID, Integer.toString(artist.getId()));
};
public final Term createPrimarykey(MediaFile mediaFile) {
return new Term(FieldNames.ID, Integer.toString(mediaFile.getId()));
};
/** /**
* Create a document. * Create a document.
@ -56,21 +148,10 @@ public class DocumentFactory {
*/ */
public Document createAlbumDocument(MediaFile mediaFile) { public Document createAlbumDocument(MediaFile mediaFile) {
Document doc = new Document(); Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) fieldId.accept(doc, mediaFile.getId());
.setIntValue(mediaFile.getId())); fieldWords.accept(doc, FieldNames.ARTIST, mediaFile.getArtist());
fieldWords.accept(doc, FieldNames.ALBUM, mediaFile.getAlbumName());
if (mediaFile.getArtist() != null) { fieldFolderPath.accept(doc, mediaFile.getFolder());
doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getAlbumName() != null) {
doc.add(new Field(FieldNames.ALBUM, mediaFile.getAlbumName(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO,
Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc; return doc;
} }
@ -83,17 +164,9 @@ public class DocumentFactory {
*/ */
public Document createArtistDocument(MediaFile mediaFile) { public Document createArtistDocument(MediaFile mediaFile) {
Document doc = new Document(); Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) fieldId.accept(doc, mediaFile.getId());
.setIntValue(mediaFile.getId())); fieldWords.accept(doc, FieldNames.ARTIST, mediaFile.getArtist());
fieldFolderPath.accept(doc, mediaFile.getFolder());
if (mediaFile.getArtist() != null) {
doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO,
Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc; return doc;
} }
@ -106,20 +179,10 @@ public class DocumentFactory {
*/ */
public Document createAlbumId3Document(Album album) { public Document createAlbumId3Document(Album album) {
Document doc = new Document(); Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false).setIntValue(album.getId())); fieldId.accept(doc, album.getId());
fieldWords.accept(doc, FieldNames.ARTIST, album.getArtist());
if (album.getArtist() != null) { fieldWords.accept(doc, FieldNames.ALBUM, album.getName());
doc.add(new Field(FieldNames.ARTIST, album.getArtist(), Field.Store.YES, fieldFolderId.accept(doc, album.getFolderId());
Field.Index.ANALYZED));
}
if (album.getName() != null) {
doc.add(new Field(FieldNames.ALBUM, album.getName(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (album.getFolderId() != null) {
doc.add(new NumericField(FieldNames.FOLDER_ID, Field.Store.NO, true)
.setIntValue(album.getFolderId()));
}
return doc; return doc;
} }
@ -131,14 +194,22 @@ public class DocumentFactory {
* @return document * @return document
* @since legacy * @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) { public Document createArtistId3Document(Artist artist, MusicFolder musicFolder) {
Document doc = new Document(); Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) fieldId.accept(doc, artist.getId());
.setIntValue(artist.getId())); fieldWords.accept(doc, FieldNames.ARTIST, artist.getName());
doc.add(new Field(FieldNames.ARTIST, artist.getName(), Field.Store.YES, fieldFolderId.accept(doc, musicFolder.getId());
Field.Index.ANALYZED));
doc.add(new NumericField(FieldNames.FOLDER_ID, Field.Store.NO, true)
.setIntValue(musicFolder.getId()));
return doc; return doc;
} }
@ -151,30 +222,13 @@ public class DocumentFactory {
*/ */
public Document createSongDocument(MediaFile mediaFile) { public Document createSongDocument(MediaFile mediaFile) {
Document doc = new Document(); Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false) fieldId.accept(doc, mediaFile.getId());
.setIntValue(mediaFile.getId())); fieldMediatype.accept(doc, mediaFile.getMediaType().name());
doc.add(new Field(FieldNames.MEDIA_TYPE, mediaFile.getMediaType().name(), Field.Store.NO, fieldWords.accept(doc, FieldNames.TITLE, mediaFile.getTitle());
Field.Index.ANALYZED_NO_NORMS)); fieldWords.accept(doc, FieldNames.ARTIST, mediaFile.getArtist());
if (mediaFile.getTitle() != null) { fieldGenre.accept(doc, mediaFile.getGenre());
doc.add(new Field(FieldNames.TITLE, mediaFile.getTitle(), Field.Store.YES, fieldYear.accept(doc, FieldNames.YEAR, mediaFile.getYear());
Field.Index.ANALYZED)); fieldFolderPath.accept(doc, mediaFile.getFolder());
}
if (mediaFile.getArtist() != null) {
doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getGenre() != null) {
doc.add(new Field(FieldNames.GENRE, normalizeGenre(mediaFile.getGenre()),
Field.Store.NO, Field.Index.ANALYZED));
}
if (mediaFile.getYear() != null) {
doc.add(new NumericField(FieldNames.YEAR, Field.Store.NO, true)
.setIntValue(mediaFile.getYear()));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO,
Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc; return doc;
} }

@ -26,9 +26,15 @@ 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.document.Document;
import org.apache.lucene.index.IndexWriter; import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.SearcherManager;
import org.apache.lucene.store.FSDirectory; import org.apache.lucene.store.FSDirectory;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -36,12 +42,14 @@ import org.springframework.stereotype.Component;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import static org.airsonic.player.service.search.IndexType.ALBUM; import static org.springframework.util.ObjectUtils.isEmpty;
import static org.airsonic.player.service.search.IndexType.ALBUM_ID3;
import static org.airsonic.player.service.search.IndexType.ARTIST;
import static org.airsonic.player.service.search.IndexType.ARTIST_ID3;
import static org.airsonic.player.service.search.IndexType.SONG;
/** /**
* Function class that is strongly linked to the lucene index implementation. * Function class that is strongly linked to the lucene index implementation.
@ -57,112 +65,258 @@ public class IndexManager {
private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class); private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class);
/**
* Schema version of Airsonic index.
* It may be incremented in the following cases:
*
* - Incompatible update case in Lucene index implementation
* - When schema definition is changed due to modification of AnalyzerFactory,
* DocumentFactory or the class that they use.
*
*/
private static final int INDEX_VERSION = 16;
/**
* Literal name of index top directory.
*/
private static final String INDEX_ROOT_DIR_NAME = "index";
/**
* File supplier for index directory.
*/
private Supplier<File> rootIndexDirectory = () ->
new File(SettingsService.getAirsonicHome(), INDEX_ROOT_DIR_NAME.concat(Integer.toString(INDEX_VERSION)));
/**
* Returns the directory of the specified index
*/
private Function<IndexType, File> getIndexDirectory = (indexType) ->
new File(rootIndexDirectory.get(), indexType.toString().toLowerCase());
@Autowired @Autowired
private AnalyzerFactory analyzerFactory; private AnalyzerFactory analyzerFactory;
@Autowired @Autowired
private DocumentFactory documentFactory; private DocumentFactory documentFactory;
private IndexWriter artistWriter; private Map<IndexType, SearcherManager> searchers = new HashMap<>();
private IndexWriter artistId3Writer;
private IndexWriter albumWriter; private Map<IndexType, IndexWriter> writers = new HashMap<>();
private IndexWriter albumId3Writer;
private IndexWriter songWriter;
public void index(Album album) { public void index(Album album) {
Term primarykey = documentFactory.createPrimarykey(album);
Document document = documentFactory.createAlbumId3Document(album);
try { try {
albumId3Writer.addDocument(documentFactory.createAlbumId3Document(album)); writers.get(IndexType.ALBUM_ID3).updateDocument(primarykey, document);
} catch (Exception x) { } catch (Exception x) {
LOG.error("Failed to create search index for " + album, x); LOG.error("Failed to create search index for " + album, x);
} }
} }
public void index(Artist artist, MusicFolder musicFolder) { public void index(Artist artist, MusicFolder musicFolder) {
Term primarykey = documentFactory.createPrimarykey(artist);
Document document = documentFactory.createArtistId3Document(artist, musicFolder);
try { try {
artistId3Writer writers.get(IndexType.ARTIST_ID3).updateDocument(primarykey, document);
.addDocument(documentFactory.createArtistId3Document(artist, musicFolder));
} catch (Exception x) { } catch (Exception x) {
LOG.error("Failed to create search index for " + artist, x); LOG.error("Failed to create search index for " + artist, x);
} }
} }
public void index(MediaFile mediaFile) { public void index(MediaFile mediaFile) {
Term primarykey = documentFactory.createPrimarykey(mediaFile);
try { try {
if (mediaFile.isFile()) { if (mediaFile.isFile()) {
songWriter.addDocument(documentFactory.createSongDocument(mediaFile)); Document document = documentFactory.createSongDocument(mediaFile);
writers.get(IndexType.SONG).updateDocument(primarykey, document);
} else if (mediaFile.isAlbum()) { } else if (mediaFile.isAlbum()) {
albumWriter.addDocument(documentFactory.createAlbumDocument(mediaFile)); Document document = documentFactory.createAlbumDocument(mediaFile);
writers.get(IndexType.ALBUM).updateDocument(primarykey, document);
} else { } else {
artistWriter.addDocument(documentFactory.createArtistDocument(mediaFile)); Document document = documentFactory.createArtistDocument(mediaFile);
writers.get(IndexType.ARTIST).updateDocument(primarykey, document);
} }
} catch (Exception x) { } catch (Exception x) {
LOG.error("Failed to create search index for " + mediaFile, x); LOG.error("Failed to create search index for " + mediaFile, x);
} }
} }
private static final String LUCENE_DIR = "lucene2"; public final void startIndexing() {
try {
public IndexReader createIndexReader(IndexType indexType) throws IOException { for (IndexType IndexType : IndexType.values()) {
File dir = getIndexDirectory(indexType); writers.put(IndexType, createIndexWriter(IndexType));
return IndexReader.open(FSDirectory.open(dir), true); }
} catch (IOException e) {
LOG.error("Failed to create search index.", e);
}
} }
/** /**
* It is static as an intermediate response of the transition period.
* (It is called before injection because it is called by SearchService constructor)
* *
* @param indexType
* @return * @return
* @throws IOException
*/
private IndexWriter createIndexWriter(IndexType indexType) throws IOException {
File indexDirectory = getIndexDirectory.apply(indexType);
IndexWriterConfig config = new IndexWriterConfig(analyzerFactory.getAnalyzer());
return new IndexWriter(FSDirectory.open(indexDirectory.toPath()), config);
}
/**
* Close Writer of all indexes and update SearcherManager.
* Called at the end of the Scan flow.
*/ */
private static File getIndexRootDirectory() { public void stopIndexing() {
return new File(SettingsService.getAirsonicHome(), LUCENE_DIR); Arrays.asList(IndexType.values()).forEach(this::stopIndexing);
} }
/** /**
* Make it public as an interim response of the transition period. * Close Writer of specified index and refresh SearcherManager.
* (It is called before the injection because it is called in the SearchService constructor.) * @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 * @param indexType
* @return * @return
* @deprecated It should not be called from outside.
*/ */
@Deprecated public @Nullable IndexSearcher getSearcher(IndexType indexType) {
public static File getIndexDirectory(IndexType indexType) { if (!searchers.containsKey(indexType)) {
return new File(getIndexRootDirectory(), indexType.toString().toLowerCase()); 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;
} }
private IndexWriter createIndexWriter(IndexType indexType) throws IOException { public void release(IndexType indexType, IndexSearcher indexSearcher) {
File dir = getIndexDirectory(indexType); if (searchers.containsKey(indexType)) {
return new IndexWriter(FSDirectory.open(dir), analyzerFactory.getAnalyzer(), true, try {
new IndexWriter.MaxFieldLength(10)); 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);
}
}
} }
public final void startIndexing() { /**
* 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 { try {
artistWriter = createIndexWriter(ARTIST); if (old.isFile()) {
artistId3Writer = createIndexWriter(ARTIST_ID3); FileUtils.deleteQuietly(old);
albumWriter = createIndexWriter(ALBUM); } else {
albumId3Writer = createIndexWriter(ALBUM_ID3); FileUtils.deleteDirectory(old);
songWriter = createIndexWriter(SONG);
} catch (Exception x) {
LOG.error("Failed to create search index.", x);
} }
} catch (IOException e) {
// Log only if failed
LOG.warn("Failed to delete the legacy Index : ".concat(old.getAbsolutePath()), e);
} }
}
});
public void stopIndexing() { // 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 { try {
artistWriter.optimize(); if (old.isFile()) {
artistId3Writer.optimize(); FileUtils.deleteQuietly(old);
albumWriter.optimize(); } else {
albumId3Writer.optimize(); FileUtils.deleteDirectory(old);
songWriter.optimize(); }
} catch (Exception x) { } catch (IOException e) {
LOG.error("Failed to create search index.", x); // Log only if failed
} finally { LOG.warn("Failed to delete the old Index : ".concat(old.getAbsolutePath()), e);
FileUtil.closeQuietly(artistId3Writer); }
FileUtil.closeQuietly(artistWriter); }
FileUtil.closeQuietly(albumWriter); });
FileUtil.closeQuietly(albumId3Writer);
FileUtil.closeQuietly(songWriter); }
/**
* 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);
}
} }
} }

@ -34,9 +34,6 @@ import java.util.Map;
*/ */
public enum IndexType { public enum IndexType {
/*
* Boosts is a factor for search scores, which is 1 by default.
*/
SONG( SONG(
fieldNames( fieldNames(
FieldNames.TITLE, FieldNames.TITLE,
@ -47,23 +44,23 @@ public enum IndexType {
ALBUM( ALBUM(
fieldNames( fieldNames(
FieldNames.ALBUM, FieldNames.ALBUM,
FieldNames.ARTIST, FieldNames.ARTIST),
FieldNames.FOLDER), // FieldNames.FOLDER), // XXX 3.x -> 8.x : Remove folder from multi-field search condition
boosts( boosts(
entry(FieldNames.ALBUM, 2F))), entry(FieldNames.ALBUM, 2F))),
ALBUM_ID3( ALBUM_ID3(
fieldNames( fieldNames(
FieldNames.ALBUM, FieldNames.ALBUM,
FieldNames.ARTIST, FieldNames.ARTIST),
FieldNames.FOLDER_ID), // FieldNames.FOLDER_ID), // XXX 3.x -> 8.x : Remove folder from multi-field search condition
boosts( boosts(
entry(FieldNames.ALBUM, 2F))), entry(FieldNames.ALBUM, 2F))),
ARTIST( ARTIST(
fieldNames( fieldNames(
FieldNames.ARTIST, FieldNames.ARTIST),
FieldNames.FOLDER), // FieldNames.FOLDER), // XXX 3.x -> 8.x : Remove folder from multi-field search condition
boosts( boosts(
entry(FieldNames.ARTIST, 1F))), entry(FieldNames.ARTIST, 1F))),
@ -72,8 +69,16 @@ public enum IndexType {
FieldNames.ARTIST), FieldNames.ARTIST),
boosts( boosts(
entry(FieldNames.ARTIST, 2F))), entry(FieldNames.ARTIST, 2F))),
; ;
/**
* Define the field's applied boost value when searching IndexType.
*
* @param entry {@link #entry(String, float)}.
* When specifying multiple values, enumerate entries.
* @return Map of boost values to be applied to the field
*/
@SafeVarargs @SafeVarargs
private static final Map<String, Float> boosts(SimpleEntry<String, Float>... entry) { private static final Map<String, Float> boosts(SimpleEntry<String, Float>... entry) {
Map<String, Float> m = new HashMap<>(); Map<String, Float> m = new HashMap<>();
@ -81,17 +86,25 @@ public enum IndexType {
return Collections.unmodifiableMap(m); return Collections.unmodifiableMap(m);
} }
/* /**
* The current state is implemented to set the same value as legacy. * Create an entry representing the boost value for the field.
* However unlike legacy, it has been changed *
* so that different values can be set for each field. * @param k Field name defined by FieldNames
* When setting two or more boost values, * @param v Boost value
* it is desirable to differentiate the values. * @return
*/ */
private static final SimpleEntry<String, Float> entry(String k, float v) { private static final SimpleEntry<String, Float> entry(String k, float v) {
return new AbstractMap.SimpleEntry<>(k, v); return new AbstractMap.SimpleEntry<>(k, v);
} }
/**
* Defines the field that the input value is to search for
* when searching IndexType.
* If you specify multiple values, list the field names.
*
* @param names
* @return
*/
private static final String[] fieldNames(String... names) { private static final String[] fieldNames(String... names) {
return Arrays.stream(names).toArray(String[]::new); return Arrays.stream(names).toArray(String[]::new);
} }
@ -108,11 +121,9 @@ public enum IndexType {
/** /**
* Returns a map of fields and boost values. * Returns a map of fields and boost values.
* *
* @return map of fields and boost values * @return Map of fields and boost values
* @since legacy * @since legacy
*/ * @see BoostQuery
/*
* See the lucene documentation for boost specifications.
*/ */
public Map<String, Float> getBoosts() { public Map<String, Float> getBoosts() {
return boosts; return boosts;
@ -124,12 +135,6 @@ public enum IndexType {
* @return Fields mainly used in multi-field search * @return Fields mainly used in multi-field search
* @since legacy * @since legacy
*/ */
/*
* It maintains a fairly early implementation
* and can be considered as an argument of MultiFieldQueryParser.
* In fact, the fields and boosts used in the search are difficult topics
* that can be determined by the search requirements.
*/
public String[] getFields() { public String[] getFields() {
return fields; return fields;
} }

@ -20,37 +20,32 @@
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.document.IntPoint;
import org.apache.lucene.index.Term; import org.apache.lucene.index.Term;
import org.apache.lucene.queryParser.MultiFieldQueryParser; import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.queryParser.ParseException;
import org.apache.lucene.queryParser.QueryParser;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.NumericRangeQuery;
import org.apache.lucene.search.Query; import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery; import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.spans.SpanOrQuery; import org.apache.lucene.search.WildcardQuery;
import org.apache.lucene.search.spans.SpanQuery; import org.checkerframework.checker.nullness.qual.NonNull;
import org.apache.lucene.search.spans.SpanTermQuery; import org.checkerframework.checker.nullness.qual.Nullable;
import org.apache.lucene.util.NumericUtils;
import org.apache.lucene.util.Version;
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 java.io.IOException; import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
import static org.airsonic.player.service.search.IndexType.ALBUM_ID3; import static org.springframework.util.ObjectUtils.isEmpty;
import static org.airsonic.player.service.search.IndexType.ARTIST_ID3;
/** /**
* Factory class of Lucene Query. * Factory class of Lucene Query.
@ -65,38 +60,101 @@ import static org.airsonic.player.service.search.IndexType.ARTIST_ID3;
@Component @Component
public class QueryFactory { public class QueryFactory {
private static final String ASTERISK = "*";
@Autowired @Autowired
private AnalyzerFactory analyzerFactory; private AnalyzerFactory analyzerFactory;
private String analyzeQuery(String query) throws IOException { private final Function<MusicFolder, Query> toFolderIdQuery = (folder) -> {
StringBuilder result = new StringBuilder(); // Unanalyzed field
return new TermQuery(new Term(FieldNames.FOLDER_ID, folder.getId().toString()));
};
private final Function<MusicFolder, Query> toFolderPathQuery = (folder) -> {
// Unanalyzed field
return new TermQuery(new Term(FieldNames.FOLDER, folder.getPath().getPath()));
};
/* /*
* Version.LUCENE_30 * XXX 3.x -> 8.x :
* It is a transient description and will be deleted when upgrading the version. * "SpanOr" has been changed to "Or".
* SearchService variables are not used because the reference direction conflicts. * - Path comparison is more appropriate with "Or".
* - If "SpanOr" is maintained, the DOC design needs to be changed.
*/ */
@SuppressWarnings("resource") private final BiFunction<@NonNull Boolean, @NonNull List<MusicFolder>, @NonNull Query> toFolderQuery = (
ASCIIFoldingFilter filter = new ASCIIFoldingFilter( isId3, folders) -> {
new StandardTokenizer(Version.LUCENE_30, new StringReader(query))); BooleanQuery.Builder mfQuery = new BooleanQuery.Builder();
TermAttribute termAttribute = filter.getAttribute(TermAttribute.class); folders.stream()
while (filter.incrementToken()) { .map(isId3 ? toFolderIdQuery : toFolderPathQuery)
result.append(termAttribute.term()).append("* "); .forEach(t -> mfQuery.add(t, Occur.SHOULD));
} return mfQuery.build();
return result.toString(); };
}
/** /*
* Normalize the genre string. * XXX 3.x -> 8.x :
* In order to support wildcards,
* MultiFieldQueryParser has been replaced by the following process.
* *
* @param genre genre string * - There is also an override of MultiFieldQueryParser, but it is known to be high cost.
* @return genre string normalized * - MultiFieldQueryParser was created before Java API was modernized.
* @deprecated should be resolved with tokenizer or filter * - The spec of Parser has changed from time to time. Using parser does not reduce library update risk.
* - Self made parser process reduces one library dependency.
* - It is easy to make corrections later when changing the query to improve search accuracy.
*/ */
@Deprecated private Query createMultiFieldWildQuery(@NonNull String[] fieldNames, @NonNull String queryString)
private String normalizeGenre(String genre) { throws IOException {
return genre.toLowerCase().replace(" ", "").replace("-", "");
BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
List<List<Query>> fieldsQuerys = new ArrayList<>();
Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
/* Wildcard applies to all tokens. **/
for (String fieldName : fieldNames) {
try (TokenStream stream = analyzer.tokenStream(fieldName, queryString)) {
stream.reset();
List<Query> fieldQuerys = new ArrayList<>();
while (stream.incrementToken()) {
String token = stream.getAttribute(CharTermAttribute.class).toString();
WildcardQuery wildcardQuery = new WildcardQuery(new Term(fieldName, token.concat(ASTERISK)));
fieldQuerys.add(wildcardQuery);
}
fieldsQuerys.add(fieldQuerys);
}
} }
/* If Field's Tokenizer is different, token's length may not match. **/
int maxTermLength = fieldsQuerys.stream()
.map(l -> l.size())
.max(Integer::compare).get();
if (0 < fieldsQuerys.size()) {
for (int i = 0; i < maxTermLength; i++) {
BooleanQuery.Builder fieldsQuery = new BooleanQuery.Builder();
for (List<Query> fieldQuerys : fieldsQuerys) {
if (i < fieldQuerys.size()) {
fieldsQuery.add(fieldQuerys.get(i), Occur.SHOULD);
}
}
mainQuery.add(fieldsQuery.build(), Occur.SHOULD);
}
}
return mainQuery.build();
};
/*
* XXX 3.x -> 8.x :
* RangeQuery has been changed to not allow null.
*/
private final BiFunction<@Nullable Integer, @Nullable Integer, @NonNull Query> toYearRangeQuery =
(from, to) -> {
return IntPoint.newRangeQuery(FieldNames.YEAR,
isEmpty(from) ? Integer.MIN_VALUE : from,
isEmpty(to) ? Integer.MAX_VALUE : to);
};
/** /**
* Query generation expression extracted from * Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}. * {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}.
@ -106,35 +164,21 @@ public class QueryFactory {
* @param indexType {@link IndexType} * @param indexType {@link IndexType}
* @return Query * @return Query
* @throws IOException When parsing of MultiFieldQueryParser fails * @throws IOException When parsing of MultiFieldQueryParser fails
* @throws ParseException When parsing of MultiFieldQueryParser fails
*/ */
public Query search(SearchCriteria criteria, List<MusicFolder> musicFolders, public Query search(SearchCriteria criteria, List<MusicFolder> musicFolders,
IndexType indexType) throws ParseException, IOException { IndexType indexType) throws IOException {
/*
* Version.LUCENE_30 BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
* It is a transient description and will be deleted when upgrading the version.
* SearchService variables are not used because the reference direction conflicts. Query multiFieldQuery = createMultiFieldWildQuery(indexType.getFields(), criteria.getQuery());
*/ mainQuery.add(multiFieldQuery, Occur.MUST);
MultiFieldQueryParser queryParser = new MultiFieldQueryParser(Version.LUCENE_30,
indexType.getFields(), analyzerFactory.getQueryAnalyzer(), indexType.getBoosts()); boolean isId3 = indexType == IndexType.ALBUM_ID3 || indexType == IndexType.ARTIST_ID3;
Query folderQuery = toFolderQuery.apply(isId3, musicFolders);
BooleanQuery query = new BooleanQuery(); mainQuery.add(folderQuery, Occur.MUST);
query.add(queryParser.parse(analyzeQuery(criteria.getQuery())), BooleanClause.Occur.MUST);
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); return mainQuery.build();
for (MusicFolder musicFolder : musicFolders) {
if (indexType == ALBUM_ID3 || indexType == ARTIST_ID3) {
musicFolderQueries.add(new SpanTermQuery(new Term(FieldNames.FOLDER_ID,
NumericUtils.intToPrefixCoded(musicFolder.getId()))));
} else {
musicFolderQueries.add(new SpanTermQuery(
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath())));
}
}
query.add(
new SpanOrQuery(
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])),
BooleanClause.Occur.MUST);
return query;
} }
/** /**
@ -143,32 +187,37 @@ public class QueryFactory {
* *
* @param criteria criteria * @param criteria criteria
* @return Query * @return Query
* @throws IOException
*/ */
public Query getRandomSongs(RandomSearchCriteria criteria) { public Query getRandomSongs(RandomSearchCriteria criteria) throws IOException {
BooleanQuery query = new BooleanQuery();
query.add(new TermQuery( BooleanQuery.Builder query = new BooleanQuery.Builder();
new Term(FieldNames.MEDIA_TYPE, MediaFile.MediaType.MUSIC.name().toLowerCase())),
BooleanClause.Occur.MUST); Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
if (criteria.getGenre() != null) {
String genre = normalizeGenre(criteria.getGenre()); // Unanalyzed field
query.add(new TermQuery(new Term(FieldNames.GENRE, genre)), BooleanClause.Occur.MUST); query.add(new TermQuery(new Term(FieldNames.MEDIA_TYPE, MediaType.MUSIC.name())), Occur.MUST);
if (!isEmpty(criteria.getGenre())) {
// Unanalyzed field, but performs filtering according to id3 tag parser.
try (TokenStream stream = analyzer.tokenStream(FieldNames.GENRE, criteria.getGenre())) {
stream.reset();
if (stream.incrementToken()) {
String token = stream.getAttribute(CharTermAttribute.class).toString();
query.add(new TermQuery(new Term(FieldNames.GENRE, token)), Occur.MUST);
}
} }
if (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);
} }
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); if (!(isEmpty(criteria.getFromYear()) && isEmpty(criteria.getToYear()))) {
for (MusicFolder musicFolder : criteria.getMusicFolders()) { query.add(toYearRangeQuery.apply(criteria.getFromYear(), criteria.getToYear()), Occur.MUST);
musicFolderQueries.add(new SpanTermQuery(
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath())));
} }
query.add(
new SpanOrQuery( query.add(toFolderQuery.apply(false, criteria.getMusicFolders()), Occur.MUST);
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])),
BooleanClause.Occur.MUST); return query.build();
return query;
} }
/** /**
@ -177,18 +226,40 @@ public class QueryFactory {
* *
* @param fieldName {@link FieldNames} * @param fieldName {@link FieldNames}
* @return Query * @return Query
* @throws ParseException When parsing of QueryParser fails * @throws IOException When parsing of QueryParser fails
*/ */
public Query searchByName(String fieldName, String name) throws ParseException { 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();
/* /*
* Version.LUCENE_30 * XXX 3.x -> 8.x :
* It is a transient description and will be deleted when upgrading the version. * In order to support wildcards,
* SearchService variables are not used because the reference direction conflicts. * QueryParser has been replaced by the following process.
*/ */
QueryParser queryParser = new QueryParser(Version.LUCENE_30, fieldName,
analyzerFactory.getQueryAnalyzer()); /* Wildcards apply only to tail tokens **/
Query query = queryParser.parse(name + "*"); while (true) {
return query; 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();
} }
/** /**
@ -199,14 +270,9 @@ public class QueryFactory {
* @return Query * @return Query
*/ */
public Query getRandomAlbums(List<MusicFolder> musicFolders) { public Query getRandomAlbums(List<MusicFolder> musicFolders) {
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); return new BooleanQuery.Builder()
for (MusicFolder musicFolder : musicFolders) { .add(toFolderQuery.apply(false, musicFolders), Occur.SHOULD)
musicFolderQueries.add(new SpanTermQuery( .build();
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath())));
}
Query query = new SpanOrQuery(
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()]));
return query;
} }
/** /**
@ -217,15 +283,9 @@ public class QueryFactory {
* @return Query * @return Query
*/ */
public Query getRandomAlbumsId3(List<MusicFolder> musicFolders) { public Query getRandomAlbumsId3(List<MusicFolder> musicFolders) {
return new BooleanQuery.Builder()
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); .add(toFolderQuery.apply(true, musicFolders), Occur.SHOULD)
for (MusicFolder musicFolder : musicFolders) { .build();
musicFolderQueries.add(new SpanTermQuery(new Term(FieldNames.FOLDER_ID,
NumericUtils.intToPrefixCoded(musicFolder.getId()))));
}
Query query = new SpanOrQuery(
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()]));
return query;
} }
} }

@ -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,15 +124,14 @@ 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);
try {
reader = indexManager.createIndexReader(SONG);
Searcher searcher = new IndexSearcher(reader);
if (isEmpty(searcher)) { if (isEmpty(searcher)) {
// At first start // At first start
return Collections.emptyList(); return Collections.emptyList();
} }
try {
Query query = queryFactory.getRandomSongs(criteria); Query query = queryFactory.getRandomSongs(criteria);
return createRandomDocsList(criteria.getCount(), searcher, query, return createRandomDocsList(criteria.getCount(), searcher, query,
(dist, id) -> util.addIgnoreNull(dist, SONG, id)); (dist, id) -> util.addIgnoreNull(dist, SONG, id));
@ -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);
try {
reader = indexManager.createIndexReader(ALBUM);
Searcher searcher = new IndexSearcher(reader);
if (isEmpty(searcher)) { if (isEmpty(searcher)) {
return Collections.emptyList(); return Collections.emptyList();
} }
Query query = queryFactory.getRandomAlbums(musicFolders); Query query = queryFactory.getRandomAlbums(musicFolders);
try {
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);
try {
reader = indexManager.createIndexReader(ALBUM_ID3);
Searcher searcher = new IndexSearcher(reader);
if (isEmpty(searcher)) { if (isEmpty(searcher)) {
return Collections.emptyList(); return Collections.emptyList();
} }
Query query = queryFactory.getRandomAlbumsId3(musicFolders); Query query = queryFactory.getRandomAlbumsId3(musicFolders);
try {
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);
}
}
}
} }

@ -28,19 +28,15 @@ 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.apache.lucene.index.Term;
import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.Nullable;
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 java.io.File;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function; import java.util.function.Function;
import static org.springframework.util.ObjectUtils.isEmpty; import static org.springframework.util.ObjectUtils.isEmpty;
@ -136,26 +132,6 @@ public class SearchServiceUtilities {
} }
}; };
private final Function<String, File> getRootDirectory = (version) -> {
return new File(SettingsService.getAirsonicHome(), version);
};
public final BiFunction<String, IndexType, File> getDirectory = (version, indexType) -> {
return new File(getRootDirectory.apply(version), indexType.toString().toLowerCase());
};
public final Term createPrimarykey(Album album) {
return new Term(FieldNames.ID, Integer.toString(album.getId()));
};
public final Term createPrimarykey(Artist artist) {
return new Term(FieldNames.ID, Integer.toString(artist.getId()));
};
public final Term createPrimarykey(MediaFile mediaFile) {
return new Term(FieldNames.ID, Integer.toString(mediaFile.getId()));
};
public final boolean addIgnoreNull(Collection<?> collection, Object object) { public final boolean addIgnoreNull(Collection<?> collection, Object object) {
return CollectionUtils.addIgnoreNull(collection, object); return CollectionUtils.addIgnoreNull(collection, object);
} }

@ -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, * Search by genre string registered in file.
null, // fromYear *
null, // toYear * The value stored in the index is different from legacy.
folders // musicFolders * Domain value is kept as it is.
); */
List<MediaFile> songs = searchService.getRandomSongs(simpleStringCriteria.apply("-(GENRE)-"));
songs = searchService.getRandomSongs(criteria);
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