net.sourceforge.subsonic.service.SearchService.java Source code

Java tutorial

Introduction

Here is the source code for net.sourceforge.subsonic.service.SearchService.java

Source

/*
 This file is part of Subsonic.
    
 Subsonic 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.
    
 Subsonic 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 Subsonic.  If not, see <http://www.gnu.org/licenses/>.
    
 Copyright 2009 (C) Sindre Mehus
 */
package net.sourceforge.subsonic.service;

import net.sourceforge.subsonic.Logger;
import net.sourceforge.subsonic.domain.MediaLibraryStatistics;
import net.sourceforge.subsonic.domain.MusicFile;
import net.sourceforge.subsonic.domain.MusicFileInfo;
import net.sourceforge.subsonic.domain.MusicFolder;
import net.sourceforge.subsonic.domain.RandomSearchCriteria;
import net.sourceforge.subsonic.domain.SearchCriteria;
import net.sourceforge.subsonic.domain.SearchResult;
import net.sourceforge.subsonic.util.StringUtil;

import static net.sourceforge.subsonic.service.LuceneSearchService.IndexType.*;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.SortedSet;
import java.util.Timer;
import java.util.TimerTask;
import java.util.TreeMap;
import java.util.TreeSet;

/**
 * Provides services for searching for music.
 *
 * @author Sindre Mehus
 */
public class SearchService {

    private static final int INDEX_VERSION = 14;
    private static final Random RANDOM = new Random(System.currentTimeMillis());
    private static final Logger LOG = Logger.getLogger(SearchService.class);

    private Map<File, Line> cachedIndex;
    private List<Line> cachedSongs;
    private List<Line> cachedArtists;
    private SortedSet<Line> cachedAlbums; // Sorted chronologically.
    private SortedSet<String> cachedGenres;
    private MediaLibraryStatistics statistics;

    private boolean creatingIndex;
    private Timer timer;
    private SettingsService settingsService;
    private SecurityService securityService;
    private MusicFileService musicFileService;
    private MusicInfoService musicInfoService;
    private LuceneSearchService luceneSearchService;

    /**
     * Returns whether the search index exists.
     *
     * @return Whether the search index exists.
     */
    private synchronized boolean isIndexCreated() {
        return getIndexFile().exists();
    }

    /**
     * Returns whether the search index is currently being created.
     *
     * @return Whether the search index is currently being created.
     */
    public synchronized boolean isIndexBeingCreated() {
        return creatingIndex;
    }

    /**
     * Generates the search index.  If the index already exists it will be
     * overwritten.  The index is created asynchronously, i.e., this method returns
     * before the index is created.
     */
    public synchronized void createIndex() {
        if (isIndexBeingCreated()) {
            return;
        }
        creatingIndex = true;

        Thread thread = new Thread("Search Index Generator") {
            @Override
            public void run() {
                doCreateIndex();
            }
        };

        thread.setPriority(Thread.MIN_PRIORITY);
        thread.start();
    }

    private void doCreateIndex() {
        deleteOldIndexFiles();
        LOG.info("Starting to create search index.");
        PrintWriter writer = null;

        try {

            // Get existing index.
            Map<File, Line> oldIndex = getIndex();

            writer = new PrintWriter(getIndexFile(), StringUtil.ENCODING_UTF8);

            // Create a scanner for visiting all music files.
            Scanner scanner = new Scanner(writer, oldIndex, settingsService.getAllMusicFolders());

            // Read entire music directory.
            for (MusicFolder musicFolder : settingsService.getAllMusicFolders()) {
                MusicFile root = musicFileService.getMusicFile(musicFolder.getPath());
                root.accept(scanner);
            }

            // Clear memory cache.
            writer.flush();
            writer.close();
            synchronized (this) {
                cachedIndex = null;
                cachedSongs = null;
                cachedAlbums = null;
                cachedGenres = null;
                statistics = null;
                getIndex();
            }

            // Now, clean up music_file_info table.
            cleanMusicFileInfo();

            // Update Lucene search index.
            LOG.info("Updating Lucene search index.");
            luceneSearchService.createIndex(SONG, cachedSongs);
            luceneSearchService.createIndex(ALBUM, cachedAlbums);
            luceneSearchService.createIndex(ARTIST, cachedArtists);

            // Don't need this any longer.
            cachedArtists.clear();

            LOG.info("Created search index with " + scanner.getCount() + " entries.");

        } catch (Exception x) {
            LOG.error("Failed to create search index.", x);
        } finally {
            creatingIndex = false;
            IOUtils.closeQuietly(writer);
        }
    }

