Split SearchService

Signed-off-by: Andrew DeMaria <lostonamountain@gmail.com>
master
tesshucom 6 years ago committed by Andrew DeMaria
parent 42bced139f
commit 767b39ed5b
No known key found for this signature in database
GPG Key ID: 0A3F5E91F8364EDF
  1. 5
      airsonic-main/pom.xml
  2. 7
      airsonic-main/src/main/java/org/airsonic/player/controller/SearchController.java
  3. 15
      airsonic-main/src/main/java/org/airsonic/player/controller/SubsonicRESTController.java
  4. 752
      airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java
  5. 9
      airsonic-main/src/main/java/org/airsonic/player/service/SonosService.java
  6. 121
      airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java
  7. 181
      airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java
  8. 95
      airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java
  9. 169
      airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java
  10. 138
      airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java
  11. 231
      airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java
  12. 298
      airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java
  13. 200
      airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java
  14. 3
      airsonic-main/src/main/java/org/airsonic/player/service/sonos/SonosHelper.java
  15. 2
      airsonic-main/src/main/resources/applicationContext-sonos.xml
  16. 574
      airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java
  17. 256
      airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java
  18. 466
      airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java

@ -155,7 +155,10 @@
</exclusions> </exclusions>
</dependency> </dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
</dependency>
<dependency> <dependency>
<groupId>commons-fileupload</groupId> <groupId>commons-fileupload</groupId>

