Split SearchService

Signed-off-by: Andrew DeMaria <lostonamountain@gmail.com>
master
tesshucom 5 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>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
</dependency>
<dependency>
<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.SecurityService;
import org.airsonic.player.service.SettingsService;
import org.airsonic.player.service.search.IndexType;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
@ -87,13 +88,13 @@ public class SearchController {
criteria.setCount(MATCH_COUNT);
criteria.setQuery(query);
SearchResult artists = searchService.search(criteria, musicFolders, SearchService.IndexType.ARTIST);
SearchResult artists = searchService.search(criteria, musicFolders, IndexType.ARTIST);
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());
SearchResult songs = searchService.search(criteria, musicFolders, SearchService.IndexType.SONG);
SearchResult songs = searchService.search(criteria, musicFolders, IndexType.SONG);
command.setSongs(songs.getMediaFiles());
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.i18n.LocaleResolver;
import org.airsonic.player.service.*;
import org.airsonic.player.service.search.IndexType;
import org.airsonic.player.util.Pair;
import org.airsonic.player.util.StringUtil;
import org.airsonic.player.util.Util;
@ -685,7 +686,7 @@ public class SubsonicRESTController {
criteria.setOffset(getIntParameter(request, "offset", 0));
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();
searchResult.setOffset(result.getOffset());
searchResult.setTotalHits(result.getTotalHits());
@ -713,21 +714,21 @@ public class SubsonicRESTController {
criteria.setQuery(StringUtils.trimToEmpty(query));
criteria.setCount(getIntParameter(request, "artistCount", 20));
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()) {
searchResult.getArtist().add(createJaxbArtist(mediaFile, username));
}
criteria.setCount(getIntParameter(request, "albumCount", 20));
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()) {
searchResult.getAlbum().add(createJaxbChild(player, mediaFile, username));
}
criteria.setCount(getIntParameter(request, "songCount", 20));
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()) {
searchResult.getSong().add(createJaxbChild(player, mediaFile, username));
}
@ -752,21 +753,21 @@ public class SubsonicRESTController {
criteria.setQuery(StringUtils.trimToEmpty(query));
criteria.setCount(getIntParameter(request, "artistCount", 20));
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()) {
searchResult.getArtist().add(createJaxbArtist(new ArtistID3(), artist, username));
}
criteria.setCount(getIntParameter(request, "albumCount", 20));
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()) {
searchResult.getAlbum().add(createJaxbAlbum(new AlbumID3(), album, username));
}
criteria.setCount(getIntParameter(request, "songCount", 20));
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()) {
searchResult.getSong().add(createJaxbChild(player, song, username));
}

@ -1,666 +1,86 @@
/*
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;
import com.google.common.collect.Lists;
import org.airsonic.player.dao.AlbumDao;
import org.airsonic.player.dao.ArtistDao;
import org.airsonic.player.domain.*;
import org.airsonic.player.util.FileUtil;
import org.apache.lucene.analysis.*;
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.analysis.tokenattributes.TermAttribute;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericField;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.Term;
import org.apache.lucene.queryParser.MultiFieldQueryParser;
import org.apache.lucene.queryParser.QueryParser;
import org.apache.lucene.search.*;
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.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.NumericUtils;
import org.apache.lucene.util.Version;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.*;
import static org.airsonic.player.service.SearchService.IndexType.*;
/**
* Performs Lucene-based searching and indexing.
*
* @author Sindre Mehus
* @version $Id$
* @see MediaScannerService
*/
@Service
public class SearchService {
private static final Logger LOG = LoggerFactory.getLogger(SearchService.class);
private static final String FIELD_ID = "id";
private static final String FIELD_TITLE = "title";
private static final String FIELD_ALBUM = "album";
private static final String FIELD_ARTIST = "artist";
private static final String FIELD_GENRE = "genre";
private static final String FIELD_YEAR = "year";
private static final String FIELD_MEDIA_TYPE = "mediaType";
private static final String FIELD_FOLDER = "folder";
private static final String FIELD_FOLDER_ID = "folderId";
private static final Version LUCENE_VERSION = Version.LUCENE_30;
private static final String LUCENE_DIR = "lucene2";
@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;
}
}
}
/*
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;
import org.airsonic.player.domain.Album;
import org.airsonic.player.domain.Artist;
import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.domain.ParamSearchResult;
import org.airsonic.player.domain.RandomSearchCriteria;
import org.airsonic.player.domain.SearchCriteria;
import org.airsonic.player.domain.SearchResult;
import org.airsonic.player.service.search.IndexType;
import java.util.List;
/**
* Performs Lucene-based searching and indexing.
*
* @author Sindre Mehus
* @version $Id$
* @see MediaScannerService
*/
public interface SearchService {
void startIndexing();
void index(MediaFile mediaFile);
void index(Artist artist, MusicFolder musicFolder);
void index(Album album);
void stopIndexing();
SearchResult search(SearchCriteria criteria, List<MusicFolder> musicFolders,
IndexType indexType);
/**
* Returns a number of random songs.
*
* @param criteria Search criteria.
* @return List of random songs.
*/
List<MediaFile> getRandomSongs(RandomSearchCriteria criteria);
/**
* 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.
*/
List<MediaFile> getRandomAlbums(int count, List<MusicFolder> musicFolders);
/**
* 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.
*/
List<Album> getRandomAlbumsId3(int count, List<MusicFolder> musicFolders);
<T> ParamSearchResult<T> searchByName(
String name, int offset, int count, List<MusicFolder> folderList, Class<T> clazz);
}

@ -25,6 +25,7 @@ import org.airsonic.player.domain.AlbumListType;
import org.airsonic.player.domain.MediaFile;
import org.airsonic.player.domain.Playlist;
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.SonosServiceRegistration;
import org.airsonic.player.service.sonos.SonosSoapFault;
@ -264,13 +265,13 @@ public class SonosService implements SonosSoap {
public SearchResponse search(Search parameters) {
String id = parameters.getId();
SearchService.IndexType indexType;
IndexType indexType;
if (ID_SEARCH_ARTISTS.equals(id)) {
indexType = SearchService.IndexType.ARTIST;
indexType = IndexType.ARTIST;
} else if (ID_SEARCH_ALBUMS.equals(id)) {
indexType = SearchService.IndexType.ALBUM;
indexType = IndexType.ALBUM;
} else if (ID_SEARCH_SONGS.equals(id)) {
indexType = SearchService.IndexType.SONG;
indexType = IndexType.SONG;
} else {
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.domain.*;
import org.airsonic.player.service.*;
import org.airsonic.player.service.search.IndexType;
import org.airsonic.player.util.StringUtil;
import org.airsonic.player.util.Util;
import org.springframework.beans.factory.annotation.Autowired;
@ -523,7 +524,7 @@ public class SonosHelper {
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.setCount(count);

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