My fork of airsonic with experimental fixes and improvements. See branch "custom"
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

288 lines
11 KiB

/*
This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/
package org.airsonic.player.service.search;
import org.airsonic.player.domain.MediaFile.MediaType;
import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.domain.RandomSearchCriteria;
import org.airsonic.player.domain.SearchCriteria;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.document.IntPoint;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.BoostQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.WildcardQuery;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
import static org.springframework.util.ObjectUtils.isEmpty;
/**
* Factory class of Lucene Query.
* This class is an extract of the functionality that was once part of SearchService.
* It is for maintainability and verification.
* Each corresponds to the SearchService method.
* The API syntax for query generation depends on the lucene version.
* verification with query grammar is possible.
* On the other hand, the generated queries are relatively small by version.
* Therefore, test cases of this class are useful for large version upgrades.
**/
@Component
public class QueryFactory {
private static final String ASTERISK = "*";
@Autowired
private AnalyzerFactory analyzerFactory;
private final Function<MusicFolder, Query> toFolderIdQuery = (folder) -> {
// 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()));
};
/*
* XXX 3.x -> 8.x :
* "SpanOr" has been changed to "Or".
* - Path comparison is more appropriate with "Or".
* - If "SpanOr" is maintained, the DOC design needs to be changed.
*/
private final BiFunction<@NonNull Boolean, @NonNull List<MusicFolder>, @NonNull Query> toFolderQuery = (
isId3, folders) -> {
BooleanQuery.Builder mfQuery = new BooleanQuery.Builder();
folders.stream()
.map(isId3 ? toFolderIdQuery : toFolderPathQuery)
.forEach(t -> mfQuery.add(t, Occur.SHOULD));
return mfQuery.build();
};
/*
* XXX 3.x -> 8.x :
* In order to support wildcards,
* MultiFieldQueryParser has been replaced by the following process.
*
* - There is also an override of MultiFieldQueryParser, but it is known to be high cost.
* - MultiFieldQueryParser was created before Java API was modernized.
* - The spec of Parser has changed from time to time. Using parser does not reduce library update risk.
* - Self made parser process reduces one library dependency.
* - It is easy to make corrections later when changing the query to improve search accuracy.
*/
private Query createMultiFieldWildQuery(@NonNull String[] fieldNames, @NonNull String queryString,
@NonNull IndexType indexType)
throws IOException {
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)));
if (indexType.getBoosts().containsKey(fieldName)) {
fieldQuerys.add(new BoostQuery(wildcardQuery, indexType.getBoosts().get(fieldName)));
} else {
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
* {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}.
*
* @param criteria criteria
* @param musicFolders musicFolders
* @param indexType {@link IndexType}
* @return Query
* @throws IOException When parsing of MultiFieldQueryParser fails
*/
public Query search(SearchCriteria criteria, List<MusicFolder> musicFolders,
IndexType indexType) throws IOException {
BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
Query multiFieldQuery = createMultiFieldWildQuery(indexType.getFields(), criteria.getQuery(), indexType);
mainQuery.add(multiFieldQuery, Occur.MUST);
boolean isId3 = indexType == IndexType.ALBUM_ID3 || indexType == IndexType.ARTIST_ID3;
Query folderQuery = toFolderQuery.apply(isId3, musicFolders);
mainQuery.add(folderQuery, Occur.MUST);
return mainQuery.build();
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#getRandomSongs(RandomSearchCriteria)}.
*/
public Query getRandomSongs(RandomSearchCriteria criteria) throws IOException {
BooleanQuery.Builder query = new BooleanQuery.Builder();
Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
// Unanalyzed field
query.add(new TermQuery(new Term(FieldNames.MEDIA_TYPE, MediaType.MUSIC.name())), Occur.MUST);
if (!isEmpty(criteria.getGenre())) {
// Unanalyzed field, but performs filtering according to id3 tag parser.
try (TokenStream stream = analyzer.tokenStream(FieldNames.GENRE, criteria.getGenre())) {
stream.reset();
if (stream.incrementToken()) {
String token = stream.getAttribute(CharTermAttribute.class).toString();
query.add(new TermQuery(new Term(FieldNames.GENRE, token)), Occur.MUST);
}
}
}
if (!(isEmpty(criteria.getFromYear()) && isEmpty(criteria.getToYear()))) {
query.add(toYearRangeQuery.apply(criteria.getFromYear(), criteria.getToYear()), Occur.MUST);
}
query.add(toFolderQuery.apply(false, criteria.getMusicFolders()), Occur.MUST);
return query.build();
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#searchByName( String, String, int, int, List, Class)}.
*
* @param fieldName {@link FieldNames}
* @return Query
* @throws IOException When parsing of QueryParser fails
*/
public Query searchByName(String fieldName, String name) throws IOException {
BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
try (TokenStream stream = analyzer.tokenStream(fieldName, name)) {
stream.reset();
stream.incrementToken();
/*
* XXX 3.x -> 8.x :
* In order to support wildcards,
* QueryParser has been replaced by the following process.
*/
/* Wildcards apply only to tail tokens **/
while (true) {
String token = stream.getAttribute(CharTermAttribute.class).toString();
if (stream.incrementToken()) {
mainQuery.add(new TermQuery(new Term(fieldName, token)), Occur.SHOULD);
} else {
WildcardQuery wildcardQuery = new WildcardQuery(new Term(fieldName, token.concat(ASTERISK)));
mainQuery.add(wildcardQuery, Occur.SHOULD);
break;
}
}
}
return mainQuery.build();
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#getRandomAlbums(int, List)}.
*
* @param musicFolders musicFolders
* @return Query
*/
public Query getRandomAlbums(List<MusicFolder> musicFolders) {
return new BooleanQuery.Builder()
.add(toFolderQuery.apply(false, musicFolders), Occur.SHOULD)
.build();
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#getRandomAlbumsId3(int, List)}.
*
* @param musicFolders musicFolders
* @return Query
*/
public Query getRandomAlbumsId3(List<MusicFolder> musicFolders) {
return new BooleanQuery.Builder()
.add(toFolderQuery.apply(true, musicFolders), Occur.SHOULD)
.build();
}
}