@ -25,6 +25,7 @@ import org.airsonic.player.service.PlayerService;
import org.airsonic.player.service.SearchService; import org.airsonic.player.service.SearchService;
import org.airsonic.player.service.SecurityService; import org.airsonic.player.service.SecurityService;
import org.airsonic.player.service.SettingsService; import org.airsonic.player.service.SettingsService;
import org.airsonic.player.service.search.IndexType;
import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
@ -87,13 +88,13 @@ public class SearchController {
criteria.setCount(MATCH_COUNT); criteria.setCount(MATCH_COUNT);
criteria.setQuery(query); criteria.setQuery(query);
SearchResult artists = searchService.search(criteria, musicFolders, SearchService.IndexType.ARTIST); SearchResult artists = searchService.search(criteria, musicFolders, IndexType.ARTIST);
command.setArtists(artists.getMediaFiles()); command.setArtists(artists.getMediaFiles());
SearchResult albums = searchService.search(criteria, musicFolders, SearchService.IndexType.ALBUM); SearchResult albums = searchService.search(criteria, musicFolders, IndexType.ALBUM);
command.setAlbums(albums.getMediaFiles()); command.setAlbums(albums.getMediaFiles());
SearchResult songs = searchService.search(criteria, musicFolders, SearchService.IndexType.SONG); SearchResult songs = searchService.search(criteria, musicFolders, IndexType.SONG);
command.setSongs(songs.getMediaFiles()); command.setSongs(songs.getMediaFiles());
command.setPlayer(playerService.getPlayer(request, response)); command.setPlayer(playerService.getPlayer(request, response));

@ -32,6 +32,7 @@ import org.airsonic.player.domain.Bookmark;
import org.airsonic.player.domain.PlayQueue; import org.airsonic.player.domain.PlayQueue;
import org.airsonic.player.i18n.LocaleResolver; import org.airsonic.player.i18n.LocaleResolver;
import org.airsonic.player.service.*; import org.airsonic.player.service.*;
import org.airsonic.player.service.search.IndexType;
import org.airsonic.player.util.Pair; import org.airsonic.player.util.Pair;
import org.airsonic.player.util.StringUtil; import org.airsonic.player.util.StringUtil;
import org.airsonic.player.util.Util; import org.airsonic.player.util.Util;
@ -685,7 +686,7 @@ public class SubsonicRESTController {
criteria.setOffset(getIntParameter(request, "offset", 0)); criteria.setOffset(getIntParameter(request, "offset", 0));
List<org.airsonic.player.domain.MusicFolder> musicFolders = settingsService.getMusicFoldersForUser(username); List<org.airsonic.player.domain.MusicFolder> musicFolders = settingsService.getMusicFoldersForUser(username);
org.airsonic.player.domain.SearchResult result = searchService.search(criteria, musicFolders, SearchService.IndexType.SONG); org.airsonic.player.domain.SearchResult result = searchService.search(criteria, musicFolders, IndexType.SONG);
org.subsonic.restapi.SearchResult searchResult = new org.subsonic.restapi.SearchResult(); org.subsonic.restapi.SearchResult searchResult = new org.subsonic.restapi.SearchResult();
searchResult.setOffset(result.getOffset()); searchResult.setOffset(result.getOffset());
searchResult.setTotalHits(result.getTotalHits()); searchResult.setTotalHits(result.getTotalHits());
@ -713,21 +714,21 @@ public class SubsonicRESTController {
criteria.setQuery(StringUtils.trimToEmpty(query)); criteria.setQuery(StringUtils.trimToEmpty(query));
criteria.setCount(getIntParameter(request, "artistCount", 20)); criteria.setCount(getIntParameter(request, "artistCount", 20));
criteria.setOffset(getIntParameter(request, "artistOffset", 0)); criteria.setOffset(getIntParameter(request, "artistOffset", 0));
org.airsonic.player.domain.SearchResult artists = searchService.search(criteria, musicFolders, SearchService.IndexType.ARTIST); org.airsonic.player.domain.SearchResult artists = searchService.search(criteria, musicFolders, IndexType.ARTIST);
for (MediaFile mediaFile : artists.getMediaFiles()) { for (MediaFile mediaFile : artists.getMediaFiles()) {
searchResult.getArtist().add(createJaxbArtist(mediaFile, username)); searchResult.getArtist().add(createJaxbArtist(mediaFile, username));
} }
criteria.setCount(getIntParameter(request, "albumCount", 20)); criteria.setCount(getIntParameter(request, "albumCount", 20));
criteria.setOffset(getIntParameter(request, "albumOffset", 0)); criteria.setOffset(getIntParameter(request, "albumOffset", 0));
org.airsonic.player.domain.SearchResult albums = searchService.search(criteria, musicFolders, SearchService.IndexType.ALBUM); org.airsonic.player.domain.SearchResult albums = searchService.search(criteria, musicFolders, IndexType.ALBUM);
for (MediaFile mediaFile : albums.getMediaFiles()) { for (MediaFile mediaFile : albums.getMediaFiles()) {
searchResult.getAlbum().add(createJaxbChild(player, mediaFile, username)); searchResult.getAlbum().add(createJaxbChild(player, mediaFile, username));
} }
criteria.setCount(getIntParameter(request, "songCount", 20)); criteria.setCount(getIntParameter(request, "songCount", 20));
criteria.setOffset(getIntParameter(request, "songOffset", 0)); criteria.setOffset(getIntParameter(request, "songOffset", 0));
org.airsonic.player.domain.SearchResult songs = searchService.search(criteria, musicFolders, SearchService.IndexType.SONG); org.airsonic.player.domain.SearchResult songs = searchService.search(criteria, musicFolders, IndexType.SONG);
for (MediaFile mediaFile : songs.getMediaFiles()) { for (MediaFile mediaFile : songs.getMediaFiles()) {
searchResult.getSong().add(createJaxbChild(player, mediaFile, username)); searchResult.getSong().add(createJaxbChild(player, mediaFile, username));
} }
@ -752,21 +753,21 @@ public class SubsonicRESTController {
criteria.setQuery(StringUtils.trimToEmpty(query)); criteria.setQuery(StringUtils.trimToEmpty(query));
criteria.setCount(getIntParameter(request, "artistCount", 20)); criteria.setCount(getIntParameter(request, "artistCount", 20));
criteria.setOffset(getIntParameter(request, "artistOffset", 0)); criteria.setOffset(getIntParameter(request, "artistOffset", 0));
org.airsonic.player.domain.SearchResult result = searchService.search(criteria, musicFolders, SearchService.IndexType.ARTIST_ID3); org.airsonic.player.domain.SearchResult result = searchService.search(criteria, musicFolders, IndexType.ARTIST_ID3);
for (org.airsonic.player.domain.Artist artist : result.getArtists()) { for (org.airsonic.player.domain.Artist artist : result.getArtists()) {
searchResult.getArtist().add(createJaxbArtist(new ArtistID3(), artist, username)); searchResult.getArtist().add(createJaxbArtist(new ArtistID3(), artist, username));
} }
criteria.setCount(getIntParameter(request, "albumCount", 20)); criteria.setCount(getIntParameter(request, "albumCount", 20));
criteria.setOffset(getIntParameter(request, "albumOffset", 0)); criteria.setOffset(getIntParameter(request, "albumOffset", 0));
result = searchService.search(criteria, musicFolders, SearchService.IndexType.ALBUM_ID3); result = searchService.search(criteria, musicFolders, IndexType.ALBUM_ID3);
for (Album album : result.getAlbums()) { for (Album album : result.getAlbums()) {
searchResult.getAlbum().add(createJaxbAlbum(new AlbumID3(), album, username)); searchResult.getAlbum().add(createJaxbAlbum(new AlbumID3(), album, username));
} }
criteria.setCount(getIntParameter(request, "songCount", 20)); criteria.setCount(getIntParameter(request, "songCount", 20));
criteria.setOffset(getIntParameter(request, "songOffset", 0)); criteria.setOffset(getIntParameter(request, "songOffset", 0));
result = searchService.search(criteria, musicFolders, SearchService.IndexType.SONG); result = searchService.search(criteria, musicFolders, IndexType.SONG);
for (MediaFile song : result.getMediaFiles()) { for (MediaFile song : result.getMediaFiles()) {
searchResult.getSong().add(createJaxbChild(player, song, username)); searchResult.getSong().add(createJaxbChild(player, song, username));
} }

@ -1,666 +1,86 @@
/* /*
This file is part of Airsonic. This file is part of Airsonic.
Airsonic is free software: you can redistribute it and/or modify Airsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
Airsonic is distributed in the hope that it will be useful, Airsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with Airsonic. If not, see <http://www.gnu.org/licenses/>. along with Airsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2016 (C) Airsonic Authors Copyright 2016 (C) Airsonic Authors
Based upon Subsonic, Copyright 2009 (C) Sindre Mehus Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
*/ */
package org.airsonic.player.service;
package org.airsonic.player.service;
import com.google.common.collect.Lists;
import org.airsonic.player.dao.AlbumDao; import org.airsonic.player.domain.Album;
import org.airsonic.player.dao.ArtistDao; import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.*; import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.util.FileUtil; import org.airsonic.player.domain.MusicFolder;
import org.apache.lucene.analysis.*; import org.airsonic.player.domain.ParamSearchResult;
import org.apache.lucene.analysis.standard.StandardAnalyzer; import org.airsonic.player.domain.RandomSearchCriteria;
import org.apache.lucene.analysis.standard.StandardFilter; import org.airsonic.player.domain.SearchCriteria;
import org.apache.lucene.analysis.standard.StandardTokenizer; import org.airsonic.player.domain.SearchResult;
import org.apache.lucene.analysis.tokenattributes.TermAttribute; import org.airsonic.player.service.search.IndexType;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field; import java.util.List;
import org.apache.lucene.document.NumericField;
import org.apache.lucene.index.IndexReader; /**
import org.apache.lucene.index.IndexWriter; * Performs Lucene-based searching and indexing.
import org.apache.lucene.index.Term; *
import org.apache.lucene.queryParser.MultiFieldQueryParser; * @author Sindre Mehus
import org.apache.lucene.queryParser.QueryParser; * @version $Id$
import org.apache.lucene.search.*; * @see MediaScannerService
import org.apache.lucene.search.spans.SpanOrQuery; */
import org.apache.lucene.search.spans.SpanQuery; public interface SearchService {
import org.apache.lucene.search.spans.SpanTermQuery;
import org.apache.lucene.store.Directory; void startIndexing();
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.NumericUtils; void index(MediaFile mediaFile);
import org.apache.lucene.util.Version;
import org.slf4j.Logger; void index(Artist artist, MusicFolder musicFolder);
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; void index(Album album);
import org.springframework.stereotype.Service;
void stopIndexing();
import java.io.File;
import java.io.IOException; SearchResult search(SearchCriteria criteria, List<MusicFolder> musicFolders,
import java.io.Reader; IndexType indexType);
import java.io.StringReader;
import java.util.*; /**
* Returns a number of random songs.
import static org.airsonic.player.service.SearchService.IndexType.*; *
* @param criteria Search criteria.
/** * @return List of random songs.
* Performs Lucene-based searching and indexing. */
* List<MediaFile> getRandomSongs(RandomSearchCriteria criteria);
* @author Sindre Mehus
* @version $Id$ /**
* @see MediaScannerService * Returns a number of random albums.
*/ *
@Service * @param count Number of albums to return.
public class SearchService { * @param musicFolders Only return albums from these folders.
* @return List of random albums.
private static final Logger LOG = LoggerFactory.getLogger(SearchService.class); */
List<MediaFile> getRandomAlbums(int count, List<MusicFolder> musicFolders);
private static final String FIELD_ID = "id";
private static final String FIELD_TITLE = "title"; /**
private static final String FIELD_ALBUM = "album"; * Returns a number of random albums, using ID3 tag.
private static final String FIELD_ARTIST = "artist"; *
private static final String FIELD_GENRE = "genre"; * @param count Number of albums to return.
private static final String FIELD_YEAR = "year"; * @param musicFolders Only return albums from these folders.
private static final String FIELD_MEDIA_TYPE = "mediaType"; * @return List of random albums.
private static final String FIELD_FOLDER = "folder"; */
private static final String FIELD_FOLDER_ID = "folderId"; List<Album> getRandomAlbumsId3(int count, List<MusicFolder> musicFolders);
private static final Version LUCENE_VERSION = Version.LUCENE_30; <T> ParamSearchResult<T> searchByName(
private static final String LUCENE_DIR = "lucene2"; String name, int offset, int count, List<MusicFolder> folderList, Class<T> clazz);
@Autowired }
private MediaFileService mediaFileService;
@Autowired
private ArtistDao artistDao;
@Autowired
private AlbumDao albumDao;
private IndexWriter artistWriter;
private IndexWriter artistId3Writer;
private IndexWriter albumWriter;
private IndexWriter albumId3Writer;
private IndexWriter songWriter;
public SearchService() {
removeLocks();
}
public void startIndexing() {
try {
artistWriter = createIndexWriter(ARTIST);
artistId3Writer = createIndexWriter(ARTIST_ID3);
albumWriter = createIndexWriter(ALBUM);
albumId3Writer = createIndexWriter(ALBUM_ID3);
songWriter = createIndexWriter(SONG);
} catch (Exception x) {
LOG.error("Failed to create search index.", x);
}
}
public void index(MediaFile mediaFile) {
try {
if (mediaFile.isFile()) {
songWriter.addDocument(SONG.createDocument(mediaFile));
} else if (mediaFile.isAlbum()) {
albumWriter.addDocument(ALBUM.createDocument(mediaFile));
} else {
artistWriter.addDocument(ARTIST.createDocument(mediaFile));
}
} catch (Exception x) {
LOG.error("Failed to create search index for " + mediaFile, x);
}
}
public void index(Artist artist, MusicFolder musicFolder) {
try {
artistId3Writer.addDocument(ARTIST_ID3.createDocument(artist, musicFolder));
} catch (Exception x) {
LOG.error("Failed to create search index for " + artist, x);
}
}
public void index(Album album) {
try {
albumId3Writer.addDocument(ALBUM_ID3.createDocument(album));
} catch (Exception x) {
LOG.error("Failed to create search index for " + album, x);
}
}
public void stopIndexing() {
try {
artistWriter.optimize();
artistId3Writer.optimize();
albumWriter.optimize();
albumId3Writer.optimize();
songWriter.optimize();
} catch (Exception x) {
LOG.error("Failed to create search index.", x);
} finally {
FileUtil.closeQuietly(artistId3Writer);
FileUtil.closeQuietly(artistWriter);
FileUtil.closeQuietly(albumWriter);
FileUtil.closeQuietly(albumId3Writer);
FileUtil.closeQuietly(songWriter);
}
}
public SearchResult search(SearchCriteria criteria, List<MusicFolder> musicFolders, IndexType indexType) {
SearchResult result = new SearchResult();
int offset = criteria.getOffset();
int count = criteria.getCount();
result.setOffset(offset);
if (count <= 0) return result;
IndexReader reader = null;
try {
reader = createIndexReader(indexType);
Searcher searcher = new IndexSearcher(reader);
Analyzer analyzer = new CustomAnalyzer();
MultiFieldQueryParser queryParser = new MultiFieldQueryParser(LUCENE_VERSION, indexType.getFields(), analyzer, indexType.getBoosts());
BooleanQuery query = new BooleanQuery();
query.add(queryParser.parse(analyzeQuery(criteria.getQuery())), BooleanClause.Occur.MUST);
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : musicFolders) {
if (indexType == ALBUM_ID3 || indexType == ARTIST_ID3) {
musicFolderQueries.add(new SpanTermQuery(new Term(FIELD_FOLDER_ID, NumericUtils.intToPrefixCoded(musicFolder.getId()))));
} else {
musicFolderQueries.add(new SpanTermQuery(new Term(FIELD_FOLDER, musicFolder.getPath().getPath())));
}
}
query.add(new SpanOrQuery(musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])), BooleanClause.Occur.MUST);
TopDocs topDocs = searcher.search(query, null, offset + count);
result.setTotalHits(topDocs.totalHits);
int start = Math.min(offset, topDocs.totalHits);
int end = Math.min(start + count, topDocs.totalHits);
for (int i = start; i < end; i++) {
Document doc = searcher.doc(topDocs.scoreDocs[i].doc);
switch (indexType) {
case SONG:
case ARTIST:
case ALBUM:
MediaFile mediaFile = mediaFileService.getMediaFile(Integer.valueOf(doc.get(FIELD_ID)));
addIfNotNull(mediaFile, result.getMediaFiles());
break;
case ARTIST_ID3:
Artist artist = artistDao.getArtist(Integer.valueOf(doc.get(FIELD_ID)));
addIfNotNull(artist, result.getArtists());
break;
case ALBUM_ID3:
Album album = albumDao.getAlbum(Integer.valueOf(doc.get(FIELD_ID)));
addIfNotNull(album, result.getAlbums());
break;
default:
break;
}
}
} catch (Throwable x) {
LOG.error("Failed to execute Lucene search.", x);
} finally {
FileUtil.closeQuietly(reader);
}
return result;
}
private String analyzeQuery(String query) throws IOException {
StringBuilder result = new StringBuilder();
ASCIIFoldingFilter filter = new ASCIIFoldingFilter(new StandardTokenizer(LUCENE_VERSION, new StringReader(query)));
TermAttribute termAttribute = filter.getAttribute(TermAttribute.class);
while (filter.incrementToken()) {
result.append(termAttribute.term()).append("* ");
}
return result.toString();
}
/**
* Returns a number of random songs.
*
* @param criteria Search criteria.
* @return List of random songs.
*/
public List<MediaFile> getRandomSongs(RandomSearchCriteria criteria) {
List<MediaFile> result = new ArrayList<MediaFile>();
IndexReader reader = null;
try {
reader = createIndexReader(SONG);
Searcher searcher = new IndexSearcher(reader);
BooleanQuery query = new BooleanQuery();
query.add(new TermQuery(new Term(FIELD_MEDIA_TYPE, MediaFile.MediaType.MUSIC.name().toLowerCase())), BooleanClause.Occur.MUST);
if (criteria.getGenre() != null) {
String genre = normalizeGenre(criteria.getGenre());
query.add(new TermQuery(new Term(FIELD_GENRE, genre)), BooleanClause.Occur.MUST);
}
if (criteria.getFromYear() != null || criteria.getToYear() != null) {
NumericRangeQuery<Integer> rangeQuery = NumericRangeQuery.newIntRange(FIELD_YEAR, criteria.getFromYear(), criteria.getToYear(), true, true);
query.add(rangeQuery, BooleanClause.Occur.MUST);
}
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : criteria.getMusicFolders()) {
musicFolderQueries.add(new SpanTermQuery(new Term(FIELD_FOLDER, musicFolder.getPath().getPath())));
}
query.add(new SpanOrQuery(musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])), BooleanClause.Occur.MUST);
TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE);
List<ScoreDoc> scoreDocs = Lists.newArrayList(topDocs.scoreDocs);
Random random = new Random(System.currentTimeMillis());
while (!scoreDocs.isEmpty() && result.size() < criteria.getCount()) {
int index = random.nextInt(scoreDocs.size());
Document doc = searcher.doc(scoreDocs.remove(index).doc);
int id = Integer.valueOf(doc.get(FIELD_ID));
try {
addIfNotNull(mediaFileService.getMediaFile(id), result);
} catch (Exception x) {
LOG.warn("Failed to get media file " + id);
}
}
} catch (Throwable x) {
LOG.error("Failed to search or random songs.", x);
} finally {
FileUtil.closeQuietly(reader);
}
return result;
}
private static String normalizeGenre(String genre) {
return genre.toLowerCase().replace(" ", "").replace("-", "");
}
/**
* Returns a number of random albums.
*
* @param count Number of albums to return.
* @param musicFolders Only return albums from these folders.
* @return List of random albums.
*/
public List<MediaFile> getRandomAlbums(int count, List<MusicFolder> musicFolders) {
List<MediaFile> result = new ArrayList<MediaFile>();
IndexReader reader = null;
try {
reader = createIndexReader(ALBUM);
Searcher searcher = new IndexSearcher(reader);
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : musicFolders) {
musicFolderQueries.add(new SpanTermQuery(new Term(FIELD_FOLDER, musicFolder.getPath().getPath())));
}
Query query = new SpanOrQuery(musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()]));
TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE);
List<ScoreDoc> scoreDocs = Lists.newArrayList(topDocs.scoreDocs);
Random random = new Random(System.currentTimeMillis());
while (!scoreDocs.isEmpty() && result.size() < count) {
int index = random.nextInt(scoreDocs.size());
Document doc = searcher.doc(scoreDocs.remove(index).doc);
int id = Integer.valueOf(doc.get(FIELD_ID));
try {
addIfNotNull(mediaFileService.getMediaFile(id), result);
} catch (Exception x) {
LOG.warn("Failed to get media file " + id, x);
}
}
} catch (Throwable x) {
LOG.error("Failed to search for random albums.", x);
} finally {
FileUtil.closeQuietly(reader);
}
return result;
}
/**
* Returns a number of random albums, using ID3 tag.
*
* @param count Number of albums to return.
* @param musicFolders Only return albums from these folders.
* @return List of random albums.
*/
public List<Album> getRandomAlbumsId3(int count, List<MusicFolder> musicFolders) {
List<Album> result = new ArrayList<Album>();
IndexReader reader = null;
try {
reader = createIndexReader(ALBUM_ID3);
Searcher searcher = new IndexSearcher(reader);
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : musicFolders) {
musicFolderQueries.add(new SpanTermQuery(new Term(FIELD_FOLDER_ID, NumericUtils.intToPrefixCoded(musicFolder.getId()))));
}
Query query = new SpanOrQuery(musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()]));
TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE);
List<ScoreDoc> scoreDocs = Lists.newArrayList(topDocs.scoreDocs);
Random random = new Random(System.currentTimeMillis());
while (!scoreDocs.isEmpty() && result.size() < count) {
int index = random.nextInt(scoreDocs.size());
Document doc = searcher.doc(scoreDocs.remove(index).doc);
int id = Integer.valueOf(doc.get(FIELD_ID));
try {
addIfNotNull(albumDao.getAlbum(id), result);
} catch (Exception x) {
LOG.warn("Failed to get album file " + id, x);
}
}
} catch (Throwable x) {
LOG.error("Failed to search for random albums.", x);
} finally {
FileUtil.closeQuietly(reader);
}
return result;
}
public <T> ParamSearchResult<T> searchByName(String name, int offset, int count, List<MusicFolder> folderList, Class<T> clazz) {
IndexType indexType = null;
String field = null;
if (clazz.isAssignableFrom(Album.class)) {
indexType = IndexType.ALBUM_ID3;
field = FIELD_ALBUM;
} else if (clazz.isAssignableFrom(Artist.class)) {
indexType = IndexType.ARTIST_ID3;
field = FIELD_ARTIST;
} else if (clazz.isAssignableFrom(MediaFile.class)) {
indexType = IndexType.SONG;
field = FIELD_TITLE;
}
ParamSearchResult<T> result = new ParamSearchResult<T>();
// we only support album, artist, and song for now
if (indexType == null || field == null) {
return result;
}
result.setOffset(offset);
IndexReader reader = null;
try {
reader = createIndexReader(indexType);
Searcher searcher = new IndexSearcher(reader);
Analyzer analyzer = new CustomAnalyzer();
QueryParser queryParser = new QueryParser(LUCENE_VERSION, field, analyzer);
Query q = queryParser.parse(name + "*");
Sort sort = new Sort(new SortField(field, SortField.STRING));
TopDocs topDocs = searcher.search(q, null, offset + count, sort);
result.setTotalHits(topDocs.totalHits);
int start = Math.min(offset, topDocs.totalHits);
int end = Math.min(start + count, topDocs.totalHits);
for (int i = start; i < end; i++) {
Document doc = searcher.doc(topDocs.scoreDocs[i].doc);
switch (indexType) {
case SONG:
MediaFile mediaFile = mediaFileService.getMediaFile(Integer.valueOf(doc.get(FIELD_ID)));
addIfNotNull(clazz.cast(mediaFile), result.getItems());
break;
case ARTIST_ID3:
Artist artist = artistDao.getArtist(Integer.valueOf(doc.get(FIELD_ID)));
addIfNotNull(clazz.cast(artist), result.getItems());
break;
case ALBUM_ID3:
Album album = albumDao.getAlbum(Integer.valueOf(doc.get(FIELD_ID)));
addIfNotNull(clazz.cast(album), result.getItems());
break;
default:
break;
}
}
} catch (Throwable x) {
LOG.error("Failed to execute Lucene search.", x);
} finally {
FileUtil.closeQuietly(reader);
}
return result;
}
private <T> void addIfNotNull(T value, List<T> list) {
if (value != null) {
list.add(value);
}
}
private IndexWriter createIndexWriter(IndexType indexType) throws IOException {
File dir = getIndexDirectory(indexType);
return new IndexWriter(FSDirectory.open(dir), new CustomAnalyzer(), true, new IndexWriter.MaxFieldLength(10));
}
private IndexReader createIndexReader(IndexType indexType) throws IOException {
File dir = getIndexDirectory(indexType);
return IndexReader.open(FSDirectory.open(dir), true);
}
private File getIndexRootDirectory() {
return new File(SettingsService.getAirsonicHome(), LUCENE_DIR);
}
private File getIndexDirectory(IndexType indexType) {
return new File(getIndexRootDirectory(), indexType.toString().toLowerCase());
}
private void removeLocks() {
for (IndexType indexType : IndexType.values()) {
Directory dir = null;
try {
dir = FSDirectory.open(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);
}
}
}
public void setMediaFileService(MediaFileService mediaFileService) {
this.mediaFileService = mediaFileService;
}
public void setArtistDao(ArtistDao artistDao) {
this.artistDao = artistDao;
}
public void setAlbumDao(AlbumDao albumDao) {
this.albumDao = albumDao;
}
public static enum IndexType {
SONG(new String[]{FIELD_TITLE, FIELD_ARTIST}, FIELD_TITLE) {
@Override
public Document createDocument(MediaFile mediaFile) {
Document doc = new Document();
doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId()));
doc.add(new Field(FIELD_MEDIA_TYPE, mediaFile.getMediaType().name(), Field.Store.NO, Field.Index.ANALYZED_NO_NORMS));
if (mediaFile.getTitle() != null) {
doc.add(new Field(FIELD_TITLE, mediaFile.getTitle(), Field.Store.YES, Field.Index.ANALYZED));
}
if (mediaFile.getArtist() != null) {
doc.add(new Field(FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED));
}
if (mediaFile.getGenre() != null) {
doc.add(new Field(FIELD_GENRE, normalizeGenre(mediaFile.getGenre()), Field.Store.NO, Field.Index.ANALYZED));
}
if (mediaFile.getYear() != null) {
doc.add(new NumericField(FIELD_YEAR, Field.Store.NO, true).setIntValue(mediaFile.getYear()));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FIELD_FOLDER, mediaFile.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc;
}
},
ALBUM(new String[]{FIELD_ALBUM, FIELD_ARTIST, FIELD_FOLDER}, FIELD_ALBUM) {
@Override
public Document createDocument(MediaFile mediaFile) {
Document doc = new Document();
doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId()));
if (mediaFile.getArtist() != null) {
doc.add(new Field(FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED));
}
if (mediaFile.getAlbumName() != null) {
doc.add(new Field(FIELD_ALBUM, mediaFile.getAlbumName(), Field.Store.YES, Field.Index.ANALYZED));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FIELD_FOLDER, mediaFile.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc;
}
},
ALBUM_ID3(new String[]{FIELD_ALBUM, FIELD_ARTIST, FIELD_FOLDER_ID}, FIELD_ALBUM) {
@Override
public Document createDocument(Album album) {
Document doc = new Document();
doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(album.getId()));
if (album.getArtist() != null) {
doc.add(new Field(FIELD_ARTIST, album.getArtist(), Field.Store.YES, Field.Index.ANALYZED));
}
if (album.getName() != null) {
doc.add(new Field(FIELD_ALBUM, album.getName(), Field.Store.YES, Field.Index.ANALYZED));
}
if (album.getFolderId() != null) {
doc.add(new NumericField(FIELD_FOLDER_ID, Field.Store.NO, true).setIntValue(album.getFolderId()));
}
return doc;
}
},
ARTIST(new String[]{FIELD_ARTIST, FIELD_FOLDER}, null) {
@Override
public Document createDocument(MediaFile mediaFile) {
Document doc = new Document();
doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId()));
if (mediaFile.getArtist() != null) {
doc.add(new Field(FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FIELD_FOLDER, mediaFile.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc;
}
},
ARTIST_ID3(new String[]{FIELD_ARTIST}, null) {
@Override
public Document createDocument(Artist artist, MusicFolder musicFolder) {
Document doc = new Document();
doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(artist.getId()));
doc.add(new Field(FIELD_ARTIST, artist.getName(), Field.Store.YES, Field.Index.ANALYZED));
doc.add(new NumericField(FIELD_FOLDER_ID, Field.Store.NO, true).setIntValue(musicFolder.getId()));
return doc;
}
};
private final String[] fields;
private final Map<String, Float> boosts;
private IndexType(String[] fields, String boostedField) {
this.fields = fields;
boosts = new HashMap<String, Float>();
if (boostedField != null) {
boosts.put(boostedField, 2.0F);
}
}
public String[] getFields() {
return fields;
}
protected Document createDocument(MediaFile mediaFile) {
throw new UnsupportedOperationException();
}
protected Document createDocument(Artist artist, MusicFolder musicFolder) {
throw new UnsupportedOperationException();
}
protected Document createDocument(Album album) {
throw new UnsupportedOperationException();
}
public Map<String, Float> getBoosts() {
return boosts;
}
}
private class CustomAnalyzer extends StandardAnalyzer {
private CustomAnalyzer() {
super(LUCENE_VERSION);
}
@Override
public TokenStream tokenStream(String fieldName, Reader reader) {
TokenStream result = super.tokenStream(fieldName, reader);
return new ASCIIFoldingFilter(result);
}
@Override
public TokenStream reusableTokenStream(String fieldName, Reader reader) throws IOException {
class SavedStreams {
StandardTokenizer tokenStream;
TokenStream filteredTokenStream;
}
SavedStreams streams = (SavedStreams) getPreviousTokenStream();
if (streams == null) {
streams = new SavedStreams();
setPreviousTokenStream(streams);
streams.tokenStream = new StandardTokenizer(LUCENE_VERSION, 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;
}
}
}

@ -25,6 +25,7 @@ import org.airsonic.player.domain.AlbumListType;
import org.airsonic.player.domain.MediaFile; import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.Playlist; import org.airsonic.player.domain.Playlist;
import org.airsonic.player.domain.User; import org.airsonic.player.domain.User;
import org.airsonic.player.service.search.IndexType;
import org.airsonic.player.service.sonos.SonosHelper; import org.airsonic.player.service.sonos.SonosHelper;
import org.airsonic.player.service.sonos.SonosServiceRegistration; import org.airsonic.player.service.sonos.SonosServiceRegistration;
import org.airsonic.player.service.sonos.SonosSoapFault; import org.airsonic.player.service.sonos.SonosSoapFault;
@ -264,13 +265,13 @@ public class SonosService implements SonosSoap {
public SearchResponse search(Search parameters) { public SearchResponse search(Search parameters) {
String id = parameters.getId(); String id = parameters.getId();
SearchService.IndexType indexType; IndexType indexType;
if (ID_SEARCH_ARTISTS.equals(id)) { if (ID_SEARCH_ARTISTS.equals(id)) {
indexType = SearchService.IndexType.ARTIST; indexType = IndexType.ARTIST;
} else if (ID_SEARCH_ALBUMS.equals(id)) { } else if (ID_SEARCH_ALBUMS.equals(id)) {
indexType = SearchService.IndexType.ALBUM; indexType = IndexType.ALBUM;
} else if (ID_SEARCH_SONGS.equals(id)) { } else if (ID_SEARCH_SONGS.equals(id)) {
indexType = SearchService.IndexType.SONG; indexType = IndexType.SONG;
} else { } else {
throw new IllegalArgumentException("Invalid search category: " + id); throw new IllegalArgumentException("Invalid search category: " + id);
} }

@ -0,0 +1,121 @@
/*
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.apache.lucene.analysis.ASCIIFoldingFilter;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.LowerCaseFilter;
import org.apache.lucene.analysis.StopFilter;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.analysis.standard.StandardFilter;
import org.apache.lucene.analysis.standard.StandardTokenizer;
import org.apache.lucene.util.Version;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.Reader;
/**
* Analyzer provider.
* This class is a division of what was once part of SearchService and added functionality.
* This class provides Analyzer which is used at index generation
* and QueryAnalyzer which analyzes the specified query at search time.
* Analyzer can be closed but is a reuse premise.
* It is held in this class.
*/
@Component
public final class AnalyzerFactory {
private Analyzer analyzer;
private Analyzer queryAnalyzer;
/**
* Return analyzer.
*
* @return analyzer for index
*/
public Analyzer getAnalyzer() {
if (null == analyzer) {
analyzer = new CustomAnalyzer();
}
return analyzer;
}
/**
* Return analyzer.
*
* @return analyzer for index
*/
public Analyzer getQueryAnalyzer() {
if (null == queryAnalyzer) {
queryAnalyzer = new CustomAnalyzer();
}
return queryAnalyzer;
}
/*
* The legacy CustomAnalyzer implementation is kept as it is.
*/
private class CustomAnalyzer extends StandardAnalyzer {
private CustomAnalyzer() {
/*
* Version.LUCENE_30
* It is a transient description and will be deleted when upgrading the version.
* SearchService variables are not used because the reference direction conflicts.
*/
super(Version.LUCENE_30);
}
@Override
public TokenStream tokenStream(String fieldName, Reader reader) {
TokenStream result = super.tokenStream(fieldName, reader);
return new ASCIIFoldingFilter(result);
}
@Override
public TokenStream reusableTokenStream(String fieldName, Reader reader) throws IOException {
class SavedStreams {
StandardTokenizer tokenStream;
TokenStream filteredTokenStream;
}
SavedStreams streams = (SavedStreams) getPreviousTokenStream();
if (streams == null) {
streams = new SavedStreams();
setPreviousTokenStream(streams);
streams.tokenStream = new StandardTokenizer(Version.LUCENE_30, reader);
streams.filteredTokenStream = new StandardFilter(streams.tokenStream);
streams.filteredTokenStream = new LowerCaseFilter(streams.filteredTokenStream);
streams.filteredTokenStream = new StopFilter(true, streams.filteredTokenStream,
STOP_WORDS_SET);
streams.filteredTokenStream = new ASCIIFoldingFilter(streams.filteredTokenStream);
} else {
streams.tokenStream.reset(reader);
}
streams.tokenStream.setMaxTokenLength(DEFAULT_MAX_TOKEN_LENGTH);
return streams.filteredTokenStream;
}
}
}

@ -0,0 +1,181 @@
/*
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.Album;
import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.MusicFolder;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericField;
import org.springframework.stereotype.Component;
/**
* A factory that generates the documents to be stored in the index.
*/
@Component
public class DocumentFactory {
/**
* Normalize the genre string.
*
* @param genre genre string
* @return genre string normalized
* @deprecated should be resolved with tokenizer or filter
*/
@Deprecated
private String normalizeGenre(String genre) {
return genre.toLowerCase().replace(" ", "").replace("-", "");
}
/**
* Create a document.
*
* @param mediaFile target of document
* @return document
* @since legacy
*/
public Document createAlbumDocument(MediaFile mediaFile) {
Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false)
.setIntValue(mediaFile.getId()));
if (mediaFile.getArtist() != null) {
doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getAlbumName() != null) {
doc.add(new Field(FieldNames.ALBUM, mediaFile.getAlbumName(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO,
Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc;
}
/**
* Create a document.
*
* @param mediaFile target of document
* @return document
* @since legacy
*/
public Document createArtistDocument(MediaFile mediaFile) {
Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false)
.setIntValue(mediaFile.getId()));
if (mediaFile.getArtist() != null) {
doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO,
Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc;
}
/**
* Create a document.
*
* @param album target of document
* @return document
* @since legacy
*/
public Document createAlbumId3Document(Album album) {
Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false).setIntValue(album.getId()));
if (album.getArtist() != null) {
doc.add(new Field(FieldNames.ARTIST, album.getArtist(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (album.getName() != null) {
doc.add(new Field(FieldNames.ALBUM, album.getName(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (album.getFolderId() != null) {
doc.add(new NumericField(FieldNames.FOLDER_ID, Field.Store.NO, true)
.setIntValue(album.getFolderId()));
}
return doc;
}
/**
* Create a document.
*
* @param artist target of document
* @param musicFolder target folder exists
* @return document
* @since legacy
*/
public Document createArtistId3Document(Artist artist, MusicFolder musicFolder) {
Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false)
.setIntValue(artist.getId()));
doc.add(new Field(FieldNames.ARTIST, artist.getName(), Field.Store.YES,
Field.Index.ANALYZED));
doc.add(new NumericField(FieldNames.FOLDER_ID, Field.Store.NO, true)
.setIntValue(musicFolder.getId()));
return doc;
}
/**
* Create a document.
*
* @param mediaFile target of document
* @return document
* @since legacy
*/
public Document createSongDocument(MediaFile mediaFile) {
Document doc = new Document();
doc.add(new NumericField(FieldNames.ID, Field.Store.YES, false)
.setIntValue(mediaFile.getId()));
doc.add(new Field(FieldNames.MEDIA_TYPE, mediaFile.getMediaType().name(), Field.Store.NO,
Field.Index.ANALYZED_NO_NORMS));
if (mediaFile.getTitle() != null) {
doc.add(new Field(FieldNames.TITLE, mediaFile.getTitle(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getArtist() != null) {
doc.add(new Field(FieldNames.ARTIST, mediaFile.getArtist(), Field.Store.YES,
Field.Index.ANALYZED));
}
if (mediaFile.getGenre() != null) {
doc.add(new Field(FieldNames.GENRE, normalizeGenre(mediaFile.getGenre()),
Field.Store.NO, Field.Index.ANALYZED));
}
if (mediaFile.getYear() != null) {
doc.add(new NumericField(FieldNames.YEAR, Field.Store.NO, true)
.setIntValue(mediaFile.getYear()));
}
if (mediaFile.getFolder() != null) {
doc.add(new Field(FieldNames.FOLDER, mediaFile.getFolder(), Field.Store.NO,
Field.Index.NOT_ANALYZED_NO_NORMS));
}
return doc;
}
}

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

@ -0,0 +1,169 @@
/*
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.Album;
import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.service.SettingsService;
import org.airsonic.player.util.FileUtil;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.store.FSDirectory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import static org.airsonic.player.service.search.IndexType.ALBUM;
import static org.airsonic.player.service.search.IndexType.ALBUM_ID3;
import static org.airsonic.player.service.search.IndexType.ARTIST;
import static org.airsonic.player.service.search.IndexType.ARTIST_ID3;
import static org.airsonic.player.service.search.IndexType.SONG;
/**
* Function class that is strongly linked to the lucene index implementation.
* Legacy has an implementation in SearchService.
*
* If the index CRUD and search functionality are in the same class,
* there is often a dependency conflict on the class used.
* Although the interface of SearchService is left to maintain the legacy implementation,
* it is desirable that methods of index operations other than search essentially use this class directly.
*/
@Component
public class IndexManager {
private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class);
@Autowired
private AnalyzerFactory analyzerFactory;
@Autowired
private DocumentFactory documentFactory;
private IndexWriter artistWriter;
private IndexWriter artistId3Writer;
private IndexWriter albumWriter;
private IndexWriter albumId3Writer;
private IndexWriter songWriter;
public void index(Album album) {
try {
albumId3Writer.addDocument(documentFactory.createAlbumId3Document(album));
} catch (Exception x) {
LOG.error("Failed to create search index for " + album, x);
}
}
public void index(Artist artist, MusicFolder musicFolder) {
try {
artistId3Writer
.addDocument(documentFactory.createArtistId3Document(artist, musicFolder));
} catch (Exception x) {
LOG.error("Failed to create search index for " + artist, x);
}
}
public void index(MediaFile mediaFile) {
try {
if (mediaFile.isFile()) {
songWriter.addDocument(documentFactory.createSongDocument(mediaFile));
} else if (mediaFile.isAlbum()) {
albumWriter.addDocument(documentFactory.createAlbumDocument(mediaFile));
} else {
artistWriter.addDocument(documentFactory.createArtistDocument(mediaFile));
}
} catch (Exception x) {
LOG.error("Failed to create search index for " + mediaFile, x);
}
}
private static final String LUCENE_DIR = "lucene2";
public IndexReader createIndexReader(IndexType indexType) throws IOException {
File dir = getIndexDirectory(indexType);
return IndexReader.open(FSDirectory.open(dir), true);
}
/**
* It is static as an intermediate response of the transition period.
* (It is called before injection because it is called by SearchService constructor)
*
* @return
*/
private static File getIndexRootDirectory() {
return new File(SettingsService.getAirsonicHome(), LUCENE_DIR);
}
/**
* Make it public as an interim response of the transition period.
* (It is called before the injection because it is called in the SearchService constructor.)
*
* @param indexType
* @return
* @deprecated It should not be called from outside.
*/
@Deprecated
public static File getIndexDirectory(IndexType indexType) {
return new File(getIndexRootDirectory(), indexType.toString().toLowerCase());
}
private IndexWriter createIndexWriter(IndexType indexType) throws IOException {
File dir = getIndexDirectory(indexType);
return new IndexWriter(FSDirectory.open(dir), analyzerFactory.getAnalyzer(), true,
new IndexWriter.MaxFieldLength(10));
}
public final void startIndexing() {
try {
artistWriter = createIndexWriter(ARTIST);
artistId3Writer = createIndexWriter(ARTIST_ID3);
albumWriter = createIndexWriter(ALBUM);
albumId3Writer = createIndexWriter(ALBUM_ID3);
songWriter = createIndexWriter(SONG);
} catch (Exception x) {
LOG.error("Failed to create search index.", x);
}
}
public void stopIndexing() {
try {
artistWriter.optimize();
artistId3Writer.optimize();
albumWriter.optimize();
albumId3Writer.optimize();
songWriter.optimize();
} catch (Exception x) {
LOG.error("Failed to create search index.", x);
} finally {
FileUtil.closeQuietly(artistId3Writer);
FileUtil.closeQuietly(artistWriter);
FileUtil.closeQuietly(albumWriter);
FileUtil.closeQuietly(albumId3Writer);
FileUtil.closeQuietly(songWriter);
}
}
}

@ -0,0 +1,138 @@
/*
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 java.util.AbstractMap;
import java.util.AbstractMap.SimpleEntry;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* Enum that symbolizes the each lucene index entity.
* This class is a division of what was once part of SearchService and added functionality.
* @since legacy
*/
public enum IndexType {
/*
* Boosts is a factor for search scores, which is 1 by default.
*/
SONG(
fieldNames(
FieldNames.TITLE,
FieldNames.ARTIST),
boosts(
entry(FieldNames.TITLE, 2F))),
ALBUM(
fieldNames(
FieldNames.ALBUM,
FieldNames.ARTIST,
FieldNames.FOLDER),
boosts(
entry(FieldNames.ALBUM, 2F))),
ALBUM_ID3(
fieldNames(
FieldNames.ALBUM,
FieldNames.ARTIST,
FieldNames.FOLDER_ID),
boosts(
entry(FieldNames.ALBUM, 2F))),
ARTIST(
fieldNames(
FieldNames.ARTIST,
FieldNames.FOLDER),
boosts(
entry(FieldNames.ARTIST, 1F))),
ARTIST_ID3(
fieldNames(
FieldNames.ARTIST),
boosts(
entry(FieldNames.ARTIST, 2F))),
;
@SafeVarargs
private static final Map<String, Float> boosts(SimpleEntry<String, Float>... entry) {
Map<String, Float> m = new HashMap<>();
Arrays.stream(entry).forEach(kv -> m.put(kv.getKey(), kv.getValue()));
return Collections.unmodifiableMap(m);
}
/*
* The current state is implemented to set the same value as legacy.
* However unlike legacy, it has been changed
* so that different values can be set for each field.
* When setting two or more boost values,
* it is desirable to differentiate the values.
*/
private static final SimpleEntry<String, Float> entry(String k, float v) {
return new AbstractMap.SimpleEntry<>(k, v);
}
private static final String[] fieldNames(String... names) {
return Arrays.stream(names).toArray(String[]::new);
}
private final Map<String, Float> boosts;
private final String[] fields;
private IndexType(String[] fieldNames, Map<String, Float> boosts) {
this.fields = fieldNames;
this.boosts = boosts;
}
/**
* Returns a map of fields and boost values.
*
* @return map of fields and boost values
* @since legacy
*/
/*
* See the lucene documentation for boost specifications.
*/
public Map<String, Float> getBoosts() {
return boosts;
}
/**
* Return some of the fields defined in the index.
*
* @return Fields mainly used in multi-field search
* @since legacy
*/
/*
* It maintains a fairly early implementation
* and can be considered as an argument of MultiFieldQueryParser.
* In fact, the fields and boosts used in the search are difficult topics
* that can be determined by the search requirements.
*/
public String[] getFields() {
return fields;
}
}

@ -0,0 +1,231 @@
/*
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;
import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.domain.RandomSearchCriteria;
import org.airsonic.player.domain.SearchCriteria;
import org.apache.lucene.analysis.ASCIIFoldingFilter;
import org.apache.lucene.analysis.standard.StandardTokenizer;
import org.apache.lucene.analysis.tokenattributes.TermAttribute;
import org.apache.lucene.index.Term;
import org.apache.lucene.queryParser.MultiFieldQueryParser;
import org.apache.lucene.queryParser.ParseException;
import org.apache.lucene.queryParser.QueryParser;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.NumericRangeQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.spans.SpanOrQuery;
import org.apache.lucene.search.spans.SpanQuery;
import org.apache.lucene.search.spans.SpanTermQuery;
import org.apache.lucene.util.NumericUtils;
import org.apache.lucene.util.Version;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import static org.airsonic.player.service.search.IndexType.ALBUM_ID3;
import static org.airsonic.player.service.search.IndexType.ARTIST_ID3;
/**
* Factory class of Lucene Query.
* This class is an extract of the functionality that was once part of SearchService.
* It is for maintainability and verification.
* Each corresponds to the SearchService method.
* The API syntax for query generation depends on the lucene version.
* verification with query grammar is possible.
* On the other hand, the generated queries are relatively small by version.
* Therefore, test cases of this class are useful for large version upgrades.
**/
@Component
public class QueryFactory {
@Autowired
private AnalyzerFactory analyzerFactory;
private String analyzeQuery(String query) throws IOException {
StringBuilder result = new StringBuilder();
/*
* Version.LUCENE_30
* It is a transient description and will be deleted when upgrading the version.
* SearchService variables are not used because the reference direction conflicts.
*/
@SuppressWarnings("resource")
ASCIIFoldingFilter filter = new ASCIIFoldingFilter(
new StandardTokenizer(Version.LUCENE_30, new StringReader(query)));
TermAttribute termAttribute = filter.getAttribute(TermAttribute.class);
while (filter.incrementToken()) {
result.append(termAttribute.term()).append("* ");
}
return result.toString();
}
/**
* Normalize the genre string.
*
* @param genre genre string
* @return genre string normalized
* @deprecated should be resolved with tokenizer or filter
*/
@Deprecated
private String normalizeGenre(String genre) {
return genre.toLowerCase().replace(" ", "").replace("-", "");
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}.
*
* @param criteria criteria
* @param musicFolders musicFolders
* @param indexType {@link IndexType}
* @return Query
* @throws IOException When parsing of MultiFieldQueryParser fails
* @throws ParseException When parsing of MultiFieldQueryParser fails
*/
public Query search(SearchCriteria criteria, List<MusicFolder> musicFolders,
IndexType indexType) throws ParseException, IOException {
/*
* Version.LUCENE_30
* It is a transient description and will be deleted when upgrading the version.
* SearchService variables are not used because the reference direction conflicts.
*/
MultiFieldQueryParser queryParser = new MultiFieldQueryParser(Version.LUCENE_30,
indexType.getFields(), analyzerFactory.getQueryAnalyzer(), indexType.getBoosts());
BooleanQuery query = new BooleanQuery();
query.add(queryParser.parse(analyzeQuery(criteria.getQuery())), BooleanClause.Occur.MUST);
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : musicFolders) {
if (indexType == ALBUM_ID3 || indexType == ARTIST_ID3) {
musicFolderQueries.add(new SpanTermQuery(new Term(FieldNames.FOLDER_ID,
NumericUtils.intToPrefixCoded(musicFolder.getId()))));
} else {
musicFolderQueries.add(new SpanTermQuery(
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath())));
}
}
query.add(
new SpanOrQuery(
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])),
BooleanClause.Occur.MUST);
return query;
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#getRandomSongs(RandomSearchCriteria)}.
*
* @param criteria criteria
* @return Query
*/
public Query getRandomSongs(RandomSearchCriteria criteria) {
BooleanQuery query = new BooleanQuery();
query.add(new TermQuery(
new Term(FieldNames.MEDIA_TYPE, MediaFile.MediaType.MUSIC.name().toLowerCase())),
BooleanClause.Occur.MUST);
if (criteria.getGenre() != null) {
String genre = normalizeGenre(criteria.getGenre());
query.add(new TermQuery(new Term(FieldNames.GENRE, genre)), BooleanClause.Occur.MUST);
}
if (criteria.getFromYear() != null || criteria.getToYear() != null) {
NumericRangeQuery<Integer> rangeQuery = NumericRangeQuery.newIntRange(FieldNames.YEAR,
criteria.getFromYear(), criteria.getToYear(), true, true);
query.add(rangeQuery, BooleanClause.Occur.MUST);
}
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : criteria.getMusicFolders()) {
musicFolderQueries.add(new SpanTermQuery(
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath())));
}
query.add(
new SpanOrQuery(
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])),
BooleanClause.Occur.MUST);
return query;
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#searchByName( String, String, int, int, List, Class)}.
*
* @param fieldName {@link FieldNames}
* @return Query
* @throws ParseException When parsing of QueryParser fails
*/
public Query searchByName(String fieldName, String name) throws ParseException {
/*
* Version.LUCENE_30
* It is a transient description and will be deleted when upgrading the version.
* SearchService variables are not used because the reference direction conflicts.
*/
QueryParser queryParser = new QueryParser(Version.LUCENE_30, fieldName,
analyzerFactory.getQueryAnalyzer());
Query query = queryParser.parse(name + "*");
return query;
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#getRandomAlbums(int, List)}.
*
* @param musicFolders musicFolders
* @return Query
*/
public Query getRandomAlbums(List<MusicFolder> musicFolders) {
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : musicFolders) {
musicFolderQueries.add(new SpanTermQuery(
new Term(FieldNames.FOLDER, musicFolder.getPath().getPath())));
}
Query query = new SpanOrQuery(
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()]));
return query;
}
/**
* Query generation expression extracted from
* {@link org.airsonic.player.service.SearchService#getRandomAlbumsId3(int, List)}.
*
* @param musicFolders musicFolders
* @return Query
*/
public Query getRandomAlbumsId3(List<MusicFolder> musicFolders) {
List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>();
for (MusicFolder musicFolder : musicFolders) {
musicFolderQueries.add(new SpanTermQuery(new Term(FieldNames.FOLDER_ID,
NumericUtils.intToPrefixCoded(musicFolder.getId()))));
}
Query query = new SpanOrQuery(
musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()]));
return query;
}
}

@ -0,0 +1,298 @@
/*
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.*;
import org.airsonic.player.service.SearchService;
import org.airsonic.player.util.FileUtil;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.queryParser.ParseException;
import org.apache.lucene.search.*;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import static org.airsonic.player.service.search.IndexType.*;
import static org.springframework.util.ObjectUtils.isEmpty;
@Service
public class SearchServiceImpl implements SearchService {
private static final Logger LOG = LoggerFactory.getLogger(SearchServiceImpl.class);
@Autowired
private QueryFactory queryFactory;
@Autowired
private IndexManager indexManager;
@Autowired
private SearchServiceUtilities util;
// TODO Should be changed to SecureRandom?
private final Random random = new Random(System.currentTimeMillis());
public SearchServiceImpl() {
removeLocks();
}
@Override
public void startIndexing() {
indexManager.startIndexing();
}
@Override
public void index(MediaFile mediaFile) {
indexManager.index(mediaFile);
}
@Override
public void index(Artist artist, MusicFolder musicFolder) {
indexManager.index(artist, musicFolder);
}
@Override
public void index(Album album) {
indexManager.index(album);
}
@Override
public void stopIndexing() {
indexManager.stopIndexing();
}
@Override
public SearchResult search(SearchCriteria criteria, List<MusicFolder> musicFolders,
IndexType indexType) {
SearchResult result = new SearchResult();
int offset = criteria.getOffset();
int count = criteria.getCount();
result.setOffset(offset);
if (count <= 0)
return result;
IndexReader reader = null;
try {
reader = indexManager.createIndexReader(indexType);
Searcher searcher = new IndexSearcher(reader);
Query query = queryFactory.search(criteria, musicFolders, indexType);
TopDocs topDocs = searcher.search(query, null, offset + count);
result.setTotalHits(topDocs.totalHits);
int start = Math.min(offset, topDocs.totalHits);
int end = Math.min(start + count, topDocs.totalHits);
for (int i = start; i < end; i++) {
util.addIfAnyMatch(result, indexType, searcher.doc(topDocs.scoreDocs[i].doc));
}
} catch (IOException | ParseException e) {
LOG.error("Failed to execute Lucene search.", e);
} finally {
FileUtil.closeQuietly(reader);
}
return result;
}
/**
* Common processing of random method.
*
* @param count Number of albums to return.
* @param searcher
* @param query
* @param id2ListCallBack Callback to get D from id and store it in List
* @return result
* @throws IOException
*/
private final <D> List<D> createRandomDocsList(
int count, Searcher searcher, Query query, BiConsumer<List<D>, Integer> id2ListCallBack)
throws IOException {
List<Integer> docs = Arrays
.stream(searcher.search(query, Integer.MAX_VALUE).scoreDocs)
.map(sd -> sd.doc)
.collect(Collectors.toList());
List<D> result = new ArrayList<>();
while (!docs.isEmpty() && result.size() < count) {
int randomPos = random.nextInt(docs.size());
Document document = searcher.doc(docs.get(randomPos));
id2ListCallBack.accept(result, util.getId.apply(document));
docs.remove(randomPos);
}
return result;
}
@Override
public List<MediaFile> getRandomSongs(RandomSearchCriteria criteria) {
IndexReader reader = null;
try {
reader = indexManager.createIndexReader(SONG);
Searcher searcher = new IndexSearcher(reader);
if (isEmpty(searcher)) {
// At first start
return Collections.emptyList();
}
Query query = queryFactory.getRandomSongs(criteria);
return createRandomDocsList(criteria.getCount(), searcher, query,
(dist, id) -> util.addIgnoreNull(dist, SONG, id));
} catch (IOException e) {
LOG.error("Failed to search or random songs.", e);
} finally {
FileUtil.closeQuietly(reader);
}
return Collections.emptyList();
}
@Override
public List<MediaFile> getRandomAlbums(int count, List<MusicFolder> musicFolders) {
IndexReader reader = null;
try {
reader = indexManager.createIndexReader(ALBUM);
Searcher searcher = new IndexSearcher(reader);
if (isEmpty(searcher)) {
return Collections.emptyList();
}
Query query = queryFactory.getRandomAlbums(musicFolders);
return createRandomDocsList(count, searcher, query,
(dist, id) -> util.addIgnoreNull(dist, ALBUM, id));
} catch (IOException e) {
LOG.error("Failed to search for random albums.", e);
} finally {
FileUtil.closeQuietly(reader);
}
return Collections.emptyList();
}
@Override
public List<Album> getRandomAlbumsId3(int count, List<MusicFolder> musicFolders) {
IndexReader reader = null;
try {
reader = indexManager.createIndexReader(ALBUM_ID3);
Searcher searcher = new IndexSearcher(reader);
if (isEmpty(searcher)) {
return Collections.emptyList();
}
Query query = queryFactory.getRandomAlbumsId3(musicFolders);
return createRandomDocsList(count, searcher, query,
(dist, id) -> util.addIgnoreNull(dist, ALBUM_ID3, id));
} catch (IOException e) {
LOG.error("Failed to search for random albums.", e);
} finally {
FileUtil.closeQuietly(reader);
}
return Collections.emptyList();
}
@Override
public <T> ParamSearchResult<T> searchByName(String name, int offset, int count,
List<MusicFolder> folderList, Class<T> assignableClass) {
// we only support album, artist, and song for now
@Nullable
IndexType indexType = util.getIndexType.apply(assignableClass);
@Nullable
String fieldName = util.getFieldName.apply(assignableClass);
ParamSearchResult<T> result = new ParamSearchResult<T>();
result.setOffset(offset);
if (isEmpty(indexType) || isEmpty(fieldName) || count <= 0) {
return result;
}
IndexReader reader = null;
try {
reader = indexManager.createIndexReader(indexType);
Searcher searcher = new IndexSearcher(reader);
Query query = queryFactory.searchByName(fieldName, name);
Sort sort = new Sort(new SortField(fieldName, SortField.STRING));
TopDocs topDocs = searcher.search(query, null, offset + count, sort);
result.setTotalHits(topDocs.totalHits);
int start = Math.min(offset, topDocs.totalHits);
int end = Math.min(start + count, topDocs.totalHits);
for (int i = start; i < end; i++) {
Document doc = searcher.doc(topDocs.scoreDocs[i].doc);
util.addIgnoreNull(result, indexType, util.getId.apply(doc), assignableClass);
}
} catch (IOException | ParseException e) {
LOG.error("Failed to execute Lucene search.", e);
} finally {
FileUtil.closeQuietly(reader);
}
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);
}
}
}
}

