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; + } + +}