diff --git a/airsonic-main/pom.xml b/airsonic-main/pom.xml
index e2166468..0e3e5079 100755
--- a/airsonic-main/pom.xml
+++ b/airsonic-main/pom.xml
@@ -155,7 +155,10 @@
-
+
+ commons-collections
+ commons-collections
+
commons-fileupload
diff --git a/airsonic-main/src/main/java/org/airsonic/player/controller/SearchController.java b/airsonic-main/src/main/java/org/airsonic/player/controller/SearchController.java
index fd60b784..49c06dae 100644
--- a/airsonic-main/src/main/java/org/airsonic/player/controller/SearchController.java
+++ b/airsonic-main/src/main/java/org/airsonic/player/controller/SearchController.java
@@ -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));
diff --git a/airsonic-main/src/main/java/org/airsonic/player/controller/SubsonicRESTController.java b/airsonic-main/src/main/java/org/airsonic/player/controller/SubsonicRESTController.java
index 68405a03..cd98aee0 100644
--- a/airsonic-main/src/main/java/org/airsonic/player/controller/SubsonicRESTController.java
+++ b/airsonic-main/src/main/java/org/airsonic/player/controller/SubsonicRESTController.java
@@ -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 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));
}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java b/airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java
index fe146d8a..ec0a2c6e 100644
--- a/airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/SearchService.java
@@ -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 .
-
- 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 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 musicFolderQueries = new ArrayList();
- 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 getRandomSongs(RandomSearchCriteria criteria) {
- List result = new ArrayList();
-
- 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 rangeQuery = NumericRangeQuery.newIntRange(FIELD_YEAR, criteria.getFromYear(), criteria.getToYear(), true, true);
- query.add(rangeQuery, BooleanClause.Occur.MUST);
- }
-
- List musicFolderQueries = new ArrayList();
- 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 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 getRandomAlbums(int count, List musicFolders) {
- List result = new ArrayList();
-
- IndexReader reader = null;
- try {
- reader = createIndexReader(ALBUM);
- Searcher searcher = new IndexSearcher(reader);
-
- List musicFolderQueries = new ArrayList();
- 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 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 getRandomAlbumsId3(int count, List musicFolders) {
- List result = new ArrayList();
-
- IndexReader reader = null;
- try {
- reader = createIndexReader(ALBUM_ID3);
- Searcher searcher = new IndexSearcher(reader);
-
- List musicFolderQueries = new ArrayList();
- 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 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 ParamSearchResult searchByName(String name, int offset, int count, List folderList, Class 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 result = new ParamSearchResult();
- // 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 void addIfNotNull(T value, List 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 boosts;
-
- private IndexType(String[] fields, String boostedField) {
- this.fields = fields;
- boosts = new HashMap();
- 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 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 .
+
+ 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 musicFolders,
+ IndexType indexType);
+
+ /**
+ * Returns a number of random songs.
+ *
+ * @param criteria Search criteria.
+ * @return List of random songs.
+ */
+ List 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 getRandomAlbums(int count, List 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 getRandomAlbumsId3(int count, List musicFolders);
+
+ ParamSearchResult searchByName(
+ String name, int offset, int count, List folderList, Class clazz);
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/SonosService.java b/airsonic-main/src/main/java/org/airsonic/player/service/SonosService.java
index cec40454..d7b91042 100644
--- a/airsonic-main/src/main/java/org/airsonic/player/service/SonosService.java
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/SonosService.java
@@ -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);
}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java
new file mode 100644
index 00000000..3e980e6e
--- /dev/null
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/AnalyzerFactory.java
@@ -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 .
+
+ 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;
+ }
+ }
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java
new file mode 100644
index 00000000..e73d0a95
--- /dev/null
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/DocumentFactory.java
@@ -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 .
+
+ 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;
+ }
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java
new file mode 100644
index 00000000..88830515
--- /dev/null
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/FieldNames.java
@@ -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 .
+
+ 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";
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java
new file mode 100644
index 00000000..d0db125a
--- /dev/null
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexManager.java
@@ -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 .
+
+ 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);
+ }
+ }
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java
new file mode 100644
index 00000000..c52a5a73
--- /dev/null
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/IndexType.java
@@ -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 .
+
+ 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 boosts(SimpleEntry... entry) {
+ Map 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 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 boosts;
+
+ private final String[] fields;
+
+ private IndexType(String[] fieldNames, Map 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 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;
+ }
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java
new file mode 100644
index 00000000..f2427c97
--- /dev/null
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/QueryFactory.java
@@ -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 .
+
+ 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 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 musicFolderQueries = new ArrayList();
+ 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 rangeQuery = NumericRangeQuery.newIntRange(FieldNames.YEAR,
+ criteria.getFromYear(), criteria.getToYear(), true, true);
+ query.add(rangeQuery, BooleanClause.Occur.MUST);
+ }
+
+ List musicFolderQueries = new ArrayList();
+ 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 musicFolders) {
+ List musicFolderQueries = new ArrayList();
+ 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 musicFolders) {
+
+ List musicFolderQueries = new ArrayList();
+ 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;
+ }
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java
new file mode 100644
index 00000000..adbef492
--- /dev/null
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceImpl.java
@@ -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 .
+
+ 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 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 List createRandomDocsList(
+ int count, Searcher searcher, Query query, BiConsumer, Integer> id2ListCallBack)
+ throws IOException {
+
+ List docs = Arrays
+ .stream(searcher.search(query, Integer.MAX_VALUE).scoreDocs)
+ .map(sd -> sd.doc)
+ .collect(Collectors.toList());
+
+ List 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 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 getRandomAlbums(int count, List 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 getRandomAlbumsId3(int count, List 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 ParamSearchResult searchByName(String name, int offset, int count,
+ List folderList, Class 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 result = new ParamSearchResult();
+ 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);
+ }
+ }
+ }
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java b/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java
new file mode 100644
index 00000000..a6ba41f6
--- /dev/null
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/search/SearchServiceUtilities.java
@@ -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 .
+
+ 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 round = (i) -> {
+ // return
+ // NumericUtils.floatToSortableInt(i);
+ return i.intValue();
+ };
+
+ public final Function getId = d -> {
+ return Integer.valueOf(d.get(FieldNames.ID));
+ };
+
+ public final BiConsumer, 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, 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, @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, @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, 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 getRootDirectory = (version) -> {
+ return new File(SettingsService.getAirsonicHome(), version);
+ };
+
+ public final BiFunction 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 void addIgnoreNull(ParamSearchResult dist, IndexType indexType,
+ int subjectId, Class 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);
+ }
+ }
+
+}
diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/sonos/SonosHelper.java b/airsonic-main/src/main/java/org/airsonic/player/service/sonos/SonosHelper.java
index 5a6a6b4f..82eb89c1 100644
--- a/airsonic-main/src/main/java/org/airsonic/player/service/sonos/SonosHelper.java
+++ b/airsonic-main/src/main/java/org/airsonic/player/service/sonos/SonosHelper.java
@@ -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);
diff --git a/airsonic-main/src/main/resources/applicationContext-sonos.xml b/airsonic-main/src/main/resources/applicationContext-sonos.xml
index 53fdb67f..ccf35125 100644
--- a/airsonic-main/src/main/resources/applicationContext-sonos.xml
+++ b/airsonic-main/src/main/resources/applicationContext-sonos.xml
@@ -20,7 +20,7 @@
-
+
diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java
new file mode 100644
index 00000000..e9f23390
--- /dev/null
+++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/AnalyzerFactoryTestCase.java
@@ -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 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 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 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 articleTerms = toTermString(n, queryArticle);
+ List indexArticleTerms = toTermString(n, queryArticle4Index);
+ List 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 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 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 termsLigature = toTermString(n, queryLigature);
+ List 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 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 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 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 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 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 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 toTermString(String str) {
+ return toTermString(null, str);
+ }
+
+ private List toTermString(String field, String str) {
+ List 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 toQueryTermString(String field, String str) {
+ List 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;
+ }
+
+}
diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java
new file mode 100644
index 00000000..9d282708
--- /dev/null
+++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/QueryFactoryTestCase.java
@@ -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 SINGLE_FOLDERS = Arrays.asList(MUSIC_FOLDER1);
+ private static final List 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());
+ }
+
+}
diff --git a/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java b/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java
new file mode 100644
index 00000000..e2871753
--- /dev/null
+++ b/airsonic-main/src/test/java/org/airsonic/player/service/search/SearchServiceTestCase.java
@@ -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 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 listeMusicChildren = mediaFileDao.getChildrenOf(
+ new File(MusicFolderTestData.resolveMusicFolderPath()).getPath());
+ Assert.assertEquals(3, listeMusicChildren.size());
+ // Music Folder Music2 must have 1 children
+ List 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 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 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 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 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 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 allAlbums = albumDao.getAlphabeticalAlbums(0, 0, true, true, allMusicFolders);
+ Assert.assertEquals("(25) Get all albums with Dao.", 5, allAlbums.size());
+ List 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 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;
+ }
+
+}