@ -0,0 +1,200 @@
/*
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.dao.AlbumDao;
import org.airsonic.player.dao.ArtistDao;
import org.airsonic.player.domain.Album;
import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.ParamSearchResult;
import org.airsonic.player.domain.SearchResult;
import org.airsonic.player.service.MediaFileService;
import org.airsonic.player.service.SettingsService;
import org.apache.commons.collections.CollectionUtils;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.Term;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.File;
import java.util.Collection;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;
import static org.springframework.util.ObjectUtils.isEmpty;
/**
* Termination used by SearchService.
*
* Since SearchService operates as a proxy for storage (DB) using lucene,
* there are many redundant descriptions different from essential data processing.
* This class is a transfer class for saving those redundant descriptions.
*
* Exception handling is not termination,
* so do not include exception handling in this class.
*/
@Component
public class SearchServiceUtilities {
/* Search by id only. */
@Autowired
private ArtistDao artistDao;
/* Search by id only. */
@Autowired
private AlbumDao albumDao;
/*
* Search by id only.
* Although there is no influence at present,
* mediaFileService has a caching mechanism.
* Service is used instead of Dao until you are sure you need to use mediaFileDao.
*/
@Autowired
private MediaFileService mediaFileService;
public final Function<Long, Integer> round = (i) -> {
// return
// NumericUtils.floatToSortableInt(i);
return i.intValue();
};
public final Function<Document, Integer> getId = d -> {
return Integer.valueOf(d.get(FieldNames.ID));
};
public final BiConsumer<List<MediaFile>, Integer> addMediaFileIfAnyMatch = (dist, id) -> {
if (!dist.stream().anyMatch(m -> id == m.getId())) {
MediaFile mediaFile = mediaFileService.getMediaFile(id);
if (!isEmpty(mediaFile)) {
dist.add(mediaFile);
}
}
};
public final BiConsumer<List<Artist>, Integer> addArtistId3IfAnyMatch = (dist, id) -> {
if (!dist.stream().anyMatch(a -> id == a.getId())) {
Artist artist = artistDao.getArtist(id);
if (!isEmpty(artist)) {
dist.add(artist);
}
}
};
public final Function<Class<?>, @Nullable IndexType> getIndexType = (assignableClass) -> {
IndexType indexType = null;
if (assignableClass.isAssignableFrom(Album.class)) {
indexType = IndexType.ALBUM_ID3;
} else if (assignableClass.isAssignableFrom(Artist.class)) {
indexType = IndexType.ARTIST_ID3;
} else if (assignableClass.isAssignableFrom(MediaFile.class)) {
indexType = IndexType.SONG;
}
return indexType;
};
public final Function<Class<?>, @Nullable String> getFieldName = (assignableClass) -> {
String fieldName = null;
if (assignableClass.isAssignableFrom(Album.class)) {
fieldName = FieldNames.ALBUM;
} else if (assignableClass.isAssignableFrom(Artist.class)) {
fieldName = FieldNames.ARTIST;
} else if (assignableClass.isAssignableFrom(MediaFile.class)) {
fieldName = FieldNames.TITLE;
}
return fieldName;
};
public final BiConsumer<List<Album>, Integer> addAlbumId3IfAnyMatch = (dist, subjectId) -> {
if (!dist.stream().anyMatch(a -> subjectId == a.getId())) {
Album album = albumDao.getAlbum(subjectId);
if (!isEmpty(album)) {
dist.add(album);
}
}
};
private final Function<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) {
return CollectionUtils.addIgnoreNull(collection, object);
}
public final boolean addIgnoreNull(Collection<?> collection, IndexType indexType,
int subjectId) {
if (indexType == IndexType.ALBUM | indexType == IndexType.SONG) {
return addIgnoreNull(collection, mediaFileService.getMediaFile(subjectId));
} else if (indexType == IndexType.ALBUM_ID3) {
return addIgnoreNull(collection, albumDao.getAlbum(subjectId));
}
return false;
}
public final <T> void addIgnoreNull(ParamSearchResult<T> dist, IndexType indexType,
int subjectId, Class<T> subjectClass) {
if (indexType == IndexType.SONG) {
MediaFile mediaFile = mediaFileService.getMediaFile(subjectId);
addIgnoreNull(dist.getItems(), subjectClass.cast(mediaFile));
} else if (indexType == IndexType.ARTIST_ID3) {
Artist artist = artistDao.getArtist(subjectId);
addIgnoreNull(dist.getItems(), subjectClass.cast(artist));
} else if (indexType == IndexType.ALBUM_ID3) {
Album album = albumDao.getAlbum(subjectId);
addIgnoreNull(dist.getItems(), subjectClass.cast(album));
}
}
public final void addIfAnyMatch(SearchResult dist, IndexType subjectIndexType,
Document subject) {
int documentId = getId.apply(subject);
if (subjectIndexType == IndexType.ARTIST | subjectIndexType == IndexType.ALBUM
| subjectIndexType == IndexType.SONG) {
addMediaFileIfAnyMatch.accept(dist.getMediaFiles(), documentId);
} else if (subjectIndexType == IndexType.ARTIST_ID3) {
addArtistId3IfAnyMatch.accept(dist.getArtists(), documentId);
} else if (subjectIndexType == IndexType.ALBUM_ID3) {
addAlbumId3IfAnyMatch.accept(dist.getAlbums(), documentId);
}
}
}

