/** * Used for creating and evolving the database schema. This class implementes the database schema * for Subsonic version 3.0. * * @author Sindre Mehus */ public class Schema30 extends Schema { private static final Logger LOG = Logger.getLogger(Schema30.class); public void execute(JdbcTemplate template) { if (template.queryForInt("select count(*) from version where version = 6") == 0) { LOG.info("Updating database schema to version 6."); template.execute("insert into version values (6)"); } if (!columnExists(template, "last_fm_enabled", "user_settings")) { LOG.info("Database columns 'user_settings.last_fm_*' not found. Creating them."); template.execute( "alter table user_settings add last_fm_enabled boolean default false not null"); template.execute("alter table user_settings add last_fm_username varchar null"); template.execute("alter table user_settings add last_fm_password varchar null"); LOG.info("Database columns 'user_settings.last_fm_*' were added successfully."); } if (!columnExists(template, "transcode_scheme", "user_settings")) { LOG.info("Database column 'user_settings.transcode_scheme' not found. Creating it."); template.execute( "alter table user_settings add transcode_scheme varchar default '" + TranscodeScheme.OFF.name() + "' not null"); LOG.info("Database column 'user_settings.transcode_scheme' was added successfully."); } } }
/** * Used for creating and evolving the database schema. This class implementes the database schema * for Subsonic version 2.7. * * @author Sindre Mehus */ public class Schema27 extends Schema { private static final Logger LOG = Logger.getLogger(Schema27.class); public void execute(JdbcTemplate template) { if (template.queryForInt("select count(*) from version where version = 3") == 0) { LOG.info("Updating database schema to version 3."); template.execute("insert into version values (3)"); LOG.info("Converting database column 'music_file_info.path' to varchar_ignorecase."); template.execute("drop index idx_music_file_info_path"); template.execute("alter table music_file_info alter column path varchar_ignorecase not null"); template.execute("create index idx_music_file_info_path on music_file_info(path)"); LOG.info("Database column 'music_file_info.path' was converted successfully."); } if (!columnExists(template, "bytes_streamed", "user")) { LOG.info( "Database columns 'user.bytes_streamed/downloaded/uploaded' not found. Creating them."); template.execute("alter table user add bytes_streamed bigint default 0 not null"); template.execute("alter table user add bytes_downloaded bigint default 0 not null"); template.execute("alter table user add bytes_uploaded bigint default 0 not null"); LOG.info( "Database columns 'user.bytes_streamed/downloaded/uploaded' were added successfully."); } } }
/** * Used for creating and evolving the database schema. This class implementes the database schema * for Subsonic version 3.4. * * @author Sindre Mehus */ public class Schema34 extends Schema { private static final Logger LOG = Logger.getLogger(Schema34.class); public void execute(JdbcTemplate template) { if (template.queryForInt("select count(*) from version where version = 10") == 0) { LOG.info("Updating database schema to version 10."); template.execute("insert into version values (10)"); } if (!columnExists(template, "ldap_authenticated", "users")) { LOG.info("Database column 'users.ldap_authenticated' not found. Creating it."); template.execute("alter table users add ldap_authenticated boolean default false not null"); LOG.info("Database column 'users.ldap_authenticated' was added successfully."); } if (!columnExists(template, "party_mode_enabled", "users_settings")) { LOG.info("Database column 'users_settings.party_mode_enabled' not found. Creating it."); template.execute( "alter table users_settings add party_mode_enabled boolean default false not null"); LOG.info("Database column 'users_settings.party_mode_enabled' was added successfully."); } } }
public class UserService { private static final Logger LOG = Logger.getLogger(UserService.class); private SecurityService securityService; public void init() { CheckUser(); } public void CheckUser() { Runnable runnable = new Runnable() { public void run() { try { LOG.info("Checking User accounts ..."); securityService.checkAccounts(); LOG.info("Checking Done"); } catch (Throwable x) { LOG.error("Failed to check User service: " + x, x); } } }; new Thread(runnable).start(); } public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } }
/** * Used for creating and evolving the database schema. This class implementes the database schema * for Subsonic version 2.5. * * @author Sindre Mehus */ public class Schema25 extends Schema { private static final Logger LOG = Logger.getLogger(Schema25.class); public void execute(JdbcTemplate template) { if (!tableExists(template, "version")) { LOG.info("Database table 'version' not found. Creating it."); template.execute("create table version (version int not null)"); template.execute("insert into version values (1)"); LOG.info("Database table 'version' was created successfully."); } if (!tableExists(template, "role")) { LOG.info("Database table 'role' not found. Creating it."); template.execute( "create table role (" + "id int not null," + "name varchar not null," + "primary key (id))"); template.execute("insert into role values (1, 'admin')"); template.execute("insert into role values (2, 'download')"); template.execute("insert into role values (3, 'upload')"); template.execute("insert into role values (4, 'playlist')"); template.execute("insert into role values (5, 'coverart')"); LOG.info("Database table 'role' was created successfully."); } if (!tableExists(template, "user")) { LOG.info("Database table 'user' not found. Creating it."); template.execute( "create table user (" + "username varchar not null," + "password varchar not null," + "primary key (username))"); template.execute("insert into user values ('admin', 'admin')"); LOG.info("Database table 'user' was created successfully."); } if (!tableExists(template, "user_role")) { LOG.info("Database table 'user_role' not found. Creating it."); template.execute( "create table user_role (" + "username varchar not null," + "role_id int not null," + "primary key (username, role_id)," + "foreign key (username) references user(username)," + "foreign key (role_id) references role(id))"); template.execute("insert into user_role values ('admin', 1)"); template.execute("insert into user_role values ('admin', 2)"); template.execute("insert into user_role values ('admin', 3)"); template.execute("insert into user_role values ('admin', 4)"); template.execute("insert into user_role values ('admin', 5)"); LOG.info("Database table 'user_role' was created successfully."); } } }
/** * Used for creating and evolving the database schema. This class implements the database schema for * Subsonic version 4.9. * * @author Sindre Mehus */ public class Schema49 extends Schema { private static final Logger LOG = Logger.getLogger(Schema49.class); @Override public void execute(JdbcTemplate template) { if (template.queryForInt("select count(*) from version where version = 21") == 0) { LOG.info("Updating database schema to version 21."); template.execute("insert into version values (21)"); } if (!columnExists(template, "year", "album")) { LOG.info("Database column 'album.year' not found. Creating it."); template.execute("alter table album add year int"); LOG.info("Database column 'album.year' was added successfully."); } if (!columnExists(template, "genre", "album")) { LOG.info("Database column 'album.genre' not found. Creating it."); template.execute("alter table album add genre varchar"); LOG.info("Database column 'album.genre' was added successfully."); } if (!tableExists(template, "genre")) { LOG.info("Database table 'genre' not found. Creating it."); template.execute( "create table genre (" + "name varchar not null," + "song_count int not null)"); LOG.info("Database table 'genre' was created successfully."); } if (!columnExists(template, "album_count", "genre")) { LOG.info("Database column 'genre.album_count' not found. Creating it."); template.execute("alter table genre add album_count int default 0 not null"); LOG.info("Database column 'genre.album_count' was added successfully."); } } }
/** * Used for creating and evolving the database schema. This class implements the database schema for * Subsonic version 4.6, with the additions for MusicCabinet 0.7.4. */ public class Schema46MusicCabinet0_7_04 extends Schema { private static final Logger LOG = Logger.getLogger(Schema46MusicCabinet0_7_04.class); @Override public void execute(JdbcTemplate template) { if (template.queryForInt("select count(*) from version where version = 28") == 0) { LOG.info("Updating database schema to version 28."); template.execute("insert into version values (28)"); if (!columnExists(template, "only_album_artist_recommendations", "user_settings")) { LOG.info( "Database column 'user_settings.only_album_artist_recommendations' not found. Creating it."); template.execute( "alter table user_settings add only_album_artist_recommendations boolean default true not null"); LOG.info( "Database column 'user_settings.only_album_artist_recommendations' was added successfully."); } } } }
/** * Controller which produces cover art images. * * @author Sindre Mehus */ public class CoverArtControllerEx implements Controller, LastModified { public static final String ALBUM_COVERART_PREFIX = "al-"; public static final String ARTIST_COVERART_PREFIX = "ar-"; private static final Logger LOG = Logger.getLogger(CoverArtController.class); private SecurityService securityService; private MediaFileService mediaFileService; private ArtistDao artistDao; private AlbumDao albumDao; public long getLastModified(HttpServletRequest request) { try { File file = getImageFile(request); if (file == null) { return 0; // Request for the default image. } if (!FileUtil.exists(file)) { return -1; } return FileUtil.lastModified(file); } catch (Exception e) { return -1; } } public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception { File file = getImageFile(request); if (file != null && !FileUtil.exists(file)) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } // Check access. if (file != null && !securityService.isReadAllowed(file)) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return null; } // Send default image if no path is given. (No need to cache it, since it will be cached in // browser.) Integer size = ServletRequestUtils.getIntParameter(request, "size"); boolean typArtist = ServletRequestUtils.getBooleanParameter(request, "typArtist", false); if (typArtist == true) { if (file == null) { sendDefaultArtist(size, response); return null; } } else { if (file == null) { sendDefault(size, request, response); return null; } } // Optimize if no scaling is required. if (size == null) { sendUnscaled(file, response); return null; } // Send cached image, creating it if necessary. try { File cachedImage = getCachedImage(file, size); sendImage(cachedImage, response); } catch (IOException e) { sendDefault(size, request, response); } return null; } private File getImageFile(HttpServletRequest request) { String id = request.getParameter("id"); if (id != null) { if (id.startsWith(ALBUM_COVERART_PREFIX)) { return getAlbumImage(Integer.valueOf(id.replace(ALBUM_COVERART_PREFIX, ""))); } if (id.startsWith(ARTIST_COVERART_PREFIX)) { return getArtistImage(Integer.valueOf(id.replace(ARTIST_COVERART_PREFIX, ""))); } return getMediaFileImage(Integer.valueOf(id)); } String path = StringUtils.trimToNull(request.getParameter("path")); return path != null ? new File(path) : null; } private File getArtistImage(int id) { Artist artist = artistDao.getArtist(id); return artist == null || artist.getCoverArtPath() == null ? null : new File(artist.getCoverArtPath()); } private File getAlbumImage(int id) { Album album = albumDao.getAlbum(id); return album == null || album.getCoverArtPath() == null ? null : new File(album.getCoverArtPath()); } private File getMediaFileImage(int id) { MediaFile mediaFile = mediaFileService.getMediaFile(id); return mediaFile == null ? null : mediaFileService.getCoverArt(mediaFile); } private void sendImage(File file, HttpServletResponse response) throws IOException { response.setContentType(StringUtil.getMimeType(FilenameUtils.getExtension(file.getName()))); InputStream in = new FileInputStream(file); try { IOUtils.copy(in, response.getOutputStream()); } finally { IOUtils.closeQuietly(in); } } private void sendDefault(Integer size, HttpServletRequest request, HttpServletResponse response) throws IOException { try { int id = Integer.parseInt(request.getParameter("id")); MediaFile mediaFile = mediaFileService.getMediaFile(id); sendAutoGenerated(size, mediaFile, response); } catch (Throwable x) { sendFallback(size, response); } } private void sendAutoGenerated(Integer size, MediaFile mediaFile, HttpServletResponse response) throws IOException { if (mediaFile.isFile()) { mediaFile = mediaFileService.getParentOf(mediaFile); } if (size == null) { size = CoverArtScheme.MEDIUM.getSize() * 2; } BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics2D graphics = image.createGraphics(); AutoCover autoCover = new AutoCover(graphics, mediaFile, size); autoCover.paintCover(); graphics.dispose(); response.setContentType(StringUtil.getMimeType("png")); ImageIO.write(image, "png", response.getOutputStream()); } private void sendFallback(Integer size, HttpServletResponse response) throws IOException { if (response.getContentType() == null) { response.setContentType(StringUtil.getMimeType("png")); } InputStream in = null; try { in = getClass().getResourceAsStream("default_cover.png"); BufferedImage image = ImageIO.read(in); if (size != null) { image = scale(image, size, size); } ImageIO.write(image, "png", response.getOutputStream()); } finally { IOUtils.closeQuietly(in); } } private void sendDefaultArtist(Integer size, HttpServletResponse response) throws IOException { InputStream in = null; try { in = getClass().getResourceAsStream("default_artist.png"); BufferedImage imageArtist = ImageIO.read(in); if (size != null) { imageArtist = scale(imageArtist, size, size); } ImageIO.write(imageArtist, "png", response.getOutputStream()); } finally { IOUtils.closeQuietly(in); } } private void sendUnscaled(File file, HttpServletResponse response) throws IOException { JaudiotaggerParser parser = new JaudiotaggerParser(); if (!parser.isApplicable(file)) { response.setContentType(StringUtil.getMimeType(FilenameUtils.getExtension(file.getName()))); } InputStream in = null; try { in = getImageInputStream(file); IOUtils.copy(in, response.getOutputStream()); } finally { IOUtils.closeQuietly(in); } } private File getCachedImage(File file, int size) throws IOException { String md5 = DigestUtils.md5Hex(file.getPath()); File cachedImage = new File(getImageCacheDirectory(size), md5 + ".jpeg"); // Is cache missing or obsolete? if (!cachedImage.exists() || FileUtil.lastModified(file) > cachedImage.lastModified()) { InputStream in = null; OutputStream out = null; try { in = getImageInputStream(file); out = new FileOutputStream(cachedImage); BufferedImage image = ImageIO.read(in); if (image == null) { throw new Exception("Unable to decode image."); } image = scale(image, size, size); ImageIO.write(image, "jpeg", out); } catch (Throwable x) { // Delete corrupt (probably empty) thumbnail cache. LOG.warn("Failed to create thumbnail for " + file, x); IOUtils.closeQuietly(out); cachedImage.delete(); throw new IOException("Failed to create thumbnail for " + file + ". " + x.getMessage()); } finally { IOUtils.closeQuietly(in); IOUtils.closeQuietly(out); } } return cachedImage; } /** * Returns an input stream to the image in the given file. If the file is an audio file, the * embedded album art is returned. */ private InputStream getImageInputStream(File file) throws IOException { JaudiotaggerParser parser = new JaudiotaggerParser(); if (parser.isApplicable(file)) { MediaFile mediaFile = mediaFileService.getMediaFile(file); return new ByteArrayInputStream(parser.getImageData(mediaFile)); } else { return new FileInputStream(file); } } private synchronized File getImageCacheDirectory(int size) { File dir = new File(SettingsService.getSubsonicHome(), "thumbs"); dir = new File(dir, String.valueOf(size)); if (!dir.exists()) { if (dir.mkdirs()) { LOG.info("Created thumbnail cache " + dir); } else { LOG.error("Failed to create thumbnail cache " + dir); } } return dir; } public static BufferedImage scale(BufferedImage image, int width, int height) { int w = image.getWidth(); int h = image.getHeight(); BufferedImage thumb = image; // For optimal results, use step by step bilinear resampling - halfing the size at each step. do { w /= 2; h /= 2; if (w < width) { w = width; } if (h < height) { h = height; } double thumbRatio = (double) width / (double) height; double aspectRatio = (double) w / (double) h; // LOG.debug("## thumbsRatio: " + thumbRatio); // LOG.debug("## aspectRatio: " + aspectRatio); if (thumbRatio < aspectRatio) { h = (int) (w / aspectRatio); } else { w = (int) (h * aspectRatio); } BufferedImage temp = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); Graphics2D g2 = temp.createGraphics(); g2.setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2.drawImage(thumb, 0, 0, w, h, null); g2.dispose(); thumb = temp; } while (w != width); // FIXME: check if (thumb.getHeight() > thumb.getWidth()) { thumb = thumb.getSubimage(0, 0, thumb.getWidth(), thumb.getWidth()); } return thumb; } public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } public void setMediaFileService(MediaFileService mediaFileService) { this.mediaFileService = mediaFileService; } public void setArtistDao(ArtistDao artistDao) { this.artistDao = artistDao; } public void setAlbumDao(AlbumDao albumDao) { this.albumDao = albumDao; } static class AutoCover { private static final int[] COLORS = { 0xCF8E25 }; // 0x33B5E5, 0xAA66CC, 0x99CC00, 0xFFBB33, 0xFF4444}; private final Graphics2D graphics; private final MediaFile mediaFile; private final int size; private final Color color; public AutoCover(Graphics2D graphics, MediaFile mediaFile, int size) { this.graphics = graphics; this.mediaFile = mediaFile; this.size = size; int rgb = COLORS[Math.abs(mediaFile.getId()) % COLORS.length]; this.color = new Color(rgb); } public void paintCover() { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint( RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); graphics.setPaint(color); graphics.fillRect(0, 0, size, size); int y = size * 1 / 3; graphics.setPaint(new GradientPaint(0, y, new Color(82, 82, 82), 0, size, Color.BLACK)); graphics.fillRect(0, y, size, size / 1); graphics.setPaint(Color.WHITE); float fontSize = 3.0f + size * 0.06f; Font font = new Font(Font.SANS_SERIF, Font.BOLD, (int) fontSize); graphics.setFont(font); String album = mediaFile.getAlbumName(); if (album != null) { graphics.drawString(album, size * 0.05f, size * 0.25f); } String artist = mediaFile.getAlbumArtist(); if (artist == null) { artist = mediaFile.getArtist(); } if (artist != null) { graphics.drawString(artist, size * 0.05f, size * 0.45f); } int borderWidth = size / 50; graphics.fillRect(0, 0, borderWidth, size); graphics.fillRect(size - borderWidth, 0, size - borderWidth, size); graphics.fillRect(0, 0, size, borderWidth); graphics.fillRect(0, size - borderWidth, size, size); graphics.setColor(Color.BLACK); graphics.drawRect(0, 0, size - 1, size - 1); } } }
/** * Used for creating and evolving the database schema. This class implementes the database schema * for Subsonic version 3.2. * * @author Sindre Mehus */ public class Schema32 extends Schema { private static final Logger LOG = Logger.getLogger(Schema32.class); public void execute(JdbcTemplate template) { if (template.queryForInt("select count(*) from version where version = 8") == 0) { LOG.info("Updating database schema to version 8."); template.execute("insert into version values (8)"); } if (!columnExists(template, "show_now_playing", "user_settings")) { LOG.info("Database column 'user_settings.show_now_playing' not found. Creating it."); template.execute( "alter table user_settings add show_now_playing boolean default false not null"); LOG.info("Database column 'user_settings.show_now_playing' was added successfully."); } if (!columnExists(template, "selected_music_folder_id", "user_settings")) { LOG.info("Database column 'user_settings.selected_music_folder_id' not found. Creating it."); template.execute( "alter table user_settings add selected_music_folder_id int default -1 not null"); LOG.info("Database column 'user_settings.selected_music_folder_id' was added successfully."); } if (!tableExists(template, "podcast_channel")) { LOG.info("Database table 'podcast_channel' not found. Creating it."); template.execute( "create table podcast_channel (" + "id identity," + "url varchar not null," + "title varchar," + "description varchar," + "status varchar not null," + "error_message varchar)"); LOG.info("Database table 'podcast_channel' was created successfully."); } if (!tableExists(template, "podcast_episode")) { LOG.info("Database table 'podcast_episode' not found. Creating it."); template.execute( "create table podcast_episode (" + "id identity," + "channel_id int not null," + "url varchar not null," + "path varchar," + "title varchar," + "description varchar," + "publish_date datetime," + "duration varchar," + "bytes_total bigint," + "bytes_downloaded bigint," + "status varchar not null," + "error_message varchar," + "foreign key (channel_id) references podcast_channel(id) on delete cascade)"); LOG.info("Database table 'podcast_episode' was created successfully."); } if (template.queryForInt("select count(*) from role where id = 7") == 0) { LOG.info("Role 'podcast' not found in database. Creating it."); template.execute("insert into role values (7, 'podcast')"); template.execute( "insert into user_role " + "select distinct u.username, 7 from user u, user_role ur " + "where u.username = ur.username and ur.role_id = 1"); LOG.info("Role 'podcast' was created successfully."); } } }
/** * Provides database services for caching. * * @author Sindre Mehus */ public class CacheDao { private static final Logger LOG = Logger.getLogger(CacheDao.class); private static final int BATCH_SIZE = 100; private EmbeddedObjectContainer db; private final ReadWriteLock dbLock = new ReentrantReadWriteLock(); private int writeCount = 0; private final File dbFile; public CacheDao() { File subsonicHome = SettingsService.getSubsonicHome(); File dbDir = new File(subsonicHome, "cache"); dbFile = new File(dbDir, "cache.dat"); if (!dbDir.exists()) { dbDir.mkdirs(); } // if (dbFile.exists()) { // try { // Defragment.defrag(dbFile.getPath()); // }catch (IOException e) { // e.printStackTrace(); // } // } try { openDatabase(dbFile); } catch (Throwable x) { LOG.error("Failed to open " + dbFile + ", deleting it: " + x); dbFile.delete(); openDatabase(dbFile); } } private void openDatabase(File dbFile) { EmbeddedConfiguration config = Db4oEmbedded.newConfiguration(); config.common().objectClass(CacheElement.class).objectField("id").indexed(true); config.common().objectClass(CacheElement.class).cascadeOnUpdate(true); config.common().objectClass(CacheElement.class).cascadeOnDelete(true); config.common().objectClass(CacheElement.class).cascadeOnActivate(true); db = Db4oEmbedded.openFile(config, dbFile.getPath()); } /** Recreates the database. */ public void clearDatabase() { dbLock.writeLock().lock(); try { db.close(); dbFile.delete(); openDatabase(dbFile); } finally { dbLock.writeLock().unlock(); } } /** * Creates a new cache element. * * @param element The cache element to create (or update). */ public void createCacheElement(CacheElement element) { dbLock.writeLock().lock(); try { deleteCacheElement(element); db.store(element); if (writeCount++ == BATCH_SIZE) { db.commit(); writeCount = 0; } } finally { dbLock.writeLock().unlock(); } } public CacheElement getCacheElement(int type, String key) { dbLock.readLock().lock(); try { ObjectSet<CacheElement> result = db.query(new CacheElementPredicate(type, key)); if (result.size() > 1) { LOG.error( "Programming error. Got " + result.size() + " cache elements of type " + type + " and key " + key); } return result.isEmpty() ? null : result.get(0); } finally { dbLock.readLock().unlock(); } } /** Deletes the cache element with the given type and key. */ private void deleteCacheElement(CacheElement element) { // Retrieve it from the database first. element = getCacheElement(element.getType(), element.getKey()); if (element != null) { db.delete(element); } } private static class CacheElementPredicate extends Predicate<CacheElement> { private static final long serialVersionUID = 54911003002373726L; private final long id; public CacheElementPredicate(int type, String key) { id = CacheElement.createId(type, key); } @Override public boolean match(CacheElement candidate) { return candidate.getId() == id; } } }
/** * Controller which produces cover art images. * * @author Sindre Mehus */ public class CoverArtController implements Controller, LastModified { private static final Logger LOG = Logger.getLogger(CoverArtController.class); private SecurityService securityService; private MediaFileService mediaFileService; public long getLastModified(HttpServletRequest request) { String path = request.getParameter("path"); if (StringUtils.trimToNull(path) == null) { return 0; } File file = new File(path); if (!FileUtil.exists(file)) { return -1; } return FileUtil.lastModified(file); } public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception { String path = request.getParameter("path"); File file = (path == null || path.length() == 0) ? null : new File(path); Integer size = ServletRequestUtils.getIntParameter(request, "size"); // Check access. if (file != null && !securityService.isReadAllowed(file)) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return null; } // Optimize if no scaling is required. if (size == null) { sendUnscaled(file, response); return null; } // Send default image if no path is given. (No need to cache it, since it will be cached in // browser.) if (file == null) { sendDefault(size, response); return null; } // Send cached image, creating it if necessary. try { File cachedImage = getCachedImage(file, size); sendImage(cachedImage, response); } catch (IOException e) { sendDefault(size, response); } return null; } private void sendImage(File file, HttpServletResponse response) throws IOException { InputStream in = new FileInputStream(file); try { IOUtils.copy(in, response.getOutputStream()); } finally { IOUtils.closeQuietly(in); } } private void sendDefault(Integer size, HttpServletResponse response) throws IOException { InputStream in = null; try { in = getClass().getResourceAsStream("default_cover.jpg"); BufferedImage image = ImageIO.read(in); image = scale(image, size, size); ImageIO.write(image, "jpeg", response.getOutputStream()); } finally { IOUtils.closeQuietly(in); } } private void sendUnscaled(File file, HttpServletResponse response) throws IOException { InputStream in = null; try { in = getImageInputStream(file); IOUtils.copy(in, response.getOutputStream()); } finally { IOUtils.closeQuietly(in); } } private File getCachedImage(File file, int size) throws IOException { String md5 = DigestUtils.md5Hex(file.getPath()); File cachedImage = new File(getImageCacheDirectory(size), md5 + ".jpeg"); // Is cache missing or obsolete? if (!cachedImage.exists() || FileUtil.lastModified(file) > cachedImage.lastModified()) { InputStream in = null; OutputStream out = null; try { in = getImageInputStream(file); out = new FileOutputStream(cachedImage); BufferedImage image = ImageIO.read(in); if (image == null) { throw new Exception("Unable to decode image."); } image = scale(image, size, size); ImageIO.write(image, "jpeg", out); } catch (Throwable x) { // Delete corrupt (probably empty) thumbnail cache. LOG.warn("Failed to create thumbnail for " + file, x); IOUtils.closeQuietly(out); cachedImage.delete(); throw new IOException("Failed to create thumbnail for " + file + ". " + x.getMessage()); } finally { IOUtils.closeQuietly(in); IOUtils.closeQuietly(out); } } return cachedImage; } /** * Returns an input stream to the image in the given file. If the file is an audio file, the * embedded album art is returned. */ private InputStream getImageInputStream(File file) throws IOException { JaudiotaggerParser parser = new JaudiotaggerParser(); if (parser.isApplicable(file)) { MediaFile mediaFile = mediaFileService.getMediaFile(file); return new ByteArrayInputStream(parser.getImageData(mediaFile)); } else { return new FileInputStream(file); } } private synchronized File getImageCacheDirectory(int size) { File dir = new File(SettingsService.getSubsonicHome(), "thumbs"); dir = new File(dir, String.valueOf(size)); if (!dir.exists()) { if (dir.mkdirs()) { LOG.info("Created thumbnail cache " + dir); } else { LOG.error("Failed to create thumbnail cache " + dir); } } return dir; } public static BufferedImage scale(BufferedImage image, int width, int height) { int w = image.getWidth(); int h = image.getHeight(); BufferedImage thumb = image; // For optimal results, use step by step bilinear resampling - halfing the size at each step. do { w /= 2; h /= 2; if (w < width) { w = width; } if (h < height) { h = height; } BufferedImage temp = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); Graphics2D g2 = temp.createGraphics(); g2.setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2.drawImage(thumb, 0, 0, temp.getWidth(), temp.getHeight(), null); g2.dispose(); thumb = temp; } while (w != width); return thumb; } public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } public void setMediaFileService(MediaFileService mediaFileService) { this.mediaFileService = mediaFileService; } }
/** * Provides database services for artists. * * @author Sindre Mehus */ public class LastFMArtistSimilarDao extends AbstractDao { private static final Logger LOG = Logger.getLogger(LastFMArtistSimilarDao.class); private static final String COLUMNS = "id, artist_name, artist_mbid, similar_name, similar_mbid"; private final RowMapper rowMapper = new LastFMArtistSimilarMapper(); // public List<LastFMArtistSimilar> getAllSimilar() { // return query("select " + COLUMNS + " from lastfm_artist_similar order by name", // rowMapper); // } public LastFMArtistSimilar getSimilar(String artistName) { return queryOne( "select " + COLUMNS + " from lastfm_artist_similar where lower(artist_name)=?", rowMapper, artistName.toLowerCase()); } public List<String> getSimilarArtist(String ArtistName) { return queryForStrings( "select distinct SIMILAR_NAME from lastfm_artist_similar where lower(artist_name)=?", ArtistName.toLowerCase()); } public LastFMArtistSimilar getSimilar(int mbid) { return queryOne( "select " + COLUMNS + " from lastfm_artist_similar where mbid=?", rowMapper, mbid); } public synchronized void createOrUpdateLastFMArtistSimilar( LastFMArtistSimilar lastFMArtistSimilar) { String sql = "update lastfm_artist_similar set " + "artist_name=?," + "artist_mbid=?," + "similar_name=?, " + "similar_mbid=? " + "where artist_name=? and similar_name=?"; int n = update( sql, lastFMArtistSimilar.getArtistName(), lastFMArtistSimilar.getArtistMbid(), lastFMArtistSimilar.getSimilarName(), lastFMArtistSimilar.getSimilarMbid(), lastFMArtistSimilar.getArtistName(), lastFMArtistSimilar.getSimilarName()); if (n == 0) { update( "insert into lastfm_artist_similar (" + COLUMNS + ") values (" + questionMarks(COLUMNS) + ")", null, lastFMArtistSimilar.getArtistName(), lastFMArtistSimilar.getArtistMbid(), lastFMArtistSimilar.getSimilarName(), lastFMArtistSimilar.getSimilarMbid()); } // int id = queryForInt("select id from lastfm_artist where artistname=?", null, // lastFMArtist.getArtistname()); // lastFMArtist.setId(id); } private static class LastFMArtistSimilarMapper implements ParameterizedRowMapper<LastFMArtistSimilar> { public LastFMArtistSimilar mapRow(ResultSet rs, int rowNum) throws SQLException { return new LastFMArtistSimilar( rs.getInt(1), rs.getString(2), rs.getString(3), rs.getString(4), rs.getString(5)); } } }
/** Controller for the page used to administrate last.fm group subscriptions. */ public class GroupSettingsController extends ParameterizableViewController { private LastFmService lastFmService; private GroupWeeklyArtistChartService artistChartService; private static final Logger LOG = Logger.getLogger(GroupSettingsController.class); @Override protected ModelAndView handleRequestInternal( HttpServletRequest request, HttpServletResponse response) throws Exception { Map<String, Object> map = new HashMap<String, Object>(); String[] groups = request.getParameterValues("group"); if (groups != null) { updateGroups(groups); } ModelAndView result = super.handleRequestInternal(request, response); map.put("lastFmGroups", lastFmService.getLastFmGroups()); result.addObject("model", map); return result; } /* * Store selected groups, and asynchronously fetch group artists from last.fm. */ private void updateGroups(String[] groups) { List<LastFmGroup> lastFmGroups = new ArrayList<>(); for (String group : groups) { if (StringUtils.trimToNull(group) != null) { lastFmGroups.add(new LastFmGroup(group)); } } lastFmService.setLastFmGroups(lastFmGroups); Executors.newSingleThreadExecutor() .execute( new Runnable() { @Override public void run() { try { LOG.debug("Update artist chart."); artistChartService.updateSearchIndex(); LOG.debug("Artist chart updated."); } catch (Throwable t) { LOG.warn("Couldn't update group artists!", t); } } }); } public void setLastFmService(LastFmService lastFmService) { this.lastFmService = lastFmService; } public void setGroupWeeklyArtistChartService(GroupWeeklyArtistChartService artistChartService) { this.artistChartService = artistChartService; } }
/** * Represents a file or directory containing music. Media files can be put in a {@link Playlist}, * and may be streamed to remote players. All media files are located in a configurable root music * folder. * * @author Sindre Mehus */ public class MediaFile implements Serializable, Comparable<MediaFile> { private static final long serialVersionUID = -3826007043440542822L; private static final Logger LOG = Logger.getLogger(MediaFile.class); private int id; private File file; private boolean isFile; private boolean isDirectory; private boolean isVideo; private long lastModified; private MetaData metaData; /** Preferred usage: {@link MediaFileService#getmediaFile}. */ public MediaFile(int id, File file) { this.id = id; this.file = file; // Cache these values for performance. isFile = file.isFile(); isDirectory = file.isDirectory(); lastModified = file.lastModified(); isVideo = isFile && isVideoFile(file); if (isFile) { getMetaData(); LOG.debug("created file with id " + id + " from file " + file + ", metadata = " + metaData); } } /** Empty constructor. Used for testing purposes only. */ protected MediaFile() { isFile = true; } public MediaFile(int id) { this.id = id; this.isFile = true; this.metaData = new MetaData(); } public int getId() { return id; } public File getFile() { return file; } public void setFile(File file) { this.file = file; } public boolean isFile() { return isFile; } /** * Returns whether this music file is a directory. * * @return Whether this music file is a directory. */ public boolean isDirectory() { return isDirectory; } /** * Returns whether this media file is a video. * * @return Whether this media file is a video. */ public boolean isVideo() { return isVideo; } /** * Returns whether this music file is one of the root music folders. * * @return Whether this music file is one of the root music folders. */ public boolean isRoot() { MediaFolderService mediaFolderSettings = ServiceLocator.getMediaFolderService(); List<MediaFolder> folders = mediaFolderSettings.getAllMediaFolders(); for (MediaFolder folder : folders) { if (file.equals(folder.getPath())) { return true; } } return false; } /** * Returns the time this music file was last modified. * * @return The time since this music file was last modified, in milliseconds since the epoch. */ public long lastModified() { return lastModified; } /** * Returns the length of the music file. The return value is unspecified if this music file is a * directory. * * @return The length, in bytes, of the music file, or or <code>0L</code> if the file does not * exist */ public long length() { return file.length(); } /** * Returns whether this music file exists. * * @return Whether this music file exists. */ public boolean exists() { return file.exists(); } /** * Returns the name of the music file. This is normally just the last name in the pathname's name * sequence. * * @return The name of the music file. */ public String getName() { return file.getName(); } /** * Same as {@link #getName}, but without file suffix (unless this music file represents a * directory). * * @return The name of the file without the suffix */ public String getNameWithoutSuffix() { String name = getName(); if (isDirectory()) { return name; } int i = name.lastIndexOf('.'); return i == -1 ? name : name.substring(0, i); } /** * Returns the file suffix, e.g., "mp3". * * @return The file suffix. */ public String getSuffix() { return FilenameUtils.getExtension(getName()); } /** * Returns the full pathname as a string. * * @return The full pathname as a string. */ public String getPath() { return file.getPath(); } /** * Returns meta data for this music file. * * @return Meta data (artist, album, title etc) for this music file. */ public MetaData getMetaData() { if (metaData == null) { MetaDataParser parser = ServiceLocator.getMetaDataParserFactory().getParser(this); metaData = (parser == null) ? null : parser.getMetaData(this); } return metaData; } /** * Returns the title of the music file, by attempting to parse relevant meta-data embedded in the * file, for instance ID3 tags in MP3 files. * * <p>If this music file is a directory, or if no tags are found, this method is equivalent to * {@link #getNameWithoutSuffix}. * * @return The song title of this music file. */ public String getTitle() { return getMetaData() == null ? getNameWithoutSuffix() : getMetaData().getTitle(); } /** * Returns the parent music file. * * @return The parent music file, or <code>null</code> if no parent exists. * @throws IOException If an I/O error occurs. */ public MediaFile getParent() throws IOException { File parent = file.getParentFile(); return parent == null ? null : createMediaFile(parent); } /** * Returns all music files that are children of this music file. * * @param includeFiles Whether files should be included in the result. * @param includeDirectories Whether directories should be included in the result. * @throws IOException If an I/O error occurs. */ public List<MediaFile> getChildren(FileFilter filter) throws IOException { File[] children = FileUtil.listFiles(file, filter); List<MediaFile> result = new ArrayList<MediaFile>(children.length); for (File child : children) { try { if (acceptMedia(child)) { result.add(createMediaFile(child)); } } catch (SecurityException x) { LOG.warn("Failed to create mediaFile for " + child, x); } } Collections.sort(result); return result; } private MediaFile createMediaFile(File file) { return ServiceLocator.getMediaFileService().getNonIndexedMediaFile(file); } private boolean acceptMedia(File file) throws IOException { if (isExcluded(file)) { return false; } if (file.isDirectory()) { return true; } return isMusicFile(file) || isVideoFile(file); } private static boolean isMusicFile(File file) { return FilenameUtils.isExtension( file.getName(), ServiceLocator.getSettingsService().getMusicFileTypesAsArray()); } private static boolean isVideoFile(File file) { return FilenameUtils.isExtension( file.getName(), ServiceLocator.getSettingsService().getVideoFileTypesAsArray()); } /** * @param file The child file in question. * @return Whether the child file is excluded. */ private boolean isExcluded(File file) throws IOException { // Exclude all hidden files starting with a "." or "@eaDir" (thumbnail // dir created on Synology devices). return (file.getName().startsWith(".") || file.getName().startsWith("@eaDir")); } public boolean equals(Object o) { if (o == null) return false; if (o == this) return true; if (o.getClass() != getClass()) return false; return id == ((MediaFile) o).id; } /** * Returns the hash code of this music file. * * @return The hash code of this music file. */ @Override public int hashCode() { return id; } /** * Equivalent to {@link #getPath}. * * @return This music file as a string. */ @Override public String toString() { return getPath(); } @Override public int compareTo(MediaFile mf) { if (!isDirectory && mf.isDirectory) { return 1; } else if (isDirectory && !mf.isDirectory) { return -1; } else if (isDirectory && mf.isDirectory) { return getName().compareTo(mf.getName()); } MetaData md1 = getMetaData(); MetaData md2 = mf.getMetaData(); CompareToBuilder ctb = new CompareToBuilder() .append(nvl(md1.getDiscNumber(), 1), nvl(md2.getDiscNumber(), 1)) .append(nvl(md1.getTrackNumber(), -1), nvl(md2.getTrackNumber(), -1)) .append(md1.getTitle(), md2.getTitle()); return ctb.toComparison(); } private int nvl(Integer value, int defaultValue) { return value == null ? defaultValue : value; } }
/** * Provides AJAX-enabled services for changing cover art images. * * <p>This class is used by the DWR framework (http://getahead.ltd.uk/dwr/). * * @author Sindre Mehus */ public class CoverArtService { private static final Logger LOG = Logger.getLogger(CoverArtService.class); private SecurityService securityService; private MediaFileService mediaFileService; /** * Downloads and saves the cover art at the given URL. * * @param path The directory in which to save the image. * @param url The image URL. * @return The error string if something goes wrong, <code>null</code> otherwise. */ public String setCoverArtImage(String path, String url) { try { saveCoverArt(path, url); return null; } catch (Exception x) { LOG.warn("Failed to save cover art for " + path, x); return x.toString(); } } private void saveCoverArt(String path, String url) throws Exception { InputStream input = null; HttpClient client = new DefaultHttpClient(); try { HttpConnectionParams.setConnectionTimeout(client.getParams(), 20 * 1000); // 20 seconds HttpConnectionParams.setSoTimeout(client.getParams(), 20 * 1000); // 20 seconds HttpGet method = new HttpGet(url); HttpResponse response = client.execute(method); input = response.getEntity().getContent(); // Attempt to resolve proper suffix. String suffix = "jpg"; if (url.toLowerCase().endsWith(".gif")) { suffix = "gif"; } else if (url.toLowerCase().endsWith(".png")) { suffix = "png"; } // Check permissions. File newCoverFile = new File(path, "cover." + suffix); if (!securityService.isWriteAllowed(newCoverFile)) { throw new Exception("Permission denied: " + StringUtil.toHtml(newCoverFile.getPath())); } // If file exists, create a backup. backup(newCoverFile, new File(path, "cover.backup." + suffix)); // Write file. IOUtils.copy(input, new FileOutputStream(newCoverFile)); MediaFile mediaFile = mediaFileService.getMediaFile(path); // Rename existing cover file if new cover file is not the preferred. try { File coverFile = mediaFileService.getCoverArt(mediaFile); if (coverFile != null) { if (!newCoverFile.equals(coverFile)) { coverFile.renameTo(new File(coverFile.getCanonicalPath() + ".old")); LOG.info("Renamed old image file " + coverFile); } } } catch (Exception x) { LOG.warn("Failed to rename existing cover file.", x); } mediaFileService.refreshMediaFile(mediaFile); } finally { IOUtils.closeQuietly(input); client.getConnectionManager().shutdown(); } } private void backup(File newCoverFile, File backup) { if (newCoverFile.exists()) { if (backup.exists()) { backup.delete(); } if (newCoverFile.renameTo(backup)) { LOG.info("Backed up old image file to " + backup); } else { LOG.warn("Failed to create image file backup " + backup); } } } public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } public void setMediaFileService(MediaFileService mediaFileService) { this.mediaFileService = mediaFileService; } }
/** * Subclass of {@link InputStream} which provides on-the-fly transcoding. Instances of <code> * TranscodeInputStream</code> can be chained together, for instance to convert from OGG to WAV to * MP3. * * @author Sindre Mehus */ public class TranscodeInputStream extends InputStream { private static final Logger LOG = Logger.getLogger(TranscodeInputStream.class); private InputStream processInputStream; private OutputStream processOutputStream; private Process process; /** * Creates a transcoded input stream by executing the given command. If <code>in</code> is not * null, data from it is copied to the command. * * @param command The command to execute. * @param in Data to feed to the command. May be <code>null</code>. * @throws IOException If an I/O error occurs. */ public TranscodeInputStream(String[] command, final InputStream in) throws IOException { StringBuffer buf = new StringBuffer("Starting transcoder: "); for (String s : command) { buf.append('[').append(s).append("] "); } LOG.debug(buf); process = Runtime.getRuntime().exec(command); processOutputStream = process.getOutputStream(); processInputStream = process.getInputStream(); // Must read stderr from the process, otherwise it may block. final String name = command[0]; new InputStreamReaderThread(process.getErrorStream(), name, true).start(); // Copy data in a separate thread if (in != null) { new Thread(name + " TranscodedInputStream copy thread") { public void run() { try { IOUtils.copy(in, processOutputStream); } catch (IOException x) { // Intentionally ignored. Will happen if the remote player closes the stream. } finally { IOUtils.closeQuietly(in); IOUtils.closeQuietly(processOutputStream); } } }.start(); } } /** @see InputStream#read() */ public int read() throws IOException { return processInputStream.read(); } /** @see InputStream#read(byte[]) */ public int read(byte[] b) throws IOException { return processInputStream.read(b); } /** @see InputStream#read(byte[], int, int) */ public int read(byte[] b, int off, int len) throws IOException { return processInputStream.read(b, off, len); } /** @see InputStream#close() */ public void close() throws IOException { IOUtils.closeQuietly(processInputStream); IOUtils.closeQuietly(processOutputStream); if (process != null) { process.destroy(); } } }
/** * Provides services for instantiating and caching music files and cover art. * * @author Sindre Mehus */ public class MediaFileService { private Ehcache mediaFileCache; private Ehcache coverArtCache; private SettingsService settingsService; private SecurityService securityService; private LibraryBrowserService libraryBrowserService; private static final Logger LOG = Logger.getLogger(MediaFileService.class); public MediaFile getMediaFile(int trackId) { if (!mediaFileCache.isElementInMemory(trackId)) { LOG.debug("media file not in memory, load meta data from db!"); loadMediaFiles(Arrays.asList(trackId)); if (!mediaFileCache.isElementInMemory(trackId)) { // trackId might refer to a playing track/video that since have been removed return null; } } return (MediaFile) mediaFileCache.get(trackId).getValue(); } public List<MediaFile> getMediaFiles(List<Integer> trackIds) { List<MediaFile> mediaFiles = new ArrayList<>(); for (Integer trackId : trackIds) { MediaFile mediaFile = getMediaFile(trackId); if (mediaFile != null) { mediaFiles.add(mediaFile); } } return mediaFiles; } public void loadMediaFiles(List<Integer> mediaFileIds) { List<Integer> missingMediaFileIds = new ArrayList<>(mediaFileIds); for (Iterator<Integer> it = missingMediaFileIds.iterator(); it.hasNext(); ) { if (mediaFileCache.isElementInMemory(it.next())) { it.remove(); } } if (missingMediaFileIds.size() > 0) { List<Track> tracks = libraryBrowserService.getTracks(missingMediaFileIds); for (Track track : tracks) { mediaFileCache.put(new Element(track.getId(), getMediaFile(track))); } } } public static MediaFile getMediaFile(Track track) { MediaFile mediaFile = new MediaFile(track.getId()); mediaFile.setFile(new File(track.getMetaData().getPath())); mediaFile.getMetaData().setAlbum(track.getMetaData().getAlbum()); mediaFile.getMetaData().setAlbumId(track.getMetaData().getAlbumId()); mediaFile.getMetaData().setArtist(track.getMetaData().getArtist()); mediaFile.getMetaData().setArtistId(track.getMetaData().getArtistId()); mediaFile.getMetaData().setComposer(track.getMetaData().getComposer()); mediaFile.getMetaData().setBitRate((int) track.getMetaData().getBitrate()); mediaFile.getMetaData().setDiscNumber((int) track.getMetaData().getDiscNr()); mediaFile.getMetaData().setDuration((int) track.getMetaData().getDuration()); mediaFile.getMetaData().setFileSize((long) track.getMetaData().getSize()); mediaFile .getMetaData() .setFormat(track.getMetaData().getMediaType().getFilesuffix().toLowerCase()); mediaFile.getMetaData().setGenre(track.getMetaData().getGenre()); mediaFile.getMetaData().setTitle(track.getName()); mediaFile.getMetaData().setTrackNumber((int) track.getMetaData().getTrackNr()); mediaFile.getMetaData().setVariableBitRate(track.getMetaData().isVbr()); mediaFile.getMetaData().setYear(toYear(track.getMetaData().getYear())); mediaFile.getMetaData().setHasLyrics(track.getMetaData().hasLyrics()); return mediaFile; } private static String toYear(short year) { return year == 0 ? null : "" + year; } public File getCoverArt(MediaFile mediaFile) throws IOException { String coverArtFile = null; Element element = coverArtCache.get(mediaFile.getId()); if (element == null) { coverArtFile = libraryBrowserService.getCoverArtFileForTrack(mediaFile.getId()); coverArtCache.put(new Element(mediaFile.getId(), coverArtFile)); } else { coverArtFile = (String) element.getObjectValue(); } return coverArtFile == null ? null : new File(coverArtFile); } public List<Album> getAlbums(List<com.github.hakko.musiccabinet.domain.model.music.Album> alb) { return getAlbums(alb, false); } public List<Album> getAlbums( List<com.github.hakko.musiccabinet.domain.model.music.Album> alb, boolean onlyLocalArtwork) { List<Album> albums = new ArrayList<>(); List<Integer> trackIds = new ArrayList<>(); boolean preferLastFmArtwork = settingsService.isPreferLastFmArtwork(); for (com.github.hakko.musiccabinet.domain.model.music.Album a : alb) { Album album = new Album(); album.setArtistId(a.getArtist().getId()); album.setArtistName(a.getArtist().getName()); album.setId(a.getId()); album.setTitle(a.getName()); album.setYear(a.getYear()); album.setCoverArtPath(a.getCoverArtPath()); album.setCoverArtUrl(a.getCoverArtURL()); album.setTrackIds(a.getTrackIds()); if (album.getCoverArtPath() != null && album.getCoverArtUrl() != null) { if (preferLastFmArtwork && !onlyLocalArtwork) { album.setCoverArtPath(null); } else { album.setCoverArtUrl(null); } } trackIds.addAll(a.getTrackIds()); albums.add(album); } loadMediaFiles(trackIds); return albums; } /* * Inefficient MediaFile instantiation. Only to be used when we don't have an id, * like when loading files from a saved playlist. */ public MediaFile getMediaFile(String absolutePath) { int trackId = libraryBrowserService.getTrackId(absolutePath); return trackId == -1 ? null : getMediaFile(trackId); } /* * Instantiate MediaFile by path name. Only to be used by file based browser. */ public MediaFile getNonIndexedMediaFile(String pathName) { return getNonIndexedMediaFile(new File(pathName)); } /* * Instantiate MediaFile by path name. Only to be used by file based browser. */ public MediaFile getNonIndexedMediaFile(File file) { int fileId = -Math.abs(file.getAbsolutePath().hashCode()); LOG.debug( "request for non indexed media file " + file.getAbsolutePath() + ", cache as " + fileId); Element element = mediaFileCache.get(fileId); if (element != null) { // Check if cache is up-to-date. MediaFile cachedMediaFile = (MediaFile) element.getObjectValue(); if (cachedMediaFile.lastModified() >= file.lastModified()) { return cachedMediaFile; } } if (!securityService.isReadAllowed(file)) { throw new SecurityException("Access denied to file " + file); } MediaFile mediaFile = new MediaFile(fileId, file); mediaFileCache.put(new Element(fileId, mediaFile)); return mediaFile; } /** * Register in service locator so that non-Spring objects can access me. This method is invoked * automatically by Spring. */ public void init() { ServiceLocator.setMediaFileService(this); } public void setSettingsService(SettingsService settingsService) { this.settingsService = settingsService; } public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } public void setLibraryBrowserService(LibraryBrowserService libraryBrowserService) { this.libraryBrowserService = libraryBrowserService; } public void setMediaFileCache(Ehcache mediaFileCache) { this.mediaFileCache = mediaFileCache; mediaFileCache.removeAll(); // TODO : remove in version 0.8 or so, just clearing traces from 4.6 } public void setChildDirCache(Ehcache childDirCache) { childDirCache.removeAll(); // TODO : remove in version 0.8 or so, just clearing traces from 4.6 } public void setCoverArtCache(Ehcache coverArtCache) { this.coverArtCache = coverArtCache; } }
/** * A controller which streams the content of a {@link net.sourceforge.subsonic.domain.PlayQueue} to * a remote {@link Player}. * * @author Sindre Mehus */ public class StreamController implements Controller { private static final Logger LOG = Logger.getLogger(StreamController.class); private StatusService statusService; private PlayerService playerService; private PlaylistService playlistService; private SecurityService securityService; private SettingsService settingsService; private TranscodingService transcodingService; private AudioScrobblerService audioScrobblerService; private MediaFileService mediaFileService; private SearchService searchService; public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception { TransferStatus status = null; PlayQueueInputStream in = null; Player player = playerService.getPlayer(request, response, false, true); User user = securityService.getUserByName(player.getUsername()); try { if (!user.isStreamRole()) { response.sendError( HttpServletResponse.SC_FORBIDDEN, "Streaming is forbidden for user " + user.getUsername()); return null; } // If "playlist" request parameter is set, this is a Podcast request. In that case, create a // separate // play queue (in order to support multiple parallel Podcast streams). Integer playlistId = ServletRequestUtils.getIntParameter(request, "playlist"); boolean isPodcast = playlistId != null; if (isPodcast) { PlayQueue playQueue = new PlayQueue(); playQueue.addFiles(false, playlistService.getFilesInPlaylist(playlistId)); player.setPlayQueue(playQueue); Util.setContentLength(response, playQueue.length()); LOG.info("Incoming Podcast request for playlist " + playlistId); } String contentType = StringUtil.getMimeType(request.getParameter("suffix")); response.setContentType(contentType); String preferredTargetFormat = request.getParameter("format"); Integer maxBitRate = ServletRequestUtils.getIntParameter(request, "maxBitRate"); if (Integer.valueOf(0).equals(maxBitRate)) { maxBitRate = null; } VideoTranscodingSettings videoTranscodingSettings = null; // Is this a request for a single file (typically from the embedded Flash player)? // In that case, create a separate playlist (in order to support multiple parallel streams). // Also, enable partial download (HTTP byte range). MediaFile file = getSingleFile(request); boolean isSingleFile = file != null; LongRange range = null; if (isSingleFile) { PlayQueue playQueue = new PlayQueue(); playQueue.addFiles(true, file); player.setPlayQueue(playQueue); if (!file.isVideo()) { response.setIntHeader("ETag", file.getId()); // response.setHeader("Accept-Ranges", "bytes"); } TranscodingService.Parameters parameters = transcodingService.getParameters( file, player, maxBitRate, preferredTargetFormat, null, false); long fileLength = getFileLength(parameters); boolean isConversion = parameters.isDownsample() || parameters.isTranscode(); boolean estimateContentLength = ServletRequestUtils.getBooleanParameter(request, "estimateContentLength", false); boolean isHls = ServletRequestUtils.getBooleanParameter(request, "hls", false); range = getRange(request, file); if (range != null) { LOG.info("Got range: " + range); // response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // Util.setContentLength(response, fileLength - // range.getMinimumLong()); // long firstBytePos = range.getMinimumLong(); // long lastBytePos = fileLength - 1; // response.setHeader("Content-Range", "bytes " + firstBytePos + "-" + // lastBytePos + "/" + fileLength); /// if (isConversion) { response.setHeader("Accept-Ranges", "none"); } else { response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); long maxLength = fileLength; if (maxLength > range.getMaximumLong()) maxLength = range.getMaximumLong() + 1; Util.setContentLength(response, Math.max(maxLength - range.getMinimumLong(), 0)); long firstBytePos = range.getMinimumLong(); long lastBytePos = maxLength - 1; response.setHeader( "Content-Range", "bytes " + firstBytePos + "-" + lastBytePos + "/" + fileLength); } /// } else if (!isHls && (!isConversion || estimateContentLength)) { Util.setContentLength(response, fileLength); } if (isHls) { response.setContentType(StringUtil.getMimeType("ts")); // HLS is always MPEG TS. } else { String transcodedSuffix = transcodingService.getSuffix(player, file, preferredTargetFormat); response.setContentType(StringUtil.getMimeType(transcodedSuffix)); } if (file.isVideo() || isHls) { videoTranscodingSettings = createVideoTranscodingSettings(file, request); } } if (request.getMethod().equals("HEAD")) { return null; } // Terminate any other streams to this player. if (!isPodcast && !isSingleFile) { for (TransferStatus streamStatus : statusService.getStreamStatusesForPlayer(player)) { if (streamStatus.isActive()) { streamStatus.terminate(); } } } status = statusService.createStreamStatus(player); in = new PlayQueueInputStream( player, status, maxBitRate, preferredTargetFormat, videoTranscodingSettings, transcodingService, audioScrobblerService, mediaFileService, searchService); OutputStream out = RangeOutputStream.wrap(response.getOutputStream(), range); // Enabled SHOUTcast, if requested. boolean isShoutCastRequested = "1".equals(request.getHeader("icy-metadata")); if (isShoutCastRequested && !isSingleFile) { response.setHeader("icy-metaint", "" + ShoutCastOutputStream.META_DATA_INTERVAL); response.setHeader("icy-notice1", "This stream is served using FutureSonic"); response.setHeader("icy-notice2", "FutureSonic - Free media streamer - sonic.lt"); response.setHeader("icy-name", "FutureSonic"); response.setHeader("icy-genre", "Mixed"); response.setHeader("icy-url", "http://sonic.lt/"); out = new ShoutCastOutputStream(out, player.getPlayQueue(), settingsService); } final int BUFFER_SIZE = 2048; byte[] buf = new byte[BUFFER_SIZE]; while (true) { // Check if stream has been terminated. if (status.terminated()) { return null; } if (player.getPlayQueue().getStatus() == PlayQueue.Status.STOPPED) { if (isPodcast || isSingleFile) { break; } else { sendDummy(buf, out); } } else { int n = in.read(buf); if (n == -1) { if (isPodcast || isSingleFile) { break; } else { sendDummy(buf, out); } } else { out.write(buf, 0, n); } } } } finally { if (status != null) { securityService.updateUserByteCounts(user, status.getBytesTransfered(), 0L, 0L); statusService.removeStreamStatus(status); } IOUtils.closeQuietly(in); } return null; } private MediaFile getSingleFile(HttpServletRequest request) throws ServletRequestBindingException { String path = request.getParameter("path"); if (path != null) { return mediaFileService.getMediaFile(path); } Integer id = ServletRequestUtils.getIntParameter(request, "id"); if (id != null) { return mediaFileService.getMediaFile(id); } return null; } private long getFileLength(TranscodingService.Parameters parameters) { MediaFile file = parameters.getMediaFile(); if (!parameters.isDownsample() && !parameters.isTranscode()) { return file.getFileSize(); } Integer duration = file.getDurationSeconds(); Integer maxBitRate = parameters.getMaxBitRate(); if (duration == null) { LOG.warn("Unknown duration for " + file + ". Unable to estimate transcoded size."); return file.getFileSize(); } if (maxBitRate == null) { LOG.error("Unknown bit rate for " + file + ". Unable to estimate transcoded size."); return file.getFileSize(); } return duration * maxBitRate * 1000L / 8L; } private LongRange getRange(HttpServletRequest request, MediaFile file) { // First, look for "Range" HTTP header. LongRange range = StringUtil.parseRange(request.getHeader("Range")); if (range != null) { return range; } // Second, look for "offsetSeconds" request parameter. String offsetSeconds = request.getParameter("offsetSeconds"); range = parseAndConvertOffsetSeconds(offsetSeconds, file); if (range != null) { return range; } return null; } private LongRange parseAndConvertOffsetSeconds(String offsetSeconds, MediaFile file) { if (offsetSeconds == null) { return null; } try { Integer duration = file.getDurationSeconds(); Long fileSize = file.getFileSize(); if (duration == null || fileSize == null) { return null; } float offset = Float.parseFloat(offsetSeconds); // Convert from time offset to byte offset. long byteOffset = (long) (fileSize * (offset / duration)); return new LongRange(byteOffset, Long.MAX_VALUE); } catch (Exception x) { LOG.error("Failed to parse and convert time offset: " + offsetSeconds, x); return null; } } private VideoTranscodingSettings createVideoTranscodingSettings( MediaFile file, HttpServletRequest request) throws ServletRequestBindingException { Integer existingWidth = file.getWidth(); Integer existingHeight = file.getHeight(); Integer maxBitRate = ServletRequestUtils.getIntParameter(request, "maxBitRate"); int timeOffset = ServletRequestUtils.getIntParameter(request, "timeOffset", 0); int defaultDuration = file.getDurationSeconds() == null ? Integer.MAX_VALUE : file.getDurationSeconds() - timeOffset; int duration = ServletRequestUtils.getIntParameter(request, "duration", defaultDuration); boolean hls = ServletRequestUtils.getBooleanParameter(request, "hls", false); Dimension dim = getRequestedVideoSize(request.getParameter("size")); if (dim == null) { dim = getSuitableVideoSize(existingWidth, existingHeight, maxBitRate); } return new VideoTranscodingSettings(dim.width, dim.height, timeOffset, duration, hls); } protected Dimension getRequestedVideoSize(String sizeSpec) { if (sizeSpec == null) { return null; } Pattern pattern = Pattern.compile("^(\\d+)x(\\d+)$"); Matcher matcher = pattern.matcher(sizeSpec); if (matcher.find()) { int w = Integer.parseInt(matcher.group(1)); int h = Integer.parseInt(matcher.group(2)); if (w >= 0 && h >= 0 && w <= 2000 && h <= 2000) { return new Dimension(w, h); } } return null; } protected Dimension getSuitableVideoSize( Integer existingWidth, Integer existingHeight, Integer maxBitRate) { if (maxBitRate == null) { return new Dimension(400, 300); } int w, h; if (maxBitRate < 400) { w = 400; h = 300; } else if (maxBitRate < 600) { w = 480; h = 360; } else if (maxBitRate < 1800) { w = 640; h = 480; } else { w = 960; h = 720; } if (existingWidth == null || existingHeight == null) { return new Dimension(w, h); } if (existingWidth < w || existingHeight < h) { return new Dimension(even(existingWidth), even(existingHeight)); } double aspectRate = existingWidth.doubleValue() / existingHeight.doubleValue(); h = (int) Math.round(w / aspectRate); return new Dimension(even(w), even(h)); } // Make sure width and height are multiples of two, as some versions of ffmpeg require it. private int even(int size) { return size + (size % 2); } /** Feed the other end with some dummy data to keep it from reconnecting. */ private void sendDummy(byte[] buf, OutputStream out) throws IOException { try { Thread.sleep(2000); } catch (InterruptedException x) { LOG.warn("Interrupted in sleep.", x); } Arrays.fill(buf, (byte) 0xFF); out.write(buf); out.flush(); } public void setStatusService(StatusService statusService) { this.statusService = statusService; } public void setPlayerService(PlayerService playerService) { this.playerService = playerService; } public void setPlaylistService(PlaylistService playlistService) { this.playlistService = playlistService; } public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } public void setSettingsService(SettingsService settingsService) { this.settingsService = settingsService; } public void setTranscodingService(TranscodingService transcodingService) { this.transcodingService = transcodingService; } public void setAudioScrobblerService(AudioScrobblerService audioScrobblerService) { this.audioScrobblerService = audioScrobblerService; } public void setMediaFileService(MediaFileService mediaFileService) { this.mediaFileService = mediaFileService; } public void setSearchService(SearchService searchService) { this.searchService = searchService; } }
/** * Performs Lucene-based searching and indexing. * * @author Sindre Mehus * @version $Id$ * @see MediaScannerService */ public class SearchService { private static final Logger LOG = Logger.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"; private MediaFileService mediaFileService; private ArtistDao artistDao; private AlbumDao albumDao; private IndexWriter artistWriter; private IndexWriter artistId3Writer; private IndexWriter albumWriter; private IndexWriter albumId3Writer; private IndexWriter songWriter; public SearchService() { removeLocks(); } public void startIndexing() { try { artistWriter = createIndexWriter(ARTIST); artistId3Writer = createIndexWriter(ARTIST_ID3); albumWriter = createIndexWriter(ALBUM); albumId3Writer = createIndexWriter(ALBUM_ID3); songWriter = createIndexWriter(SONG); } catch (Exception x) { LOG.error("Failed to create search index.", x); } } public void index(MediaFile mediaFile) { try { if (mediaFile.isFile()) { songWriter.addDocument(SONG.createDocument(mediaFile)); } else if (mediaFile.isAlbum()) { albumWriter.addDocument(ALBUM.createDocument(mediaFile)); } else { artistWriter.addDocument(ARTIST.createDocument(mediaFile)); } } catch (Exception x) { LOG.error("Failed to create search index for " + mediaFile, x); } } public void index(Artist artist, MusicFolder musicFolder) { try { artistId3Writer.addDocument(ARTIST_ID3.createDocument(artist, musicFolder)); } catch (Exception x) { LOG.error("Failed to create search index for " + artist, x); } } public void index(Album album) { try { albumId3Writer.addDocument(ALBUM_ID3.createDocument(album)); } catch (Exception x) { LOG.error("Failed to create search index for " + album, x); } } public void stopIndexing() { try { artistWriter.optimize(); artistId3Writer.optimize(); albumWriter.optimize(); albumId3Writer.optimize(); songWriter.optimize(); } catch (Exception x) { LOG.error("Failed to create search index.", x); } finally { FileUtil.closeQuietly(artistId3Writer); FileUtil.closeQuietly(artistWriter); FileUtil.closeQuietly(albumWriter); FileUtil.closeQuietly(albumId3Writer); FileUtil.closeQuietly(songWriter); } } public SearchResult search( SearchCriteria criteria, List<MusicFolder> musicFolders, IndexType indexType) { SearchResult result = new SearchResult(); int offset = criteria.getOffset(); int count = criteria.getCount(); result.setOffset(offset); IndexReader reader = null; try { reader = createIndexReader(indexType); Searcher searcher = new IndexSearcher(reader); Analyzer analyzer = new SubsonicAnalyzer(); MultiFieldQueryParser queryParser = new MultiFieldQueryParser( LUCENE_VERSION, indexType.getFields(), analyzer, indexType.getBoosts()); BooleanQuery query = new BooleanQuery(); query.add(queryParser.parse(analyzeQuery(criteria.getQuery())), BooleanClause.Occur.MUST); List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); for (MusicFolder musicFolder : musicFolders) { if (indexType == ALBUM_ID3 || indexType == ARTIST_ID3) { musicFolderQueries.add( new SpanTermQuery( new Term(FIELD_FOLDER_ID, NumericUtils.intToPrefixCoded(musicFolder.getId())))); } else { musicFolderQueries.add( new SpanTermQuery(new Term(FIELD_FOLDER, musicFolder.getPath().getPath()))); } } query.add( new SpanOrQuery(musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])), BooleanClause.Occur.MUST); TopDocs topDocs = searcher.search(query, null, offset + count); result.setTotalHits(topDocs.totalHits); int start = Math.min(offset, topDocs.totalHits); int end = Math.min(start + count, topDocs.totalHits); for (int i = start; i < end; i++) { Document doc = searcher.doc(topDocs.scoreDocs[i].doc); switch (indexType) { case SONG: case ARTIST: case ALBUM: MediaFile mediaFile = mediaFileService.getMediaFile(Integer.valueOf(doc.get(FIELD_ID))); addIfNotNull(mediaFile, result.getMediaFiles()); break; case ARTIST_ID3: Artist artist = artistDao.getArtist(Integer.valueOf(doc.get(FIELD_ID))); addIfNotNull(artist, result.getArtists()); break; case ALBUM_ID3: Album album = albumDao.getAlbum(Integer.valueOf(doc.get(FIELD_ID))); addIfNotNull(album, result.getAlbums()); break; default: break; } } } catch (Throwable x) { LOG.error("Failed to execute Lucene search.", x); } finally { FileUtil.closeQuietly(reader); } return result; } private String analyzeQuery(String query) throws IOException { StringBuilder result = new StringBuilder(); ASCIIFoldingFilter filter = new ASCIIFoldingFilter(new StandardTokenizer(LUCENE_VERSION, new StringReader(query))); TermAttribute termAttribute = filter.getAttribute(TermAttribute.class); while (filter.incrementToken()) { result.append(termAttribute.term()).append("* "); } return result.toString(); } /** * Returns a number of random songs. * * @param criteria Search criteria. * @return List of random songs. */ public List<MediaFile> getRandomSongs(RandomSearchCriteria criteria) { List<MediaFile> result = new ArrayList<MediaFile>(); IndexReader reader = null; try { reader = createIndexReader(SONG); Searcher searcher = new IndexSearcher(reader); BooleanQuery query = new BooleanQuery(); query.add( new TermQuery(new Term(FIELD_MEDIA_TYPE, MediaFile.MediaType.MUSIC.name().toLowerCase())), BooleanClause.Occur.MUST); if (criteria.getGenre() != null) { String genre = normalizeGenre(criteria.getGenre()); query.add(new TermQuery(new Term(FIELD_GENRE, genre)), BooleanClause.Occur.MUST); } if (criteria.getFromYear() != null || criteria.getToYear() != null) { NumericRangeQuery<Integer> rangeQuery = NumericRangeQuery.newIntRange( FIELD_YEAR, criteria.getFromYear(), criteria.getToYear(), true, true); query.add(rangeQuery, BooleanClause.Occur.MUST); } List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); for (MusicFolder musicFolder : criteria.getMusicFolders()) { musicFolderQueries.add( new SpanTermQuery(new Term(FIELD_FOLDER, musicFolder.getPath().getPath()))); } query.add( new SpanOrQuery(musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])), BooleanClause.Occur.MUST); TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE); List<ScoreDoc> scoreDocs = Lists.newArrayList(topDocs.scoreDocs); Random random = new Random(System.currentTimeMillis()); while (!scoreDocs.isEmpty() && result.size() < criteria.getCount()) { int index = random.nextInt(scoreDocs.size()); Document doc = searcher.doc(scoreDocs.remove(index).doc); int id = Integer.valueOf(doc.get(FIELD_ID)); try { addIfNotNull(mediaFileService.getMediaFile(id), result); } catch (Exception x) { LOG.warn("Failed to get media file " + id); } } } catch (Throwable x) { LOG.error("Failed to search or random songs.", x); } finally { FileUtil.closeQuietly(reader); } return result; } private static String normalizeGenre(String genre) { return genre.toLowerCase().replace(" ", "").replace("-", ""); } /** * Returns a number of random albums. * * @param count Number of albums to return. * @param musicFolders Only return albums from these folders. * @return List of random albums. */ public List<MediaFile> getRandomAlbums(int count, List<MusicFolder> musicFolders) { List<MediaFile> result = new ArrayList<MediaFile>(); IndexReader reader = null; try { reader = createIndexReader(ALBUM); Searcher searcher = new IndexSearcher(reader); List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); for (MusicFolder musicFolder : musicFolders) { musicFolderQueries.add( new SpanTermQuery(new Term(FIELD_FOLDER, musicFolder.getPath().getPath()))); } Query query = new SpanOrQuery(musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])); TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE); List<ScoreDoc> scoreDocs = Lists.newArrayList(topDocs.scoreDocs); Random random = new Random(System.currentTimeMillis()); while (!scoreDocs.isEmpty() && result.size() < count) { int index = random.nextInt(scoreDocs.size()); Document doc = searcher.doc(scoreDocs.remove(index).doc); int id = Integer.valueOf(doc.get(FIELD_ID)); try { addIfNotNull(mediaFileService.getMediaFile(id), result); } catch (Exception x) { LOG.warn("Failed to get media file " + id, x); } } } catch (Throwable x) { LOG.error("Failed to search for random albums.", x); } finally { FileUtil.closeQuietly(reader); } return result; } /** * Returns a number of random albums, using ID3 tag. * * @param count Number of albums to return. * @param musicFolders Only return albums from these folders. * @return List of random albums. */ public List<Album> getRandomAlbumsId3(int count, List<MusicFolder> musicFolders) { List<Album> result = new ArrayList<Album>(); IndexReader reader = null; try { reader = createIndexReader(ALBUM_ID3); Searcher searcher = new IndexSearcher(reader); List<SpanTermQuery> musicFolderQueries = new ArrayList<SpanTermQuery>(); for (MusicFolder musicFolder : musicFolders) { musicFolderQueries.add( new SpanTermQuery( new Term(FIELD_FOLDER_ID, NumericUtils.intToPrefixCoded(musicFolder.getId())))); } Query query = new SpanOrQuery(musicFolderQueries.toArray(new SpanQuery[musicFolderQueries.size()])); TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE); List<ScoreDoc> scoreDocs = Lists.newArrayList(topDocs.scoreDocs); Random random = new Random(System.currentTimeMillis()); while (!scoreDocs.isEmpty() && result.size() < count) { int index = random.nextInt(scoreDocs.size()); Document doc = searcher.doc(scoreDocs.remove(index).doc); int id = Integer.valueOf(doc.get(FIELD_ID)); try { addIfNotNull(albumDao.getAlbum(id), result); } catch (Exception x) { LOG.warn("Failed to get album file " + id, x); } } } catch (Throwable x) { LOG.error("Failed to search for random albums.", x); } finally { FileUtil.closeQuietly(reader); } return result; } private <T> void addIfNotNull(T value, List<T> list) { if (value != null) { list.add(value); } } private IndexWriter createIndexWriter(IndexType indexType) throws IOException { File dir = getIndexDirectory(indexType); return new IndexWriter( FSDirectory.open(dir), new SubsonicAnalyzer(), 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.getSubsonicHome(), LUCENE_DIR); } private File getIndexDirectory(IndexType indexType) { return new File(getIndexRootDirectory(), indexType.toString().toLowerCase()); } private void removeLocks() { for (IndexType indexType : IndexType.values()) { Directory dir = null; try { dir = FSDirectory.open(getIndexDirectory(indexType)); if (IndexWriter.isLocked(dir)) { IndexWriter.unlock(dir); LOG.info("Removed Lucene lock file in " + dir); } } catch (Exception x) { LOG.warn("Failed to remove Lucene lock file in " + dir, x); } finally { FileUtil.closeQuietly(dir); } } } public void setMediaFileService(MediaFileService mediaFileService) { this.mediaFileService = mediaFileService; } public void setArtistDao(ArtistDao artistDao) { this.artistDao = artistDao; } public void setAlbumDao(AlbumDao albumDao) { this.albumDao = albumDao; } public static enum IndexType { SONG(new String[] {FIELD_TITLE, FIELD_ARTIST}, FIELD_TITLE) { @Override public Document createDocument(MediaFile mediaFile) { Document doc = new Document(); doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId())); doc.add( new Field( FIELD_MEDIA_TYPE, mediaFile.getMediaType().name(), Field.Store.NO, Field.Index.ANALYZED_NO_NORMS)); if (mediaFile.getTitle() != null) { doc.add( new Field(FIELD_TITLE, mediaFile.getTitle(), Field.Store.YES, Field.Index.ANALYZED)); } if (mediaFile.getArtist() != null) { doc.add( new Field( FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED)); } if (mediaFile.getGenre() != null) { doc.add( new Field( FIELD_GENRE, normalizeGenre(mediaFile.getGenre()), Field.Store.NO, Field.Index.ANALYZED)); } if (mediaFile.getYear() != null) { doc.add( new NumericField(FIELD_YEAR, Field.Store.NO, true).setIntValue(mediaFile.getYear())); } if (mediaFile.getFolder() != null) { doc.add( new Field( FIELD_FOLDER, mediaFile.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS)); } return doc; } }, ALBUM(new String[] {FIELD_ALBUM, FIELD_ARTIST, FIELD_FOLDER}, FIELD_ALBUM) { @Override public Document createDocument(MediaFile mediaFile) { Document doc = new Document(); doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId())); if (mediaFile.getArtist() != null) { doc.add( new Field( FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED)); } if (mediaFile.getAlbumName() != null) { doc.add( new Field( FIELD_ALBUM, mediaFile.getAlbumName(), Field.Store.YES, Field.Index.ANALYZED)); } if (mediaFile.getFolder() != null) { doc.add( new Field( FIELD_FOLDER, mediaFile.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS)); } return doc; } }, ALBUM_ID3(new String[] {FIELD_ALBUM, FIELD_ARTIST, FIELD_FOLDER_ID}, FIELD_ALBUM) { @Override public Document createDocument(Album album) { Document doc = new Document(); doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(album.getId())); if (album.getArtist() != null) { doc.add( new Field(FIELD_ARTIST, album.getArtist(), Field.Store.YES, Field.Index.ANALYZED)); } if (album.getName() != null) { doc.add(new Field(FIELD_ALBUM, album.getName(), Field.Store.YES, Field.Index.ANALYZED)); } if (album.getFolderId() != null) { doc.add( new NumericField(FIELD_FOLDER_ID, Field.Store.NO, true) .setIntValue(album.getFolderId())); } return doc; } }, ARTIST(new String[] {FIELD_ARTIST, FIELD_FOLDER}, null) { @Override public Document createDocument(MediaFile mediaFile) { Document doc = new Document(); doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId())); if (mediaFile.getArtist() != null) { doc.add( new Field( FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED)); } if (mediaFile.getFolder() != null) { doc.add( new Field( FIELD_FOLDER, mediaFile.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS)); } return doc; } }, ARTIST_ID3(new String[] {FIELD_ARTIST}, null) { @Override public Document createDocument(Artist artist, MusicFolder musicFolder) { Document doc = new Document(); doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(artist.getId())); doc.add(new Field(FIELD_ARTIST, artist.getName(), Field.Store.YES, Field.Index.ANALYZED)); doc.add( new NumericField(FIELD_FOLDER_ID, Field.Store.NO, true) .setIntValue(musicFolder.getId())); return doc; } }; private final String[] fields; private final Map<String, Float> boosts; private IndexType(String[] fields, String boostedField) { this.fields = fields; boosts = new HashMap<String, Float>(); if (boostedField != null) { boosts.put(boostedField, 2.0F); } } public String[] getFields() { return fields; } protected Document createDocument(MediaFile mediaFile) { throw new UnsupportedOperationException(); } protected Document createDocument(Artist artist, MusicFolder musicFolder) { throw new UnsupportedOperationException(); } protected Document createDocument(Album album) { throw new UnsupportedOperationException(); } public Map<String, Float> getBoosts() { return boosts; } } private class SubsonicAnalyzer extends StandardAnalyzer { private SubsonicAnalyzer() { 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; } } }
/** * Provides services for Podcast reception. * * @author Sindre Mehus */ public class PodcastService { private static final Logger LOG = Logger.getLogger(PodcastService.class); private static final DateFormat[] RSS_DATE_FORMATS = { new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US), new SimpleDateFormat("dd MMM yyyy HH:mm:ss Z", Locale.US) }; private static final Namespace[] ITUNES_NAMESPACES = { Namespace.getNamespace("http://www.itunes.com/DTDs/Podcast-1.0.dtd"), Namespace.getNamespace("http://www.itunes.com/dtds/podcast-1.0.dtd") }; private final ExecutorService refreshExecutor; private final ExecutorService downloadExecutor; private final ScheduledExecutorService scheduledExecutor; private ScheduledFuture<?> scheduledRefresh; private PodcastDao podcastDao; private SettingsService settingsService; private SecurityService securityService; private MediaFileService mediaFileService; public PodcastService() { ThreadFactory threadFactory = new ThreadFactory() { public Thread newThread(Runnable r) { Thread t = Executors.defaultThreadFactory().newThread(r); t.setDaemon(true); return t; } }; refreshExecutor = Executors.newFixedThreadPool(5, threadFactory); downloadExecutor = Executors.newFixedThreadPool(3, threadFactory); scheduledExecutor = Executors.newSingleThreadScheduledExecutor(threadFactory); } public synchronized void init() { // Clean up partial downloads. for (PodcastChannel channel : getAllChannels()) { for (PodcastEpisode episode : getEpisodes(channel.getId(), false)) { if (episode.getStatus() == PodcastStatus.DOWNLOADING) { deleteEpisode(episode.getId(), false); LOG.info( "Deleted Podcast episode '" + episode.getTitle() + "' since download was interrupted."); } } } schedule(); } public synchronized void schedule() { Runnable task = new Runnable() { public void run() { LOG.info("Starting scheduled Podcast refresh."); refreshAllChannels(true); LOG.info("Completed scheduled Podcast refresh."); } }; if (scheduledRefresh != null) { scheduledRefresh.cancel(true); } int hoursBetween = settingsService.getPodcastUpdateInterval(); if (hoursBetween == -1) { LOG.info("Automatic Podcast update disabled."); return; } long periodMillis = hoursBetween * 60L * 60L * 1000L; long initialDelayMillis = 5L * 60L * 1000L; scheduledRefresh = scheduledExecutor.scheduleAtFixedRate( task, initialDelayMillis, periodMillis, TimeUnit.MILLISECONDS); Date firstTime = new Date(System.currentTimeMillis() + initialDelayMillis); LOG.info( "Automatic Podcast update scheduled to run every " + hoursBetween + " hour(s), starting at " + firstTime); } /** * Creates a new Podcast channel. * * @param url The URL of the Podcast channel. */ public void createChannel(String url) { url = sanitizeUrl(url); PodcastChannel channel = new PodcastChannel(url); int channelId = podcastDao.createChannel(channel); refreshChannels(Arrays.asList(getChannel(channelId)), true); } private String sanitizeUrl(String url) { return url.replace(" ", "%20"); } private PodcastChannel getChannel(int channelId) { for (PodcastChannel channel : getAllChannels()) { if (channelId == channel.getId()) { return channel; } } return null; } /** * Returns all Podcast channels. * * @return Possibly empty list of all Podcast channels. */ public List<PodcastChannel> getAllChannels() { return podcastDao.getAllChannels(); } /** * Returns all Podcast episodes for a given channel. * * @param channelId The Podcast channel ID. * @param includeDeleted Whether to include logically deleted episodes in the result. * @return Possibly empty list of all Podcast episodes for the given channel, sorted in reverse * chronological order (newest episode first). */ public List<PodcastEpisode> getEpisodes(int channelId, boolean includeDeleted) { List<PodcastEpisode> all = podcastDao.getEpisodes(channelId); if (includeDeleted) { return all; } List<PodcastEpisode> filtered = new ArrayList<PodcastEpisode>(); for (PodcastEpisode episode : all) { if (episode.getStatus() != PodcastStatus.DELETED) { filtered.add(episode); } } return filtered; } public PodcastEpisode getEpisode(int episodeId, boolean includeDeleted) { PodcastEpisode episode = podcastDao.getEpisode(episodeId); if (episode == null) { return null; } if (episode.getStatus() == PodcastStatus.DELETED && !includeDeleted) { return null; } return episode; } private PodcastEpisode getEpisode(int channelId, String url) { if (url == null) { return null; } for (PodcastEpisode episode : getEpisodes(channelId, true)) { if (url.equals(episode.getUrl())) { return episode; } } return null; } public void refreshAllChannels(boolean downloadEpisodes) { refreshChannels(getAllChannels(), downloadEpisodes); } private void refreshChannels( final List<PodcastChannel> channels, final boolean downloadEpisodes) { for (final PodcastChannel channel : channels) { Runnable task = new Runnable() { public void run() { doRefreshChannel(channel, downloadEpisodes); } }; refreshExecutor.submit(task); } } @SuppressWarnings({"unchecked"}) private void doRefreshChannel(PodcastChannel channel, boolean downloadEpisodes) { InputStream in = null; HttpClient client = new DefaultHttpClient(); try { channel.setStatus(PodcastStatus.DOWNLOADING); channel.setErrorMessage(null); podcastDao.updateChannel(channel); HttpConnectionParams.setConnectionTimeout(client.getParams(), 2 * 60 * 1000); // 2 minutes HttpConnectionParams.setSoTimeout(client.getParams(), 10 * 60 * 1000); // 10 minutes HttpGet method = new HttpGet(channel.getUrl()); HttpResponse response = client.execute(method); in = response.getEntity().getContent(); Document document = new SAXBuilder().build(in); Element channelElement = document.getRootElement().getChild("channel"); channel.setTitle(channelElement.getChildTextTrim("title")); channel.setDescription(channelElement.getChildTextTrim("description")); channel.setStatus(PodcastStatus.COMPLETED); channel.setErrorMessage(null); podcastDao.updateChannel(channel); refreshEpisodes(channel, channelElement.getChildren("item")); } catch (Exception x) { LOG.warn("Failed to get/parse RSS file for Podcast channel " + channel.getUrl(), x); channel.setStatus(PodcastStatus.ERROR); channel.setErrorMessage(x.toString()); podcastDao.updateChannel(channel); } finally { IOUtils.closeQuietly(in); client.getConnectionManager().shutdown(); } if (downloadEpisodes) { for (final PodcastEpisode episode : getEpisodes(channel.getId(), false)) { if (episode.getStatus() == PodcastStatus.NEW && episode.getUrl() != null) { downloadEpisode(episode); } } } } public void downloadEpisode(final PodcastEpisode episode) { Runnable task = new Runnable() { public void run() { doDownloadEpisode(episode); } }; downloadExecutor.submit(task); } private void refreshEpisodes(PodcastChannel channel, List<Element> episodeElements) { List<PodcastEpisode> episodes = new ArrayList<PodcastEpisode>(); for (Element episodeElement : episodeElements) { String title = episodeElement.getChildTextTrim("title"); String duration = getITunesElement(episodeElement, "duration"); String description = episodeElement.getChildTextTrim("description"); if (StringUtils.isBlank(description)) { description = getITunesElement(episodeElement, "summary"); } Element enclosure = episodeElement.getChild("enclosure"); if (enclosure == null) { LOG.debug("No enclosure found for episode " + title); continue; } String url = enclosure.getAttributeValue("url"); url = sanitizeUrl(url); if (url == null) { LOG.debug("No enclosure URL found for episode " + title); continue; } if (getEpisode(channel.getId(), url) == null) { Long length = null; try { length = new Long(enclosure.getAttributeValue("length")); } catch (Exception x) { LOG.warn("Failed to parse enclosure length.", x); } Date date = parseDate(episodeElement.getChildTextTrim("pubDate")); PodcastEpisode episode = new PodcastEpisode( null, channel.getId(), url, null, title, description, date, duration, length, 0L, PodcastStatus.NEW, null); episodes.add(episode); LOG.info("Created Podcast episode " + title); } } // Sort episode in reverse chronological order (newest first) Collections.sort( episodes, new Comparator<PodcastEpisode>() { public int compare(PodcastEpisode a, PodcastEpisode b) { long timeA = a.getPublishDate() == null ? 0L : a.getPublishDate().getTime(); long timeB = b.getPublishDate() == null ? 0L : b.getPublishDate().getTime(); if (timeA < timeB) { return 1; } if (timeA > timeB) { return -1; } return 0; } }); // Create episodes in database, skipping the proper number of episodes. int downloadCount = settingsService.getPodcastEpisodeDownloadCount(); if (downloadCount == -1) { downloadCount = Integer.MAX_VALUE; } for (int i = 0; i < episodes.size(); i++) { PodcastEpisode episode = episodes.get(i); if (i >= downloadCount) { episode.setStatus(PodcastStatus.SKIPPED); } podcastDao.createEpisode(episode); } } private Date parseDate(String s) { for (DateFormat dateFormat : RSS_DATE_FORMATS) { try { return dateFormat.parse(s); } catch (Exception x) { // Ignored. } } LOG.warn("Failed to parse publish date: '" + s + "'."); return null; } private String getITunesElement(Element element, String childName) { for (Namespace ns : ITUNES_NAMESPACES) { String value = element.getChildTextTrim(childName, ns); if (value != null) { return value; } } return null; } private void doDownloadEpisode(PodcastEpisode episode) { InputStream in = null; OutputStream out = null; if (getEpisode(episode.getId(), false) == null) { LOG.info("Podcast " + episode.getUrl() + " was deleted. Aborting download."); return; } LOG.info("Starting to download Podcast from " + episode.getUrl()); HttpClient client = new DefaultHttpClient(); try { PodcastChannel channel = getChannel(episode.getChannelId()); HttpConnectionParams.setConnectionTimeout(client.getParams(), 2 * 60 * 1000); // 2 minutes HttpConnectionParams.setSoTimeout(client.getParams(), 10 * 60 * 1000); // 10 minutes HttpGet method = new HttpGet(episode.getUrl()); HttpResponse response = client.execute(method); in = response.getEntity().getContent(); File file = getFile(channel, episode); out = new FileOutputStream(file); episode.setStatus(PodcastStatus.DOWNLOADING); episode.setBytesDownloaded(0L); episode.setErrorMessage(null); episode.setPath(file.getPath()); podcastDao.updateEpisode(episode); byte[] buffer = new byte[4096]; long bytesDownloaded = 0; int n; long nextLogCount = 30000L; while ((n = in.read(buffer)) != -1) { out.write(buffer, 0, n); bytesDownloaded += n; if (bytesDownloaded > nextLogCount) { episode.setBytesDownloaded(bytesDownloaded); nextLogCount += 30000L; if (getEpisode(episode.getId(), false) == null) { break; } podcastDao.updateEpisode(episode); } } if (getEpisode(episode.getId(), false) == null) { LOG.info("Podcast " + episode.getUrl() + " was deleted. Aborting download."); IOUtils.closeQuietly(out); file.delete(); } else { episode.setBytesDownloaded(bytesDownloaded); podcastDao.updateEpisode(episode); LOG.info("Downloaded " + bytesDownloaded + " bytes from Podcast " + episode.getUrl()); IOUtils.closeQuietly(out); episode.setStatus(PodcastStatus.COMPLETED); podcastDao.updateEpisode(episode); deleteObsoleteEpisodes(channel); } } catch (Exception x) { LOG.warn("Failed to download Podcast from " + episode.getUrl(), x); episode.setStatus(PodcastStatus.ERROR); episode.setErrorMessage(x.toString()); podcastDao.updateEpisode(episode); } finally { IOUtils.closeQuietly(in); IOUtils.closeQuietly(out); client.getConnectionManager().shutdown(); } } private synchronized void deleteObsoleteEpisodes(PodcastChannel channel) { int episodeCount = settingsService.getPodcastEpisodeRetentionCount(); if (episodeCount == -1) { return; } List<PodcastEpisode> episodes = getEpisodes(channel.getId(), false); // Don't do anything if other episodes of the same channel is currently downloading. for (PodcastEpisode episode : episodes) { if (episode.getStatus() == PodcastStatus.DOWNLOADING) { return; } } // Reverse array to get chronological order (oldest episodes first). Collections.reverse(episodes); int episodesToDelete = Math.max(0, episodes.size() - episodeCount); for (int i = 0; i < episodesToDelete; i++) { deleteEpisode(episodes.get(i).getId(), true); LOG.info("Deleted old Podcast episode " + episodes.get(i).getUrl()); } } private synchronized File getFile(PodcastChannel channel, PodcastEpisode episode) { File podcastDir = new File(settingsService.getPodcastFolder()); File channelDir = new File(podcastDir, StringUtil.fileSystemSafe(channel.getTitle())); if (!channelDir.exists()) { boolean ok = channelDir.mkdirs(); if (!ok) { throw new RuntimeException("Failed to create directory " + channelDir); } MediaFile mediaFile = mediaFileService.getMediaFile(channelDir); mediaFile.setComment(channel.getDescription()); mediaFileService.updateMediaFile(mediaFile); } String filename = StringUtil.getUrlFile(episode.getUrl()); if (filename == null) { filename = episode.getTitle(); } filename = StringUtil.fileSystemSafe(filename); String extension = FilenameUtils.getExtension(filename); filename = FilenameUtils.removeExtension(filename); if (StringUtils.isBlank(extension)) { extension = "mp3"; } File file = new File(channelDir, filename + "." + extension); for (int i = 0; file.exists(); i++) { file = new File(channelDir, filename + i + "." + extension); } if (!securityService.isWriteAllowed(file)) { throw new SecurityException("Access denied to file " + file); } return file; } /** * Deletes the Podcast channel with the given ID. * * @param channelId The Podcast channel ID. */ public void deleteChannel(int channelId) { // Delete all associated episodes (in case they have files that need to be deleted). List<PodcastEpisode> episodes = getEpisodes(channelId, false); for (PodcastEpisode episode : episodes) { deleteEpisode(episode.getId(), false); } podcastDao.deleteChannel(channelId); } /** * Deletes the Podcast episode with the given ID. * * @param episodeId The Podcast episode ID. * @param logicalDelete Whether to perform a logical delete by setting the episode status to * {@link PodcastStatus#DELETED}. */ public void deleteEpisode(int episodeId, boolean logicalDelete) { PodcastEpisode episode = podcastDao.getEpisode(episodeId); if (episode == null) { return; } // Delete file. if (episode.getPath() != null) { File file = new File(episode.getPath()); if (file.exists()) { file.delete(); // TODO: Delete directory if empty? } } if (logicalDelete) { episode.setStatus(PodcastStatus.DELETED); episode.setErrorMessage(null); podcastDao.updateEpisode(episode); } else { podcastDao.deleteEpisode(episodeId); } } public void setPodcastDao(PodcastDao podcastDao) { this.podcastDao = podcastDao; } public void setSettingsService(SettingsService settingsService) { this.settingsService = settingsService; } public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } public void setMediaFileService(MediaFileService mediaFileService) { this.mediaFileService = mediaFileService; } }
/** * Provides database services for playlists. * * @author Sindre Mehus */ public class PlaylistDao extends AbstractDao { private static final Logger LOG = Logger.getLogger(PlaylistDao.class); private static final String COLUMNS = "id, username, is_public, name, comment, file_count, duration_seconds, " + "created, changed, imported_from"; private final RowMapper rowMapper = new PlaylistMapper(); public List<Playlist> getReadablePlaylistsForUser(String username) { List<Playlist> result1 = getWritablePlaylistsForUser(username); List<Playlist> result2 = query("select " + COLUMNS + " from playlist where is_public", rowMapper); List<Playlist> result3 = query( "select " + prefix(COLUMNS, "playlist") + " from playlist, playlist_user where " + "playlist.id = playlist_user.playlist_id and " + "playlist.username != ? and " + "playlist_user.username = ?", rowMapper, username, username); // Put in sorted map to avoid duplicates. SortedMap<Integer, Playlist> map = new TreeMap<Integer, Playlist>(); for (Playlist playlist : result1) { map.put(playlist.getId(), playlist); } for (Playlist playlist : result2) { map.put(playlist.getId(), playlist); } for (Playlist playlist : result3) { map.put(playlist.getId(), playlist); } return new ArrayList<Playlist>(map.values()); } public List<Playlist> getWritablePlaylistsForUser(String username) { return query("select " + COLUMNS + " from playlist where username=?", rowMapper, username); } public Playlist getPlaylist(int id) { return queryOne("select " + COLUMNS + " from playlist where id=?", rowMapper, id); } public List<Playlist> getAllPlaylists() { return query("select " + COLUMNS + " from playlist", rowMapper); } public synchronized void createPlaylist(Playlist playlist) { update( "insert into playlist(" + COLUMNS + ") values(" + questionMarks(COLUMNS) + ")", null, playlist.getUsername(), playlist.isShared(), playlist.getName(), playlist.getComment(), 0, 0, playlist.getCreated(), playlist.getChanged(), playlist.getImportedFrom()); int id = queryForInt("select max(id) from playlist", 0); playlist.setId(id); } public void setFilesInPlaylist(int id, List<MediaFile> files) { update("delete from playlist_file where playlist_id=?", id); int duration = 0; for (MediaFile file : files) { update( "insert into playlist_file (playlist_id, media_file_id) values (?, ?)", id, file.getId()); if (file.getDurationSeconds() != null) { duration += file.getDurationSeconds(); } } update( "update playlist set file_count=?, duration_seconds=?, changed=? where id=?", files.size(), duration, new Date(), id); } public List<String> getPlaylistUsers(int playlistId) { return queryForStrings("select username from playlist_user where playlist_id=?", playlistId); } public void addPlaylistUser(int playlistId, String username) { if (!getPlaylistUsers(playlistId).contains(username)) { update("insert into playlist_user(playlist_id,username) values (?,?)", playlistId, username); } } public void deletePlaylistUser(int playlistId, String username) { update("delete from playlist_user where playlist_id=? and username=?", playlistId, username); } public synchronized void deletePlaylist(int id) { update("delete from playlist where id=?", id); } public void updatePlaylist(Playlist playlist) { update( "update playlist set username=?, is_public=?, name=?, comment=?, changed=?, imported_from=? where id=?", playlist.getUsername(), playlist.isShared(), playlist.getName(), playlist.getComment(), new Date(), playlist.getImportedFrom(), playlist.getId()); } private static class PlaylistMapper implements ParameterizedRowMapper<Playlist> { public Playlist mapRow(ResultSet rs, int rowNum) throws SQLException { return new Playlist( rs.getInt(1), rs.getString(2), rs.getBoolean(3), rs.getString(4), rs.getString(5), rs.getInt(6), rs.getInt(7), rs.getTimestamp(8), rs.getTimestamp(9), rs.getString(10)); } } }