    private void cleanMusicFileInfo() {

        // Create sorted set of albums.
        SortedSet<String> albums = new TreeSet<String>();
        for (Line line : cachedAlbums) {
            albums.add(line.file.getPath());
        }

        // Page through music_file_info table.
        int offset = 0;
        int count = 100;
        while (true) {
            List<MusicFileInfo> infos = musicInfoService.getAllMusicFileInfos(offset, count);
            if (infos.isEmpty()) {
                break;
            }
            offset += infos.size();

            for (MusicFileInfo info : infos) {

                // Disable row if album does not exist on disk any more.
                if (info.isEnabled() && !albums.contains(info.getPath())) {
                    info.setEnabled(false);
                    musicInfoService.updateMusicFileInfo(info);
                    LOG.debug("Logically deleting info for album " + info.getPath() + ". Not found on disk.");
                }

                // Enable row if album has reoccurred on disk.
                else if (!info.isEnabled() && albums.contains(info.getPath())) {
                    info.setEnabled(true);
                    musicInfoService.updateMusicFileInfo(info);
                    LOG.debug("Logically undeleting info for album " + info.getPath() + ". Found on disk.");
                }
            }
        }
    }

    /**
     * Schedule background execution of index creation.
     */
    public synchronized void schedule() {
        if (timer != null) {
            timer.cancel();
        }
        timer = new Timer(true);

        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                createIndex();
            }
        };

        long daysBetween = settingsService.getIndexCreationInterval();
        int hour = settingsService.getIndexCreationHour();

        if (daysBetween == -1) {
            LOG.info("Automatic index creation disabled.");
            return;
        }

        Date now = new Date();
        Calendar cal = Calendar.getInstance();
        cal.setTime(now);
        cal.set(Calendar.HOUR_OF_DAY, hour);
        cal.set(Calendar.MINUTE, 0);
        cal.set(Calendar.SECOND, 0);

        if (cal.getTime().before(now)) {
            cal.add(Calendar.DATE, 1);
        }

        Date firstTime = cal.getTime();
        long period = daysBetween * 24L * 3600L * 1000L;
        timer.schedule(task, firstTime, period);

        LOG.info("Automatic index creation scheduled to run every " + daysBetween + " day(s), starting at "
                + firstTime);

        // In addition, create index immediately if it doesn't exist on disk.
        if (!isIndexCreated()) {
            LOG.info("Search index not found on disk. Creating it.");
            createIndex();
        }
    }

    /**
     * Search for music files fulfilling the given search criteria.
     *
     * @param criteria The search criteria.
     * @param indexType The search index to use.
     * @return The search result.
     * @throws IOException If an I/O error occurs.
     */
    public synchronized SearchResult search(SearchCriteria criteria, LuceneSearchService.IndexType indexType)
            throws IOException {

        if (!isIndexCreated() || isIndexBeingCreated()) {
            SearchResult empty = new SearchResult();
            empty.setOffset(criteria.getOffset());
            empty.setMusicFiles(Collections.<MusicFile>emptyList());
            return empty;
        }

        return luceneSearchService.search(criteria, indexType);
    }

    /**
     * Returns media library statistics, including the number of artists, albums and songs.
     *
     * @return Media library statistics.
     * @throws IOException If an I/O error occurs.
     */
    public MediaLibraryStatistics getStatistics() throws IOException {
        if (!isIndexCreated() || isIndexBeingCreated()) {
            return null;
        }

        // Ensure that index is read to memory.
        getIndex();
        return statistics;
    }

    /**
     * Returns a number of random songs.
     *
     * @param criteria Search criteria.
     * @return Array of random songs.
     * @throws IOException If an I/O error occurs.
     */
    public List<MusicFile> getRandomSongs(RandomSearchCriteria criteria) throws IOException {
        int count = criteria.getCount();
        List<MusicFile> result = new ArrayList<MusicFile>(count);

        if (!isIndexCreated() || isIndexBeingCreated()) {
            return result;
        }

        // Ensure that index is read to memory.
        getIndex();

        if (cachedSongs == null || cachedSongs.isEmpty()) {
            return result;
        }

        String genre = criteria.getGenre();
        Integer fromYear = criteria.getFromYear();
        Integer toYear = criteria.getToYear();
        String musicFolderPath = null;
        if (criteria.getMusicFolderId() != null) {
            MusicFolder musicFolder = settingsService.getMusicFolderById(criteria.getMusicFolderId());
            musicFolderPath = musicFolder.getPath().getPath().toUpperCase() + File.separator;
        }

        // Filter by genre, year and music folder.
        List<Line> songs = new ArrayList<Line>(cachedSongs.size());
        String fromYearString = fromYear == null ? null : String.valueOf(fromYear);
        String toYearString = toYear == null ? null : String.valueOf(toYear);

        for (Line song : cachedSongs) {

            // Skip if wrong genre.
            if (genre != null && !genre.equalsIgnoreCase(song.genre)) {
                continue;
            }

            // Skip podcasts if no genre is given.
            if (genre == null && "podcast".equalsIgnoreCase(song.genre)) {
                continue;
            }

            // Skip if wrong year.
            if (fromYearString != null) {
                if (song.year == null || song.year.compareTo(fromYearString) < 0) {
                    continue;
                }
            }
            if (toYearString != null) {
                if (song.year == null || song.year.compareTo(toYearString) > 0) {
                    continue;
                }
            }

            // Skip if wrong music folder.
            if (musicFolderPath != null) {
                String filePath = song.file.getPath().toUpperCase();
                if (!filePath.startsWith(musicFolderPath)) {
                    continue;
                }
            }

            songs.add(song);
        }

        if (songs.isEmpty()) {
            return result;
        }

        // Note: To avoid duplicates, we iterate over more than the requested number of songs.
        for (int i = 0; i < count * 10; i++) {
            int n = RANDOM.nextInt(songs.size());
            File file = songs.get(n).file;

            if (file.exists() && securityService.isReadAllowed(file)) {
                MusicFile musicFile = musicFileService.getMusicFile(file);
                if (!result.contains(musicFile) && !musicFile.isVideo()) {
                    result.add(musicFile);

                    // Enough songs found?
                    if (result.size() == count) {
                        break;
                    }
                }
            }
        }

        return result;
    }

    /**
     * Returns all genres in the music collection.
     *
     * @return Sorted set of genres.
     * @throws IOException If an I/O error occurs.
     */
    public Set<String> getGenres() throws IOException {

        if (!isIndexCreated() || isIndexBeingCreated()) {
            return Collections.emptySet();
        }

        // Ensure that index is read to memory.
        getIndex();

        return Collections.unmodifiableSortedSet(cachedGenres);
    }

    /**
     * Returns a number of random albums.
     *
     * @param count Maximum number of albums to return.
     * @return Array of random albums.
     * @throws IOException If an I/O error occurs.
     */
    public List<MusicFile> getRandomAlbums(int count) throws IOException {
        List<MusicFile> result = new ArrayList<MusicFile>(count);

        if (!isIndexCreated() || isIndexBeingCreated()) {
            return result;
        }

        // Ensure that index is read to memory.
        getIndex();

        if (cachedSongs == null || cachedSongs.isEmpty()) {
            return result;
        }

        // Note: To avoid duplicates, we iterate over more than the requested number of items.
        for (int i = 0; i < count * 20; i++) {
            int n = RANDOM.nextInt(cachedSongs.size());
            File file = cachedSongs.get(n).file;

            if (file.exists() && securityService.isReadAllowed(file)) {
                MusicFile album = musicFileService.getMusicFile(file.getParentFile());
                if (!album.isRoot() && !result.contains(album)) {
                    result.add(album);

                    // Enough items found?
                    if (result.size() == count) {
                        break;
                    }
                }
            }
        }

        return result;
    }

    /**
     * Returns a number of least recently modified music files. Only directories (albums) are returned.
     *
     * @param offset Number of music files to skip.
     * @param count  Maximum number of music files to return.
     * @return Array of new music files.
     * @throws IOException If an I/O error occurs.
     */
    public List<MusicFile> getNewestAlbums(int offset, int count) throws IOException {
        List<MusicFile> result = new ArrayList<MusicFile>(count);

        if (!isIndexCreated() || isIndexBeingCreated()) {
            return result;
        }

        // Ensure that index is read to memory.
        getIndex();

        int n = 0;
        for (Line line : cachedAlbums) {
            if (n == count + offset) {
                break;
            }
            if (line.file.exists() && securityService.isReadAllowed(line.file)) {
                if (n >= offset) {
                    result.add(musicFileService.getMusicFile(line.file));
                }
                n++;
            }
        }

        return result;
    }

    /**
     * Returns the creation date for the given file.
     *
     * @param musicFile The file in question.
     * @return The creation date, or {@code null} if not found.
     */
    public Date getCreationDate(MusicFile musicFile) throws IOException {
        if (!isIndexCreated() || isIndexBeingCreated()) {
            return null;
        }

        Line line = getIndex().get(musicFile.getFile());
        return line == null ? null : new Date(line.created);
    }

    /**
     * Returns the search index as a map from files to {@link Line} instances.
     *
     * @return The search index.
     * @throws IOException If an I/O error occurs.
     */
    private synchronized Map<File, Line> getIndex() throws IOException {
        if (!isIndexCreated()) {
            return new TreeMap<File, Line>();
        }

        if (cachedIndex != null) {
            return cachedIndex;
        }

        cachedIndex = new TreeMap<File, Line>();

        // Statistics.
        int songCount = 0;
        long totalLength = 0;
        Set<String> artists = new HashSet<String>();
        Set<String> albums = new HashSet<String>();

        cachedSongs = new ArrayList<Line>();
        cachedArtists = new ArrayList<Line>();
        cachedGenres = new TreeSet<String>();
        cachedAlbums = new TreeSet<Line>(new Comparator<Line>() {
            public int compare(Line line1, Line line2) {
                if (line2.created < line1.created) {
                    return -1;
                }
                if (line1.created < line2.created) {
                    return 1;
                }
                return 0;
            }
        });

        BufferedReader reader = new BufferedReader(
                new InputStreamReader(new FileInputStream(getIndexFile()), StringUtil.ENCODING_UTF8));

        // TODO: Calculate artist/album count from cachedArtists/cachedAlbums.

        try {

            for (String s = reader.readLine(); s != null; s = reader.readLine()) {

                try {

                    Line line = Line.parse(s);
                    cachedIndex.put(line.file, line);

                    if (line.isAlbum) {
                        cachedAlbums.add(line);
                    } else if (line.isArtist) {
                        cachedArtists.add(line);
                    } else if (line.isFile) {
                        songCount++;
                        totalLength += line.length;
                        artists.add(line.artist);
                        albums.add(line.album);
                        cachedSongs.add(line);
                        if (line.genre != null) {
                            cachedGenres.add(line.genre);
                        }
                    }

                } catch (Exception x) {
                    LOG.error("An error occurred while reading index entry '" + s + "'.", x);
                }
            }
        } finally {
            reader.close();
        }

        statistics = new MediaLibraryStatistics(artists.size(), albums.size(), songCount, totalLength);

        return cachedIndex;
    }

    /**
     * Returns the file containing the index.
     *
     * @return The file containing the index.
     */
    private File getIndexFile() {
        return getIndexFile(INDEX_VERSION);
    }

    /**
     * Returns the index file for the given index version.
     *
     * @param version The index version.
     * @return The index file for the given index version.
     */
    private File getIndexFile(int version) {
        File home = SettingsService.getSubsonicHome();
        return new File(home, "subsonic" + version + ".index");
    }

    /**
     * Deletes old versions of the index file.
     */
    private void deleteOldIndexFiles() {
        for (int i = 2; i < INDEX_VERSION; i++) {
            File file = getIndexFile(i);
            try {
                if (file.exists()) {
                    if (file.delete()) {
                        LOG.info("Deleted old index file: " + file.getPath());
                    }
                }
            } catch (Exception x) {
                LOG.warn("Failed to delete old index file: " + file.getPath(), x);
            }
        }
    }

    public void setSettingsService(SettingsService settingsService) {
        this.settingsService = settingsService;
    }

    public void setSecurityService(SecurityService securityService) {
        this.securityService = securityService;
    }

    public void setMusicFileService(MusicFileService musicFileService) {
        this.musicFileService = musicFileService;
    }

    public void setMusicInfoService(MusicInfoService musicInfoService) {
        this.musicInfoService = musicInfoService;
    }

    public void setLuceneSearchService(LuceneSearchService luceneSearchService) {
        this.luceneSearchService = luceneSearchService;
    }

    /**
     * Contains the content of a single line in the index file.
     */
    static class Line {

        /**
         * Column separator.
         */
        static final String SEPARATOR = " ixYxi ";

        // TODO: Replace isFile, isAlbum, isDirectory with one char.

        public boolean isFile;
        public boolean isAlbum;
        private boolean isArtist;
        private boolean isDirectory;
        public long created;
        private long lastModified;
        public File file;
        private long length;
        public String artist;
        public String album;
        public String title;
        private String year;
        private String genre;

        private Line() {
        }

        /**
         * Creates a line instance by parsing the given string.
         *
         * @param s The string to parse.
         * @return The line created by parsing the string.
         */
        public static Line parse(String s) {
            Line line = new Line();

            String[] tokens = s.split(SEPARATOR, -1);
            line.isFile = "F".equals(tokens[0]);
            line.isArtist = "R".equals(tokens[0]);
            line.isAlbum = "A".equals(tokens[0]);
            line.isDirectory = "D".equals(tokens[0]);
            line.created = Long.parseLong(tokens[1]);
            line.lastModified = Long.parseLong(tokens[2]);
            line.file = new File(tokens[3]);
            line.artist = tokens[5].length() == 0 ? null : tokens[5];
            line.album = tokens[6].length() == 0 ? null : tokens[6];
            if (line.isFile) {
                line.length = Long.parseLong(tokens[4]);
                line.title = tokens[7].length() == 0 ? null : tokens[7];
                line.year = tokens[8].length() == 0 ? null : tokens[8];
                line.genre = tokens[9].length() == 0 ? null : tokens[9];
            }

            return line;
        }

        /**
         * Creates a line instance representing the given music file.
         *
         * @param file         The music file.
         * @param index        The existing search index. Used to avoid parsing metadata if the file has not changed
         *                     since the last time the search index was created.
         * @param musicFolders The set of configured music folders.
         * @return A line instance representing the given music file.
         */
        public static Line forFile(MusicFile file, Map<File, Line> index, Set<File> musicFolders) {
            // Look in existing index first.
            Line existingLine = index.get(file.getFile());

            // Found up-to-date line?
            if (existingLine != null && file.lastModified() == existingLine.lastModified) {
                return existingLine;
            }

            // Otherwise, construct meta data.
            Line line = new Line();

            MusicFile.MetaData metaData = file.getMetaData();
            line.isFile = file.isFile();
            line.isDirectory = file.isDirectory();
            if (line.isDirectory && !musicFolders.contains(file.getFile())) {
                try {
                    line.isAlbum = file.isAlbum();
                } catch (Exception x) {
                    LOG.warn("Failed to determine if " + file + " is an album.", x);
                }
                line.isArtist = !line.isAlbum;
            }
            line.lastModified = file.lastModified();
            line.created = existingLine != null ? existingLine.created : line.lastModified;
            line.file = file.getFile();
            if (line.isFile) {
                line.length = file.length();
                line.artist = StringUtils.upperCase(metaData.getArtist());
                line.album = StringUtils.upperCase(metaData.getAlbum());
                line.title = StringUtils.upperCase(metaData.getTitle());
                line.year = metaData.getYear();
                line.genre = StringUtils.capitalize(StringUtils.lowerCase(metaData.getGenre()));
            } else if (line.isAlbum) {
                resolveArtistAndAlbum(file, line);
            } else if (line.isArtist) {
                line.artist = StringUtils.upperCase(file.getName());
            }

            return line;
        }

        private static void resolveArtistAndAlbum(MusicFile file, Line line) {

            // If directory, find artist from metadata in child.
            if (file.isDirectory()) {
                try {
                    file = file.getFirstChild();
                } catch (IOException e) {
                    return;
                }
                if (file == null) {
                    return;
                }
            }
            line.artist = StringUtils.upperCase(file.getMetaData().getArtist());
            line.album = StringUtils.upperCase(file.getMetaData().getAlbum());
        }

        /**
         * Returns the content of this line as a string.
         *
         * @return The content of this line as a string.
         */
        @Override
        public String toString() {
            StringBuilder buf = new StringBuilder(256);

            if (isFile) {
                buf.append('F').append(SEPARATOR);
            } else if (isAlbum) {
                buf.append('A').append(SEPARATOR);
            } else if (isArtist) {
                buf.append('R').append(SEPARATOR);
            } else {
                buf.append('D').append(SEPARATOR);
            }

            buf.append(created).append(SEPARATOR);
            buf.append(lastModified).append(SEPARATOR);
            buf.append(file.getPath()).append(SEPARATOR);
            buf.append(length).append(SEPARATOR);
            buf.append(artist == null ? "" : artist).append(SEPARATOR);
            buf.append(album == null ? "" : album).append(SEPARATOR);
            buf.append(title == null ? "" : title).append(SEPARATOR);
            buf.append(year == null ? "" : year).append(SEPARATOR);
            buf.append(genre == null ? "" : genre);

            return buf.toString();
        }
    }

    private class Scanner implements MusicFile.Visitor {
        private final PrintWriter writer;
        private final Map<File, Line> oldIndex;
        private final Set<File> musicFolders;
        private int count;

        Scanner(PrintWriter writer, Map<File, Line> oldIndex, List<MusicFolder> musicFolders) {
            this.writer = writer;
            this.oldIndex = oldIndex;
            this.musicFolders = new HashSet<File>();
            for (MusicFolder musicFolder : musicFolders) {
                this.musicFolders.add(musicFolder.getPath());
            }
        }

        public void visit(MusicFile musicFile) {

            Line line = Line.forFile(musicFile, oldIndex, musicFolders);
            writer.println(line);

            // Get cover art in order to store it in the cache.
            if (line.isAlbum) {
                try {
                    musicFileService.getCoverArt(musicFile);
                } catch (IOException x) {
                    // Ignored.
                }
            }

            count++;
            if (count % 250 == 0) {
                LOG.info("Created search index with " + count + " entries.");
            }
        }

        public boolean includeDirectories() {
            return true;
        }

        public boolean sorted() {
            return false;
        }

        public int getCount() {
            return count;
        }
    }
}