@ -27,6 +27,7 @@ import org.airsonic.player.controller.CoverArtController;
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.service.*; import org.airsonic.player.service.*;
import org.airsonic.player.service.search.IndexType;
import org.airsonic.player.util.StringUtil; import org.airsonic.player.util.StringUtil;
import org.airsonic.player.util.Util; import org.airsonic.player.util.Util;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -523,7 +524,7 @@ public class SonosHelper {
return Arrays.asList(artists, albums, songs); return Arrays.asList(artists, albums, songs);
} }
public MediaList forSearch(String query, int offset, int count, SearchService.IndexType indexType, String username, HttpServletRequest request) { public MediaList forSearch(String query, int offset, int count, IndexType indexType, String username, HttpServletRequest request) {
SearchCriteria searchCriteria = new SearchCriteria(); SearchCriteria searchCriteria = new SearchCriteria();
searchCriteria.setCount(count); searchCriteria.setCount(count);

@ -20,7 +20,7 @@
<property name="playerService" ref="playerService"/> <property name="playerService" ref="playerService"/>
<property name="transcodingService" ref="transcodingService"/> <property name="transcodingService" ref="transcodingService"/>
<property name="musicIndexService" ref="musicIndexService"/> <property name="musicIndexService" ref="musicIndexService"/>
<property name="searchService" ref="searchService"/> <property name="searchService" ref="searchServiceImpl"/>
<property name="ratingService" ref="ratingService"/> <property name="ratingService" ref="ratingService"/>
<property name="lastFmService" ref="lastFmService"/> <property name="lastFmService" ref="lastFmService"/>
<property name="podcastService" ref="podcastService"/> <property name="podcastService" ref="podcastService"/>

@ -0,0 +1,574 @@
package org.airsonic.player.service.search;
import static org.junit.Assert.assertEquals;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.TermAttribute;
import org.junit.Test;
import org.slf4j.LoggerFactory;
/**
* Test case for Analyzer.
* These cases have the purpose of observing the current situation
* and observing the impact of upgrading Lucene.
*/
public class AnalyzerFactoryTestCase {
private AnalyzerFactory analyzerFactory = new AnalyzerFactory();
/**
* Test for the number of character separators per field.
*/
@Test
public void testTokenCounts() {
/*
* Analyzer used in legacy uses the same Tokenizer for all fields.
* (Some fields are converted to their own input string for integrity.)
* As a result, specifications for strings are scattered and difficult to understand.
* Using PerFieldAnalyzerWrapper,
* it is possible to use different Analyzer (Tokenizer/Filter) for each field.
* This allows consistent management of parsing definitions.
* It is also possible to apply definitions such as "id3 delimiters Tokenizer" to specific fields.
*/
// The number of words excluding articles is 7.
String query = "The quick brown fox jumps over the lazy dog.";
Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> {
List<String> terms = toTermString(n, query);
switch (n) {
/*
* In the legacy, these field divide input into 7. It is not necessary to delimit
* this field originally.
*/
case FieldNames.FOLDER:
case FieldNames.MEDIA_TYPE:
case FieldNames.GENRE:
assertEquals("oneTokenFields : " + n, 7, terms.size());
break;
/*
* These should be divided into 7.
*/
case FieldNames.ARTIST:
case FieldNames.ALBUM:
case FieldNames.TITLE:
assertEquals("multiTokenFields : " + n, 7, terms.size());
break;
/*
* ID, FOLDER_ID, YEAR
* This is not a problem because the input value does not contain a delimiter.
*/
default:
assertEquals("oneTokenFields : " + n, 7, terms.size());
break;
}
});
}
/**
* Detailed tests on Punctuation.
* In addition to the common delimiters, there are many delimiters.
*/
@Test
public void testPunctuation1() {
String query = "B︴C";
String expected1 = "b";
String expected2 = "c";
Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> {
List<String> terms = toTermString(n, query);
switch (n) {
/*
* In the legacy, these field divide input into 2.
* It is not necessary to delimit
* this field originally.
*/
case FieldNames.FOLDER:
case FieldNames.GENRE:
case FieldNames.MEDIA_TYPE:
assertEquals("tokenized : " + n, 2, terms.size());
assertEquals("tokenized : " + n, expected1, terms.get(0));
assertEquals("tokenized : " + n, expected2, terms.get(1));
break;
/*
* What should the fields of this be?
* Generally discarded.
*/
case FieldNames.ARTIST:
case FieldNames.ALBUM:
case FieldNames.TITLE:
assertEquals("tokenized : " + n, 2, terms.size());
assertEquals("tokenized : " + n, expected1, terms.get(0));
assertEquals("tokenized : " + n, expected2, terms.get(1));
break;
/*
* ID, FOLDER_ID, YEAR
* This is not a problem because the input value does not contain a delimiter.
*/
default:
assertEquals("tokenized : " + n, 2, terms.size());
break;
}
});
}
/*
* Detailed tests on Punctuation.
* Many of the symbols are delimiters or target to be removed.
*/
@Test
public void testPunctuation2() {
String query = "{'“『【【】】[︴○◎@ $〒→+]";
Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> {
List<String> terms = toTermString(n, query);
switch (n) {
case FieldNames.FOLDER:
case FieldNames.MEDIA_TYPE:
case FieldNames.GENRE:
case FieldNames.ARTIST:
case FieldNames.ALBUM:
case FieldNames.TITLE:
assertEquals("removed : " + n, 0, terms.size());
break;
default:
assertEquals("removed : " + n, 0, terms.size());
}
});
}
/**
* Detailed tests on Stopward.
*
* @see org.apache.lucene.analysis.StopAnalyzer#ENGLISH_STOP_WORDS_SET
*/
@Test
public void testStopward() {
/*
* Legacy behavior is to remove ENGLISH_STOP_WORDS_SET from the Token stream.
* (Putting whether or not it matches the specification of the music search.)
*/
/*
* article.
* This is included in ENGLISH_STOP_WORDS_SET.
*/
String queryArticle = "a an the";
/*
* The default set as index stop word.
* But these are not included in ENGLISH_STOP_WORDS_SET.
*/
String queryArticle4Index = "el la los las le les";
/*
* Non-article in the ENGLISH_STOP_WORDS_SET.
* Stopwords are essential for newspapers and documents,
* but offten they are over-processed for song titles.
* For example, "we will rock you" can not be searched by "will".
*/
String queryStop = "and are as at be but by for if in into is it no not of on " //
+ "or such that their then there these they this to was will with";
Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> {
List<String> articleTerms = toTermString(n, queryArticle);
List<String> indexArticleTerms = toTermString(n, queryArticle4Index);
List<String> stopedTerms = toTermString(n, queryStop);
switch (n) {
case FieldNames.FOLDER:
case FieldNames.MEDIA_TYPE:
case FieldNames.GENRE:
case FieldNames.ARTIST:
case FieldNames.ALBUM:
case FieldNames.TITLE:
// It is removed because it is included in ENGLISH_STOP_WORDS_SET.
assertEquals("article : " + n, 0, articleTerms.size());
// Not removed because it is not included in ENGLISH_STOP_WORDS_SET.
assertEquals("sonic server index article: " + n, 6, indexArticleTerms.size());
// It is removed because it is included in ENGLISH_STOP_WORDS_SET.
assertEquals("non-article stop words : " + n, 0, stopedTerms.size());
break;
// Legacy has common behavior for all fields.
default:
assertEquals("article : " + n, 0, articleTerms.size());
assertEquals("sonic server index article: " + n, 6, indexArticleTerms.size());
assertEquals("non-article stop words : " + n, 0, stopedTerms.size());
break;
}
});
}
/**
* Simple test on FullWidth.
*/
@Test
public void testFullWidth() {
String query = "FULL-WIDTH";
List<String> terms = toTermString(query);
assertEquals(2, terms.size());
assertEquals("full", terms.get(0));
assertEquals("width", terms.get(1));
}
/**
* Combined case of Stop and full-width.
*/
@Test
public void testStopwardAndFullWidth() {
/*
* Stop word is removed.
*/
String queryHalfWidth = "THIS IS FULL-WIDTH SENTENCES.";
List<String> terms = toTermString(queryHalfWidth);
assertEquals(3, terms.size());
assertEquals("full", terms.get(0));
assertEquals("width", terms.get(1));
assertEquals("sentences", terms.get(2));
/*
* Legacy can avoid Stopward if it is full width.
* It is unclear whether it is a specification or not.
* (Problems due to a defect in filter application order?
* or
* Is it popular in English speaking countries?)
*/
String queryFullWidth = "THIS IS FULL-WIDTH SENTENCES.";
terms = toTermString(queryFullWidth);
assertEquals(5, terms.size());
assertEquals("this", terms.get(0));// removal target is ignored
assertEquals("is", terms.get(1));
assertEquals("full", terms.get(2));
assertEquals("width", terms.get(3));
assertEquals("sentences", terms.get(4));
}
/**
* Tests on ligature and diacritical marks.
* In UAX#29, determination of non-practical word boundaries is not considered.
* Languages that use special strings require "practical word" sample.
* Unit testing with only ligature and diacritical marks is not possible.
*/
@Test
public void testAsciiFoldingStop() {
String queryLigature = "Cæsar";
String expectedLigature = "caesar";
String queryDiacritical = "Café";
String expectedDiacritical = "cafe";
Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> {
List<String> termsLigature = toTermString(n, queryLigature);
List<String> termsDiacritical = toTermString(n, queryDiacritical);
switch (n) {
/*
* It is decomposed into the expected string.
*/
case FieldNames.FOLDER:
case FieldNames.MEDIA_TYPE:
case FieldNames.GENRE:
case FieldNames.ARTIST:
case FieldNames.ALBUM:
case FieldNames.TITLE:
assertEquals("Cæsar : " + n, 1, termsLigature.size());
assertEquals("Cæsar : " + n, expectedLigature, termsLigature.get(0));
assertEquals("Café : " + n, 1, termsDiacritical.size());
assertEquals("Café : " + n, expectedDiacritical, termsDiacritical.get(0));
break;
// Legacy has common behavior for all fields.
default:
assertEquals("Cæsar : " + n, 1, termsLigature.size());
assertEquals("Cæsar : " + n, expectedLigature, termsLigature.get(0));
assertEquals("Café : " + n, 1, termsDiacritical.size());
assertEquals("Café : " + n, expectedDiacritical, termsDiacritical.get(0));
break;
}
});
}
/**
* Detailed tests on LowerCase.
*/
@Test
public void testLowerCase() {
// Filter operation check only. Verify only some settings.
String query = "ABCDEFG";
String expected = "abcdefg";
Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> {
List<String> terms = toTermString(n, query);
switch (n) {
/*
* In legacy, it is converted to lower. (over-processed?)
*/
case FieldNames.FOLDER:
case FieldNames.MEDIA_TYPE:
assertEquals("lower : " + n, 1, terms.size());
assertEquals("lower : " + n, expected, terms.get(0));
break;
/*
* These are searchable fields in lower case.
*/
case FieldNames.GENRE:
case FieldNames.ARTIST:
case FieldNames.ALBUM:
case FieldNames.TITLE:
assertEquals("lower : " + n, 1, terms.size());
assertEquals("lower : " + n, expected, terms.get(0));
break;
// Legacy has common behavior for all fields.
default:
assertEquals("lower : " + n, 1, terms.size());
assertEquals("lower : " + n, expected, terms.get(0));
break;
}
});
}
/**
* Detailed tests on EscapeRequires.
* The reserved string is discarded unless it is purposely Escape.
* This is fine as a search specification(if it is considered as a kind of reserved stop word).
* However, in the case of file path, it may be a problem.
*/
@Test
public void testLuceneEscapeRequires() {
String queryEscapeRequires = "+-&&||!(){}[]^\"~*?:\\/";
String queryFileUsable = "+-&&!(){}[]^~";
Arrays.stream(IndexType.values()).flatMap(i -> Arrays.stream(i.getFields())).forEach(n -> {
List<String> terms = toTermString(n, queryEscapeRequires);
switch (n) {
/*
* Will be removed. (Can not distinguish the directory of a particular pattern?)
*/
case FieldNames.FOLDER:
assertEquals("escape : " + n, 0, terms.size());
terms = toTermString(n, queryFileUsable);
assertEquals("escape : " + n, 0, terms.size());
break;
/*
* Will be removed.
*/
case FieldNames.MEDIA_TYPE:
case FieldNames.GENRE:
case FieldNames.ARTIST:
case FieldNames.ALBUM:
case FieldNames.TITLE:
assertEquals("escape : " + n, 0, terms.size());
break;
// Will be removed.
default:
assertEquals("escape : " + n, 0, terms.size());
break;
}
});
}
/**
* Create an example that makes UAX 29 differences easy to understand.
*/
@Test
public void testUax29() {
/*
* Case using test resource name
*/
// title
String query = "Bach: Goldberg Variations, BWV 988 - Aria";
List<String> terms = toTermString(query);
assertEquals(6, terms.size());
assertEquals("bach", terms.get(0));
assertEquals("goldberg", terms.get(1));
assertEquals("variations", terms.get(2));
assertEquals("bwv", terms.get(3));
assertEquals("988", terms.get(4));
assertEquals("aria", terms.get(5));
// artist
query = "_ID3_ARTIST_ Céline Frisch: Café Zimmermann";
terms = toTermString(query);
assertEquals(5, terms.size());
assertEquals("id3_artist", terms.get(0));
assertEquals("celine", terms.get(1));
assertEquals("frisch", terms.get(2));
assertEquals("cafe", terms.get(3));
assertEquals("zimmermann", terms.get(4));
}
/**
* Special handling of single quotes.
*/
@Test
public void testSingleQuotes() {
/*
* A somewhat cultural that seems to be related to a specific language.
*/
String query = "This is Airsonic's analysis.";
List<String> terms = toTermString(query);
assertEquals(2, terms.size());
assertEquals("airsonic", terms.get(0));
assertEquals("analysis", terms.get(1));
query = "We’ve been here before.";
terms = toTermString(query);
assertEquals(5, terms.size());
assertEquals("we", terms.get(0));
assertEquals("ve", terms.get(1));
assertEquals("been", terms.get(2));
assertEquals("here", terms.get(3));
assertEquals("before", terms.get(4));
query = "LʼHomme";
terms = toTermString(query);
assertEquals(1, terms.size());
assertEquals("lʼhomme", terms.get(0));
query = "L'Homme";
terms = toTermString(query);
assertEquals(1, terms.size());
assertEquals("l'homme", terms.get(0));
query = "aujourd'hui";
terms = toTermString(query);
assertEquals(1, terms.size());
assertEquals("aujourd'hui", terms.get(0));
query = "fo'c'sle";
terms = toTermString(query);
assertEquals(1, terms.size());
assertEquals("fo'c'sle", terms.get(0));
}
/*
* There is also a filter that converts the tense to correspond to the search by the present
* tense.
*/
@Test
public void testPastParticiple() {
/*
* Confirming no conversion to present tense.
*/
String query = "This is formed with a form of the verb \"have\" and a past participl.";
List<String> terms = toTermString(query);
assertEquals(6, terms.size());
assertEquals("formed", terms.get(0));// leave passive / not "form"
assertEquals("form", terms.get(1));
assertEquals("verb", terms.get(2));
assertEquals("have", terms.get(3));
assertEquals("past", terms.get(4));
assertEquals("participl", terms.get(5));
}
/*
* There are also filters that convert plurals to singular.
*/
@Test
public void testNumeral() {
/*
* Confirming no conversion to singular.
*/
String query = "books boxes cities leaves men glasses";
List<String> terms = toTermString(query);
assertEquals(6, terms.size());
assertEquals("books", terms.get(0));// leave numeral / not singular
assertEquals("boxes", terms.get(1));
assertEquals("cities", terms.get(2));
assertEquals("leaves", terms.get(3));
assertEquals("men", terms.get(4));
assertEquals("glasses", terms.get(5));
}
private List<String> toTermString(String str) {
return toTermString(null, str);
}
private List<String> toTermString(String field, String str) {
List<String> result = new ArrayList<>();
try {
TokenStream stream = analyzerFactory.getAnalyzer().tokenStream(field,
new StringReader(str));
stream.reset();
while (stream.incrementToken()) {
result.add(stream.getAttribute(TermAttribute.class).toString()
.replaceAll("^term\\=", ""));
}
stream.close();
} catch (IOException e) {
LoggerFactory.getLogger(AnalyzerFactoryTestCase.class)
.error("Error during Token processing.", e);
}
return result;
}
/*
* Should be added in later versions.
*/
public void testWildCard() {
}
@SuppressWarnings("unused")
private List<String> toQueryTermString(String field, String str) {
List<String> result = new ArrayList<>();
try {
TokenStream stream = analyzerFactory.getQueryAnalyzer().tokenStream(field,
new StringReader(str));
stream.reset();
while (stream.incrementToken()) {
result.add(stream.getAttribute(TermAttribute.class).toString()
.replaceAll("^term\\=", ""));
}
stream.close();
} catch (IOException e) {
LoggerFactory.getLogger(AnalyzerFactoryTestCase.class)
.error("Error during Token processing.", e);
}
return result;
}
}

@ -0,0 +1,256 @@
package org.airsonic.player.service.search;
import static org.junit.Assert.assertEquals;
import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.domain.RandomSearchCriteria;
import org.airsonic.player.domain.SearchCriteria;
import org.airsonic.player.util.HomeRule;
import org.apache.commons.lang.builder.ToStringBuilder;
import org.apache.lucene.queryParser.ParseException;
import org.apache.lucene.search.Query;
import org.apache.lucene.util.NumericUtils;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.rules.SpringClassRule;
import org.springframework.test.context.junit4.rules.SpringMethodRule;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* Test case for QueryFactory.
* These cases have the purpose of observing the current situation
* and observing the impact of upgrading Lucene.
*/
@ContextConfiguration(
locations = {
"/applicationContext-service.xml",
"/applicationContext-cache.xml",
"/applicationContext-testdb.xml",
"/applicationContext-mockSonos.xml" })
@DirtiesContext(
classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public class QueryFactoryTestCase {
@ClassRule
public static final SpringClassRule classRule = new SpringClassRule() {
HomeRule homeRule = new HomeRule();
@Override
public Statement apply(Statement base, Description description) {
Statement spring = super.apply(base, description);
return homeRule.apply(spring, description);
}
};
@Rule
public final SpringMethodRule springMethodRule = new SpringMethodRule();
@Autowired
private QueryFactory queryFactory;
private static final String QUERY_ENG_ONLY = "ABC DEF";
private static final String SEPA = System.getProperty("file.separator");
private static final String PATH1 = SEPA + "var" + SEPA + "music1";
private static final String PATH2 = SEPA + "var" + SEPA + "music2";
private static final int FID1 = 10;
private static final int FID2 = 20;
private static final MusicFolder MUSIC_FOLDER1 =
new MusicFolder(Integer.valueOf(FID1), new File(PATH1), "music1", true, new java.util.Date());
private static final MusicFolder MUSIC_FOLDER2 =
new MusicFolder(Integer.valueOf(FID2), new File(PATH2), "music2", true, new java.util.Date());
private static final List<MusicFolder> SINGLE_FOLDERS = Arrays.asList(MUSIC_FOLDER1);
private static final List<MusicFolder> MULTI_FOLDERS = Arrays.asList(MUSIC_FOLDER1, MUSIC_FOLDER2);
@Test
public void testSearchArtist() throws ParseException, IOException {
SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE);
criteria.setQuery(QUERY_ENG_ONLY);
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ARTIST);
assertEquals("SearchArtist",
"+((artist:abc* folder:abc*) (artist:def* folder:def*)) +spanOr([folder:" + PATH1 + "])",
query.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ARTIST);
assertEquals("SearchArtist", "+((artist:abc* folder:abc*) (artist:def* folder:def*)) +spanOr([folder:" + PATH1
+ ", folder:" + PATH2 + "])", query.toString());
}
@Test
public void testSearchAlbum() throws ParseException, IOException {
SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE);
criteria.setQuery(QUERY_ENG_ONLY);
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ALBUM);
assertEquals("SearchAlbum",
"+((album:abc* artist:abc* folder:abc*) (album:def* artist:def* folder:def*)) +spanOr([folder:" + PATH1
+ "])",
query.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ALBUM);
assertEquals("SearchAlbum",
"+((album:abc* artist:abc* folder:abc*) (album:def* artist:def* folder:def*)) +spanOr([folder:" + PATH1
+ ", folder:" + PATH2 + "])",
query.toString());
}
@Test
public void testSearchSong() throws ParseException, IOException {
SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE);
criteria.setQuery(QUERY_ENG_ONLY);
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.SONG);
assertEquals("SearchSong",
"+((title:abc* artist:abc*) (title:def* artist:def*)) +spanOr([folder:" + PATH1 + "])",
query.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.SONG);
assertEquals("SearchSong", "+((title:abc* artist:abc*) (title:def* artist:def*)) +spanOr([folder:" + PATH1
+ ", folder:" + PATH2 + "])", query.toString());
}
@Test
public void testSearchArtistId3() throws ParseException, IOException {
SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE);
criteria.setQuery(QUERY_ENG_ONLY);
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ARTIST_ID3);
assertEquals("SearchSong", "+((artist:abc*) (artist:def*)) +spanOr([folderId:"
+ NumericUtils.intToPrefixCoded(FID1) + "])", query.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ARTIST_ID3);
assertEquals("SearchSong",
"+((artist:abc*) (artist:def*)) +spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1)
+ ", folderId:" + NumericUtils.intToPrefixCoded(FID2) + "])",
query.toString());
}
@Test
public void testSearchAlbumId3() throws ParseException, IOException {
SearchCriteria criteria = new SearchCriteria();
criteria.setOffset(10);
criteria.setCount(Integer.MAX_VALUE);
criteria.setQuery(QUERY_ENG_ONLY);
Query query = queryFactory.search(criteria, SINGLE_FOLDERS, IndexType.ALBUM_ID3);
assertEquals(
"SearchAlbumId3", "+((album:abc* artist:abc* folderId:abc*) (album:def* artist:def* folderId:def*)) "
+ "+spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + "])",
query.toString());
query = queryFactory.search(criteria, MULTI_FOLDERS, IndexType.ALBUM_ID3);
assertEquals("SearchAlbumId3",
"+((album:abc* artist:abc* folderId:abc*) (album:def* artist:def* folderId:def*)) +spanOr([folderId:"
+ NumericUtils.intToPrefixCoded(FID1) + ", folderId:"
+ NumericUtils.intToPrefixCoded(FID2) + "])",
query.toString());
}
@Test
public void testSearchByNameArtist() throws ParseException {
Query query = queryFactory.searchByName(FieldNames.ARTIST, QUERY_ENG_ONLY);
assertEquals("SearchByNameArtist", "artist:abc artist:def*", query.toString());
}
@Test
public void testSearchByNameAlbum() throws ParseException {
Query query = queryFactory.searchByName(FieldNames.ALBUM, QUERY_ENG_ONLY);
assertEquals("SearchByNameAlbum", "album:abc album:def*", query.toString());
}
@Test
public void testSearchByNameTitle() throws ParseException {
Query query = queryFactory.searchByName(FieldNames.TITLE, QUERY_ENG_ONLY);
assertEquals("SearchByNameTitle", "title:abc title:def*", query.toString());
}
@Test
public void testGetRandomSongs() {
RandomSearchCriteria criteria = new RandomSearchCriteria(50, "Classic Rock",
Integer.valueOf(1900), Integer.valueOf(2000), SINGLE_FOLDERS);
Query query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(criteria),
"+mediaType:music +genre:classicrock +year:[1900 TO 2000] +spanOr([folder:" + PATH1 + "])",
query.toString());
criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900),
Integer.valueOf(2000), MULTI_FOLDERS);
query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(criteria),
"+mediaType:music +genre:classicrock +year:[1900 TO 2000] +spanOr([folder:" + PATH1 + ", folder:" + PATH2
+ "])",
query.toString());
criteria = new RandomSearchCriteria(50, "Classic Rock", null, null, MULTI_FOLDERS);
query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(criteria),
"+mediaType:music +genre:classicrock +spanOr([folder:" + PATH1 + ", folder:" + PATH2 + "])",
query.toString());
criteria = new RandomSearchCriteria(50, "Classic Rock", Integer.valueOf(1900), null,
MULTI_FOLDERS);
query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(criteria),
"+mediaType:music +genre:classicrock +year:[1900 TO *] +spanOr([folder:" + PATH1 + ", folder:" + PATH2
+ "])",
query.toString());
criteria = new RandomSearchCriteria(50, "Classic Rock", null, Integer.valueOf(2000),
MULTI_FOLDERS);
query = queryFactory.getRandomSongs(criteria);
assertEquals(ToStringBuilder.reflectionToString(criteria),
"+mediaType:music +genre:classicrock +year:[* TO 2000] +spanOr([folder:" + PATH1 + ", folder:" + PATH2
+ "])",
query.toString());
}
@Test
public void testGetRandomAlbums() {
Query query = queryFactory.getRandomAlbums(SINGLE_FOLDERS);
assertEquals(ToStringBuilder.reflectionToString(SINGLE_FOLDERS),
"spanOr([folder:" + PATH1 + "])", query.toString());
query = queryFactory.getRandomAlbums(MULTI_FOLDERS);
assertEquals(ToStringBuilder.reflectionToString(MULTI_FOLDERS),
"spanOr([folder:" + PATH1 + ", folder:" + PATH2 + "])", query.toString());
}
@Test
public void testGetRandomAlbumsId3() {
Query query = queryFactory.getRandomAlbumsId3(SINGLE_FOLDERS);
assertEquals(ToStringBuilder.reflectionToString(SINGLE_FOLDERS),
"spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + "])", query.toString());
query = queryFactory.getRandomAlbumsId3(MULTI_FOLDERS);
assertEquals(ToStringBuilder.reflectionToString(MULTI_FOLDERS),
"spanOr([folderId:" + NumericUtils.intToPrefixCoded(FID1) + ", folderId:"
+ NumericUtils.intToPrefixCoded(FID2) + "])",
query.toString());
}
}

@ -0,0 +1,466 @@
package org.airsonic.player.service.search;
import com.codahale.metrics.ConsoleReporter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.Timer;
import java.io.File;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import org.airsonic.player.TestCaseUtils;
import org.airsonic.player.dao.AlbumDao;
import org.airsonic.player.dao.DaoHelper;
import org.airsonic.player.dao.MediaFileDao;
import org.airsonic.player.dao.MusicFolderDao;
import org.airsonic.player.dao.MusicFolderTestData;
import org.airsonic.player.domain.Album;
import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.MediaFile.MediaType;
import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.domain.ParamSearchResult;
import org.airsonic.player.domain.RandomSearchCriteria;
import org.airsonic.player.domain.SearchCriteria;
import org.airsonic.player.domain.SearchResult;
import org.airsonic.player.service.MediaScannerService;
import org.airsonic.player.service.SearchService;
import org.airsonic.player.service.SettingsService;
import org.airsonic.player.service.search.IndexType;
import org.airsonic.player.util.HomeRule;
import org.junit.Assert;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.rules.SpringClassRule;
import org.springframework.test.context.junit4.rules.SpringMethodRule;
import org.subsonic.restapi.ArtistID3;
@ContextConfiguration(
locations = {
"/applicationContext-service.xml",
"/applicationContext-cache.xml",
"/applicationContext-testdb.xml",
"/applicationContext-mockSonos.xml" })
@DirtiesContext(
classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class SearchServiceTestCase {
@ClassRule
public static final SpringClassRule classRule = new SpringClassRule() {
HomeRule homeRule = new HomeRule();
@Override
public Statement apply(Statement base, Description description) {
Statement spring = super.apply(base, description);
return homeRule.apply(spring, description);
}
};
@Rule
public final SpringMethodRule springMethodRule = new SpringMethodRule();
private final MetricRegistry metrics = new MetricRegistry();
@Autowired
private MediaScannerService mediaScannerService;
@Autowired
private MediaFileDao mediaFileDao;
@Autowired
private MusicFolderDao musicFolderDao;
@Autowired
private DaoHelper daoHelper;
@Autowired
private AlbumDao albumDao;
@Autowired
private SearchService searchService;
@Autowired
private SettingsService settingsService;
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Autowired
ResourceLoader resourceLoader;
@Before
public void setup() throws Exception {
populateDatabase();
}
private static boolean dataBasePopulated;
private int count = 1;
/*
* Cases susceptible to SerchService refactoring and version upgrades.
* It is not exhaustive.
*/
private synchronized void populateDatabase() {
/*
* It seems that there is a case that does not work well
* if you test immediately after initialization in 1 method.
* It may be improved later.
*/
try {
Thread.sleep(300 * count++);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (!dataBasePopulated) {
MusicFolderTestData.getTestMusicFolders().forEach(musicFolderDao::createMusicFolder);
settingsService.clearMusicFolderCache();
TestCaseUtils.execScan(mediaScannerService);
System.out.println("--- Report of records count per table ---");
Map<String, Integer> records = TestCaseUtils.recordsInAllTables(daoHelper);
records.keySet().stream().filter(s -> s.equals("MEDIA_FILE") // 20
| s.equals("ARTIST") // 5
| s.equals("MUSIC_FOLDER")// 3
| s.equals("ALBUM"))// 5
.forEach(tableName -> System.out
.println("\t" + tableName + " : " + records.get(tableName).toString()));
// Music Folder Music must have 3 children
List<MediaFile> listeMusicChildren = mediaFileDao.getChildrenOf(
new File(MusicFolderTestData.resolveMusicFolderPath()).getPath());
Assert.assertEquals(3, listeMusicChildren.size());
// Music Folder Music2 must have 1 children
List<MediaFile> listeMusic2Children = mediaFileDao.getChildrenOf(
new File(MusicFolderTestData.resolveMusic2FolderPath()).getPath());
Assert.assertEquals(1, listeMusic2Children.size());
System.out.println("--- *********************** ---");
dataBasePopulated = true;
}
}
@Test
public void testSearchTypical() {
/*
* A simple test that is expected to easily detect API syntax differences when updating lucene.
* Complete route coverage and data coverage in this case alone are not conscious.
*/
List<MusicFolder> allMusicFolders = musicFolderDao.getAllMusicFolders();
Assert.assertEquals(3, allMusicFolders.size());
// *** testSearch() ***
String query = "Sarah Walker";
final SearchCriteria searchCriteria = new SearchCriteria();
searchCriteria.setQuery(query);
searchCriteria.setCount(Integer.MAX_VALUE);
searchCriteria.setOffset(0);
/*
* _ID3_ALBUMARTIST_ Sarah Walker/Nash Ensemble
* Should find any version of Lucene.
*/
SearchResult result = searchService.search(searchCriteria, allMusicFolders,
IndexType.ALBUM);
Assert.assertEquals("(0) Specify '" + query + "' as query, total Hits is", 1,
result.getTotalHits());
Assert.assertEquals("(1) Specify artist '" + query + "' as query. Artist SIZE is", 0,
result.getArtists().size());
Assert.assertEquals("(2) Specify artist '" + query + "' as query. Album SIZE is", 0,
result.getAlbums().size());
Assert.assertEquals("(3) Specify artist '" + query + "' as query, MediaFile SIZE is", 1,
result.getMediaFiles().size());
Assert.assertEquals("(4) ", MediaType.ALBUM, result.getMediaFiles().get(0).getMediaType());
Assert.assertEquals(
"(5) Specify artist '" + query + "' as query, and get a album. Name is ",
"_ID3_ALBUMARTIST_ Sarah Walker/Nash Ensemble",
result.getMediaFiles().get(0).getArtist());
Assert.assertEquals(
"(6) Specify artist '" + query + "' as query, and get a album. Name is ",
"_ID3_ALBUM_ Ravel - Chamber Music With Voice",
result.getMediaFiles().get(0).getAlbumName());
/*
* _ID3_ALBUM_ Ravel - Chamber Music With Voice
* Should find any version of Lucene.
*/
query = "music";
searchCriteria.setQuery(query);
result = searchService.search(searchCriteria, allMusicFolders, IndexType.ALBUM_ID3);
Assert.assertEquals("Specify '" + query + "' as query, total Hits is", 1,
result.getTotalHits());
Assert.assertEquals("(7) Specify '" + query + "' as query, and get a song. Artist SIZE is ",
0, result.getArtists().size());
Assert.assertEquals("(8) Specify '" + query + "' as query, and get a song. Album SIZE is ",
1, result.getAlbums().size());
Assert.assertEquals(
"(9) Specify '" + query + "' as query, and get a song. MediaFile SIZE is ", 0,
result.getMediaFiles().size());
Assert.assertEquals("(9) Specify '" + query + "' as query, and get a album. Name is ",
"_ID3_ALBUMARTIST_ Sarah Walker/Nash Ensemble",
result.getAlbums().get(0).getArtist());
Assert.assertEquals("(10) Specify '" + query + "' as query, and get a album. Name is ",
"_ID3_ALBUM_ Ravel - Chamber Music With Voice",
result.getAlbums().get(0).getName());
/*
* _ID3_ALBUM_ Ravel - Chamber Music With Voice
* Should find any version of Lucene.
*/
query = "Ravel - Chamber Music";
searchCriteria.setQuery(query);
result = searchService.search(searchCriteria, allMusicFolders, IndexType.SONG);
Assert.assertEquals("(11) Specify album '" + query + "' as query, total Hits is", 2,
result.getTotalHits());
Assert.assertEquals("(12) Specify album '" + query + "', and get a song. Artist SIZE is", 0,
result.getArtists().size());
Assert.assertEquals("(13) Specify album '" + query + "', and get a song. Album SIZE is", 0,
result.getAlbums().size());
Assert.assertEquals("(14) Specify album '" + query + "', and get a song. MediaFile SIZE is",
2, result.getMediaFiles().size());
Assert.assertEquals("(15) Specify album '" + query + "', and get songs. The first song is ",
"01 - Gaspard de la Nuit - i. Ondine", result.getMediaFiles().get(0).getTitle());
Assert.assertEquals(
"(16) Specify album '" + query + "', and get songs. The second song is ",
"02 - Gaspard de la Nuit - ii. Le Gibet", result.getMediaFiles().get(1).getTitle());
// *** testSearchByName() ***
/*
* _ID3_ALBUM_ Sackcloth 'n' Ashes
* Should be 1 in Lucene 3.0(Because Single quate is not a delimiter).
*/
query = "Sackcloth 'n' Ashes";
ParamSearchResult<Album> albumResult = searchService.searchByName(query, 0,
Integer.MAX_VALUE, allMusicFolders, Album.class);
Assert.assertEquals(
"(17) Specify album name '" + query + "' as the name, and get an album.", 1,
albumResult.getItems().size());
Assert.assertEquals("(18) Specify '" + query + "' as the name, The album name is ",
"_ID3_ALBUM_ Sackcloth 'n' Ashes", albumResult.getItems().get(0).getName());
Assert.assertEquals(
"(19) Whether the acquired album contains data of the specified album name", 1L,
albumResult.getItems().stream()
.filter(r -> "_ID3_ALBUM_ Sackcloth \'n\' Ashes".equals(r.getName()))
.count());
/*
* Should be 0 in Lucene 3.0(Since the slash is not a delimiter).
*/
query = "lker/Nash";
ParamSearchResult<ArtistID3> artistId3Result = searchService.searchByName(query, 0,
Integer.MAX_VALUE, allMusicFolders, ArtistID3.class);
Assert.assertEquals("(20) Specify '" + query + "' as the name, and get an artist.", 0,
artistId3Result.getItems().size());
ParamSearchResult<Artist> artistResult = searchService.searchByName(query, 0,
Integer.MAX_VALUE, allMusicFolders, Artist.class);
Assert.assertEquals("(21) Specify '" + query + "' as the name, and get an artist.", 0,
artistResult.getItems().size());
// *** testGetRandomSongs() ***
/*
* Regardless of the Lucene version,
* RandomSearchCriteria can specify null and means the maximum range.
* 11 should be obtainable.
*/
RandomSearchCriteria randomSearchCriteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count
null, // genre,
null, // fromYear
null, // toYear
allMusicFolders // musicFolders
);
List<MediaFile> allRandomSongs = searchService.getRandomSongs(randomSearchCriteria);
Assert.assertEquals(
"(22) Specify MAX_VALUE as the upper limit, and randomly acquire songs.", 11,
allRandomSongs.size());
/*
* Regardless of the Lucene version,
* 7 should be obtainable.
*/
randomSearchCriteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count
null, // genre,
1900, // fromYear
null, // toYear
allMusicFolders // musicFolders
);
allRandomSongs = searchService.getRandomSongs(randomSearchCriteria);
Assert.assertEquals("(23) Specify 1900 as 'fromYear', and randomly acquire songs.", 7,
allRandomSongs.size());
/*
* Regardless of the Lucene version,
* It should be 0 because it is a non-existent genre.
*/
randomSearchCriteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count
"Chamber Music", // genre,
null, // fromYear
null, // toYear
allMusicFolders // musicFolders
);
allRandomSongs = searchService.getRandomSongs(randomSearchCriteria);
Assert.assertEquals("(24) Specify music as 'genre', and randomly acquire songs.", 0,
allRandomSongs.size());
// *** testGetRandomAlbums() ***
/*
* Acquisition of maximum number(5).
*/
List<Album> allAlbums = albumDao.getAlphabeticalAlbums(0, 0, true, true, allMusicFolders);
Assert.assertEquals("(25) Get all albums with Dao.", 5, allAlbums.size());
List<MediaFile> allRandomAlbums = searchService.getRandomAlbums(Integer.MAX_VALUE,
allMusicFolders);
Assert.assertEquals("(26) Specify Integer.MAX_VALUE as the upper limit,"
+ "and randomly acquire albums(file struct).", 5, allRandomAlbums.size());
/*
* Acquisition of maximum number(5).
*/
List<Album> allRandomAlbumsId3 = searchService.getRandomAlbumsId3(Integer.MAX_VALUE,
allMusicFolders);
Assert.assertEquals(
"(27) Specify Integer.MAX_VALUE as the upper limit, and randomly acquire albums(ID3).",
5, allRandomAlbumsId3.size());
/*
* Total is 4.
*/
query = "ID 3 ARTIST";
searchCriteria.setQuery(query);
result = searchService.search(searchCriteria, allMusicFolders, IndexType.ARTIST_ID3);
Assert.assertEquals("(28) Specify '" + query + "', total Hits is", 4,
result.getTotalHits());
Assert.assertEquals("(29) Specify '" + query + "', and get an artists. Artist SIZE is ", 4,
result.getArtists().size());
Assert.assertEquals("(30) Specify '" + query + "', and get a artists. Album SIZE is ", 0,
result.getAlbums().size());
Assert.assertEquals("(31) Specify '" + query + "', and get a artists. MediaFile SIZE is ",
0, result.getMediaFiles().size());
/*
* Three hits to the artist.
* ALBUMARTIST is not registered with these.
* Therefore, the registered value of ARTIST is substituted in ALBUMARTIST.
*/
long count = result.getArtists().stream()
.filter(a -> a.getName().startsWith("_ID3_ARTIST_")).count();
Assert.assertEquals("(32) Artist whose name contains \\\"_ID3_ARTIST_\\\" is 3 records.",
3L, count);
/*
* The structure of "01 - Sonata Violin & Cello I. Allegro.ogg"
* ARTIST -> _ID3_ARTIST_ Sarah Walker/Nash Ensemble
* ALBUMARTIST -> _ID3_ALBUMARTIST_ Sarah Walker/Nash Ensemble
* (The result must not contain duplicates. And ALBUMARTIST must be returned correctly.)
*/
count = result.getArtists().stream()
.filter(a -> a.getName().startsWith("_ID3_ALBUMARTIST_")).count();
Assert.assertEquals("(33) Artist whose name is \"_ID3_ARTIST_\" is 1 records.", 1L, count);
/*
* Below is a simple loop test.
* How long is the total time?
*/
int countForEachMethod = 500;
String[] randomWords4Search = createRandomWords(countForEachMethod);
String[] randomWords4SearchByName = createRandomWords(countForEachMethod);
Timer globalTimer = metrics
.timer(MetricRegistry.name(SearchServiceTestCase.class, "Timer.global"));
final Timer.Context globalTimerContext = globalTimer.time();
System.out.println("--- Random search (" + countForEachMethod * 5 + " times) ---");
// testSearch()
Arrays.stream(randomWords4Search).forEach(w -> {
searchCriteria.setQuery(w);
searchService.search(searchCriteria, allMusicFolders, IndexType.ALBUM);
});
// testSearchByName()
Arrays.stream(randomWords4SearchByName).forEach(w -> {
searchService.searchByName(w, 0, Integer.MAX_VALUE, allMusicFolders, Artist.class);
});
// testGetRandomSongs()
RandomSearchCriteria criteria = new RandomSearchCriteria(Integer.MAX_VALUE, // count
null, // genre,
null, // fromYear
null, // toYear
allMusicFolders // musicFolders
);
for (int i = 0; i < countForEachMethod; i++) {
searchService.getRandomSongs(criteria);
}
// testGetRandomAlbums()
for (int i = 0; i < countForEachMethod; i++) {
searchService.getRandomAlbums(Integer.MAX_VALUE, allMusicFolders);
}
// testGetRandomAlbumsId3()
for (int i = 0; i < countForEachMethod; i++) {
searchService.getRandomAlbumsId3(Integer.MAX_VALUE, allMusicFolders);
}
globalTimerContext.stop();
/*
* Whether or not IndexReader is exhausted.
*/
query = "Sarah Walker";
searchCriteria.setQuery(query);
result = searchService.search(searchCriteria, allMusicFolders, IndexType.ALBUM);
Assert.assertEquals("(35) Can the normal case be implemented.", 0,
result.getArtists().size());
Assert.assertEquals("(36) Can the normal case be implemented.", 0,
result.getAlbums().size());
Assert.assertEquals("(37) Can the normal case be implemented.", 1,
result.getMediaFiles().size());
Assert.assertEquals("(38) Can the normal case be implemented.", MediaType.ALBUM,
result.getMediaFiles().get(0).getMediaType());
Assert.assertEquals("(39) Can the normal case be implemented.",
"_ID3_ALBUMARTIST_ Sarah Walker/Nash Ensemble",
result.getMediaFiles().get(0).getArtist());
System.out.println("--- SUCCESS ---");
ConsoleReporter reporter = ConsoleReporter.forRegistry(metrics)
.convertRatesTo(TimeUnit.SECONDS).convertDurationsTo(TimeUnit.MILLISECONDS).build();
reporter.report();
System.out.println("End. ");
}
private static String[] createRandomWords(int count) {
String[] randomStrings = new String[count];
Random random = new Random();
for (int i = 0; i < count; i++) {
char[] word = new char[random.nextInt(8) + 3];
for (int j = 0; j < word.length; j++) {
word[j] = (char) ('a' + random.nextInt(26));
}
randomStrings[i] = new String(word);
}
return randomStrings;
}
}
Loading…
Cancel
Save