From 9584bfaea5e9ea0544485a929e79ac05c4a0de6e Mon Sep 17 00:00:00 2001 From: Andrew DeMaria Date: Thu, 5 Jan 2017 21:52:08 -0700 Subject: [PATCH] Improve Playlist Handling - Use external library chamelon (lizzy) - Adds the ability to specify playlist export format - Fixes some deficiences with playlist handling Signed-off-by: Andrew DeMaria --- libresonic-main/pom.xml | 28 ++ .../controller/ImportPlaylistController.java | 4 +- .../player/service/PlaylistService.java | 340 ++++-------------- .../player/service/SettingsService.java | 6 + .../DefaultPlaylistExportHandler.java | 49 +++ .../DefaultPlaylistImportHandler.java | 97 +++++ .../playlist/PlaylistExportHandler.java | 11 + .../playlist/PlaylistImportHandler.java | 14 + .../playlist/XspfPlaylistExportHandler.java | 66 ++++ .../playlist/XspfPlaylistImportHandler.java | 73 ++++ ...hameleon.playlist.SpecificPlaylistProvider | 21 ++ .../resources/applicationContext-service.xml | 11 +- .../service/PlaylistServiceTestExport.java | 108 ++++++ .../service/PlaylistServiceTestImport.java | 210 +++++++++++ .../src/test/resources/PLAYLISTS/23.m3u | 3 + .../src/test/resources/PLAYLISTS/23.pls | 6 + .../src/test/resources/PLAYLISTS/23.xspf | 8 + 17 files changed, 777 insertions(+), 278 deletions(-) create mode 100644 libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistExportHandler.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistImportHandler.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistExportHandler.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistImportHandler.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistExportHandler.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistImportHandler.java create mode 100644 libresonic-main/src/main/resources/META-INF/services/chameleon.playlist.SpecificPlaylistProvider create mode 100644 libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestExport.java create mode 100644 libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestImport.java create mode 100644 libresonic-main/src/test/resources/PLAYLISTS/23.m3u create mode 100644 libresonic-main/src/test/resources/PLAYLISTS/23.pls create mode 100644 libresonic-main/src/test/resources/PLAYLISTS/23.xspf diff --git a/libresonic-main/pom.xml b/libresonic-main/pom.xml index 11c461e0..93d4dfea 100644 --- a/libresonic-main/pom.xml +++ b/libresonic-main/pom.xml @@ -14,6 +14,7 @@ 3.1.0 + 1.2.0-RELEASE @@ -41,6 +42,13 @@ + + org.mockito + mockito-core + 2.4.1 + test + + org.springframework.boot @@ -389,6 +397,26 @@ 3.3.9 + + com.github.muff1nman.chameleon + core + ${chameleon.version} + + + + com.github.muff1nman.chameleon + playlist-xspf + ${chameleon.version} + compile + + + + com.github.muff1nman.chameleon + playlist-all + ${chameleon.version} + runtime + + javax.validation validation-api diff --git a/libresonic-main/src/main/java/org/libresonic/player/controller/ImportPlaylistController.java b/libresonic-main/src/main/java/org/libresonic/player/controller/ImportPlaylistController.java index 29b8903b..a077770f 100644 --- a/libresonic-main/src/main/java/org/libresonic/player/controller/ImportPlaylistController.java +++ b/libresonic-main/src/main/java/org/libresonic/player/controller/ImportPlaylistController.java @@ -77,7 +77,8 @@ public class ImportPlaylistController { String fileName = FilenameUtils.getName(item.getName()); String format = StringUtils.lowerCase(FilenameUtils.getExtension(item.getName())); String username = securityService.getCurrentUsername(request); - Playlist playlist = playlistService.importPlaylist(username, playlistName, fileName, format, item.getInputStream(), null); + Playlist playlist = playlistService.importPlaylist(username, playlistName, fileName, + item.getInputStream(), null); map.put("playlist", playlist); } } @@ -95,5 +96,4 @@ public class ImportPlaylistController { return "importPlaylist"; } - } diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/PlaylistService.java b/libresonic-main/src/main/java/org/libresonic/player/service/PlaylistService.java index 68c88561..5ab20027 100644 --- a/libresonic-main/src/main/java/org/libresonic/player/service/PlaylistService.java +++ b/libresonic-main/src/main/java/org/libresonic/player/service/PlaylistService.java @@ -19,31 +19,29 @@ */ package org.libresonic.player.service; +import chameleon.playlist.SpecificPlaylist; +import chameleon.playlist.SpecificPlaylistFactory; +import chameleon.playlist.SpecificPlaylistProvider; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang.StringEscapeUtils; -import org.jdom.Document; -import org.jdom.Element; -import org.jdom.JDOMException; -import org.jdom.Namespace; -import org.jdom.input.SAXBuilder; import org.libresonic.player.dao.MediaFileDao; import org.libresonic.player.dao.PlaylistDao; import org.libresonic.player.domain.MediaFile; -import org.libresonic.player.domain.MusicFolder; import org.libresonic.player.domain.Playlist; import org.libresonic.player.domain.User; +import org.libresonic.player.service.playlist.PlaylistExportHandler; +import org.libresonic.player.service.playlist.PlaylistImportHandler; import org.libresonic.player.util.Pair; import org.libresonic.player.util.StringUtil; -import org.libresonic.player.util.Util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; -import java.io.*; -import java.nio.charset.Charset; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.OutputStream; import java.util.*; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Provides services for loading and saving playlists to and from persistent storage. @@ -54,11 +52,34 @@ import java.util.regex.Pattern; public class PlaylistService { private static final Logger LOG = LoggerFactory.getLogger(PlaylistService.class); - private MediaFileService mediaFileService; private MediaFileDao mediaFileDao; private PlaylistDao playlistDao; private SecurityService securityService; private SettingsService settingsService; + private List exportHandlers; + private List importHandlers; + + public PlaylistService( + MediaFileDao mediaFileDao, + PlaylistDao playlistDao, + SecurityService securityService, + SettingsService settingsService, + List exportHandlers, + List importHandlers + ) { + Assert.notNull(mediaFileDao); + Assert.notNull(playlistDao); + Assert.notNull(securityService); + Assert.notNull(settingsService); + Assert.notNull(exportHandlers); + Assert.notNull(importHandlers); + this.mediaFileDao = mediaFileDao; + this.playlistDao = playlistDao; + this.securityService = securityService; + this.settingsService = settingsService; + this.exportHandlers = exportHandlers; + this.importHandlers = importHandlers; + } public List getAllPlaylists() { return sort(playlistDao.getAllPlaylists()); @@ -147,14 +168,20 @@ public class PlaylistService { playlistDao.updatePlaylist(playlist); } - public Playlist importPlaylist(String username, String playlistName, String fileName, String format, - InputStream inputStream, Playlist existingPlaylist) throws Exception { - PlaylistFormat playlistFormat = getPlaylistFormat(format); - if (playlistFormat == null) { - throw new Exception("Unsupported playlist format: " + format); + public Playlist importPlaylist( + String username, String playlistName, String fileName, InputStream inputStream, Playlist existingPlaylist + ) throws Exception { + + // TODO: handle other encodings + final SpecificPlaylist inputSpecificPlaylist = SpecificPlaylistFactory.getInstance().readFrom(inputStream, "UTF-8"); + if (inputSpecificPlaylist == null) { + throw new Exception("Unsupported playlist " + fileName); } + PlaylistImportHandler importHandler = getImportHandler(inputSpecificPlaylist); + LOG.debug("Using "+importHandler.getClass().getSimpleName()+" playlist import handler"); + + Pair, List> result = importHandler.handle(inputSpecificPlaylist); - Pair, List> result = parseFiles(IOUtils.toByteArray(inputStream), playlistFormat); if (result.getFirst().isEmpty() && !result.getSecond().isEmpty()) { throw new Exception("No songs in the playlist were found."); } @@ -183,28 +210,35 @@ public class PlaylistService { return playlist; } - private Pair, List> parseFiles(byte[] playlist, PlaylistFormat playlistFormat) throws IOException { - Pair, List> result = null; + public String getExportPlaylistExtension() { + String format = settingsService.getPlaylistExportFormat(); + SpecificPlaylistProvider provider = SpecificPlaylistFactory.getInstance().findProviderById(format); + return provider.getContentTypes()[0].getExtensions()[0]; + } - // Try with multiple encodings; use the one that finds the most files. - String[] encodings = {StringUtil.ENCODING_LATIN, StringUtil.ENCODING_UTF8, Charset.defaultCharset().name()}; - for (String encoding : encodings) { - Pair, List> files = parseFilesWithEncoding(playlist, playlistFormat, encoding); - if (result == null || result.getFirst().size() < files.getFirst().size()) { - result = files; - } - } - return result; + public void exportPlaylist(int id, OutputStream out) throws Exception { + String format = settingsService.getPlaylistExportFormat(); + SpecificPlaylistProvider provider = SpecificPlaylistFactory.getInstance().findProviderById(format); + PlaylistExportHandler handler = getExportHandler(provider); + SpecificPlaylist specificPlaylist = handler.handle(id, provider); + specificPlaylist.writeTo(out, StringUtil.ENCODING_UTF8); } - private Pair, List> parseFilesWithEncoding(byte[] playlist, PlaylistFormat playlistFormat, String encoding) throws IOException { - BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(playlist), encoding)); - return playlistFormat.parse(reader, mediaFileService); + private PlaylistImportHandler getImportHandler(SpecificPlaylist playlist) { + return importHandlers.stream() + .filter(handler -> handler.canHandle(playlist.getClass())) + .findFirst() + .orElseThrow(() -> new RuntimeException("No import handler for " + playlist.getClass() + .getName())); + } - public void exportPlaylist(int id, OutputStream out) throws Exception { - PrintWriter writer = new PrintWriter(new OutputStreamWriter(out, StringUtil.ENCODING_UTF8)); - new M3UFormat().format(getFilesInPlaylist(id, true), writer); + private PlaylistExportHandler getExportHandler(SpecificPlaylistProvider provider) { + return exportHandlers.stream() + .filter(handler -> handler.canHandle(provider.getClass())) + .findFirst() + .orElseThrow(() -> new RuntimeException("No export handler for " + provider.getClass() + .getName())); } public void importPlaylists() { @@ -238,10 +272,6 @@ public class PlaylistService { } private void importPlaylistIfUpdated(File file, List allPlaylists) throws Exception { - String format = FilenameUtils.getExtension(file.getPath()); - if (getPlaylistFormat(format) == null) { - return; - } String fileName = file.getName(); Playlist existingPlaylist = null; @@ -256,241 +286,13 @@ public class PlaylistService { } InputStream in = new FileInputStream(file); try { - importPlaylist(User.USERNAME_ADMIN, FilenameUtils.getBaseName(fileName), fileName, format, in, existingPlaylist); + importPlaylist(User.USERNAME_ADMIN, FilenameUtils.getBaseName(fileName), fileName, in, existingPlaylist); LOG.info("Auto-imported playlist " + file); } finally { IOUtils.closeQuietly(in); } } - private PlaylistFormat getPlaylistFormat(String format) { - if (format == null) { - return null; - } - if (format.equalsIgnoreCase("m3u") || format.equalsIgnoreCase("m3u8")) { - return new M3UFormat(); - } - if (format.equalsIgnoreCase("pls")) { - return new PLSFormat(); - } - if (format.equalsIgnoreCase("xspf")) { - return new XSPFFormat(); - } - return null; - } - - public void setPlaylistDao(PlaylistDao playlistDao) { - this.playlistDao = playlistDao; - } - - public void setMediaFileDao(MediaFileDao mediaFileDao) { - this.mediaFileDao = mediaFileDao; - } - - public void setMediaFileService(MediaFileService mediaFileService) { - this.mediaFileService = mediaFileService; - } - - public void setSecurityService(SecurityService securityService) { - this.securityService = securityService; - } - - public void setSettingsService(SettingsService settingsService) { - this.settingsService = settingsService; - } - - /** - * Abstract superclass for playlist formats. - */ - private abstract class PlaylistFormat { - public abstract Pair, List> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException; - - public abstract void format(List files, PrintWriter writer) throws IOException; - - - protected MediaFile getMediaFile(String path) { - try { - File file = new File(path); - if (!file.exists()) { - return null; - } - - file = normalizePath(file); - if (file == null) { - return null; - } - MediaFile mediaFile = mediaFileService.getMediaFile(file); - if (mediaFile != null && mediaFile.exists()) { - return mediaFile; - } - } catch (SecurityException x) { - // Ignored - } catch (IOException x) { - // Ignored - } - return null; - } - - /** - * Paths in an external playlist may not have the same upper/lower case as in the (case sensitive) media_file table. - * This methods attempts to normalize the external path to match the one stored in the table. - */ - private File normalizePath(File file) throws IOException { - - // Only relevant for Windows where paths are case insensitive. - if (!Util.isWindows()) { - return file; - } - - // Find the most specific music folder. - String canonicalPath = file.getCanonicalPath(); - MusicFolder containingMusicFolder = null; - for (MusicFolder musicFolder : settingsService.getAllMusicFolders()) { - String musicFolderPath = musicFolder.getPath().getPath(); - if (canonicalPath.toLowerCase().startsWith(musicFolderPath.toLowerCase())) { - if (containingMusicFolder == null || containingMusicFolder.getPath().length() < musicFolderPath.length()) { - containingMusicFolder = musicFolder; - } - } - } - - if (containingMusicFolder == null) { - return null; - } - - return new File(containingMusicFolder.getPath().getPath() + canonicalPath.substring(containingMusicFolder.getPath().getPath().length())); - // TODO: Consider slashes. - } - } - - private class M3UFormat extends PlaylistFormat { - public Pair, List> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException { - List ok = new ArrayList(); - List error = new ArrayList(); - String line = reader.readLine(); - while (line != null) { - if (!line.startsWith("#")) { - MediaFile file = getMediaFile(line); - if (file != null) { - ok.add(file); - } else { - error.add(line); - } - } - line = reader.readLine(); - } - return new Pair, List>(ok, error); - } - - public void format(List files, PrintWriter writer) throws IOException { - writer.println("#EXTM3U"); - for (MediaFile file : files) { - writer.println(file.getPath()); - } - if (writer.checkError()) { - throw new IOException("Error when writing playlist"); - } - } - } - - /** - * Implementation of PLS playlist format. - */ - private class PLSFormat extends PlaylistFormat { - public Pair, List> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException { - List ok = new ArrayList(); - List error = new ArrayList(); - - Pattern pattern = Pattern.compile("^File\\d+=(.*)$"); - String line = reader.readLine(); - while (line != null) { - - Matcher matcher = pattern.matcher(line); - if (matcher.find()) { - String path = matcher.group(1); - MediaFile file = getMediaFile(path); - if (file != null) { - ok.add(file); - } else { - error.add(path); - } - } - line = reader.readLine(); - } - return new Pair, List>(ok, error); - } - - public void format(List files, PrintWriter writer) throws IOException { - writer.println("[playlist]"); - int counter = 0; - - for (MediaFile file : files) { - counter++; - writer.println("File" + counter + '=' + file.getPath()); - } - writer.println("NumberOfEntries=" + counter); - writer.println("Version=2"); - - if (writer.checkError()) { - throw new IOException("Error when writing playlist."); - } - } - } - - /** - * Implementation of XSPF (http://www.xspf.org/) playlist format. - */ - private class XSPFFormat extends PlaylistFormat { - public Pair, List> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException { - List ok = new ArrayList(); - List error = new ArrayList(); - - SAXBuilder builder = new SAXBuilder(); - Document document; - try { - document = builder.build(reader); - } catch (JDOMException x) { - LOG.warn("Failed to parse XSPF playlist.", x); - throw new IOException("Failed to parse XSPF playlist."); - } - - Element root = document.getRootElement(); - Namespace ns = root.getNamespace(); - Element trackList = root.getChild("trackList", ns); - List tracks = trackList.getChildren("track", ns); - - for (Object obj : tracks) { - Element track = (Element) obj; - String location = track.getChildText("location", ns); - if (location != null) { - MediaFile file = getMediaFile(location); - if (file != null) { - ok.add(file); - } else { - error.add(location); - } - } - } - return new Pair, List>(ok, error); - } - - public void format(List files, PrintWriter writer) throws IOException { - writer.println(""); - writer.println(""); - writer.println(" "); - - for (MediaFile file : files) { - writer.println(" file://" + StringEscapeUtils.escapeXml(file.getPath()) + ""); - } - writer.println(" "); - writer.println(""); - - if (writer.checkError()) { - throw new IOException("Error when writing playlist."); - } - } - } - private static class PlaylistComparator implements Comparator { @Override public int compare(Playlist p1, Playlist p2) { diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java b/libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java index a7fdd9b2..94f3a0fb 100644 --- a/libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java +++ b/libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java @@ -107,6 +107,7 @@ public class SettingsService { private static final String KEY_SMTP_USER = "SmtpUser"; private static final String KEY_SMTP_PASSWORD = "SmtpPassword"; private static final String KEY_SMTP_FROM = "SmtpFrom"; + private static final String KEY_EXPORT_PLAYLIST_FORMAT = "PlaylistExportFormat"; // Database Settings private static final String KEY_DATABASE_CONFIG_TYPE = "DatabaseConfigType"; @@ -174,6 +175,7 @@ public class SettingsService { private static final boolean DEFAULT_SONOS_ENABLED = false; private static final String DEFAULT_SONOS_SERVICE_NAME = "Libresonic"; private static final int DEFAULT_SONOS_SERVICE_ID = 242; + private static final String DEFAULT_EXPORT_PLAYLIST_FORMAT = "m3u"; private static final String DEFAULT_SMTP_SERVER = null; private static final String DEFAULT_SMTP_ENCRYPTION = "None"; @@ -1363,4 +1365,8 @@ public class SettingsService { setDatabaseUsertableQuote(DEFAULT_DATABASE_USERTABLE_QUOTE); setDatabaseConfigType(DEFAULT_DATABASE_CONFIG_TYPE); } + + public String getPlaylistExportFormat() { + return getProperty(KEY_EXPORT_PLAYLIST_FORMAT, DEFAULT_EXPORT_PLAYLIST_FORMAT); + } } diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistExportHandler.java b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistExportHandler.java new file mode 100644 index 00000000..c9eb7b7d --- /dev/null +++ b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistExportHandler.java @@ -0,0 +1,49 @@ +package org.libresonic.player.service.playlist; + +import chameleon.content.Content; +import chameleon.playlist.Media; +import chameleon.playlist.Playlist; +import chameleon.playlist.SpecificPlaylist; +import chameleon.playlist.SpecificPlaylistProvider; +import org.libresonic.player.dao.MediaFileDao; +import org.libresonic.player.domain.MediaFile; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.Ordered; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class DefaultPlaylistExportHandler implements PlaylistExportHandler { + + @Autowired + MediaFileDao mediaFileDao; + + @Override + public boolean canHandle(Class providerClass) { + return true; + } + + @Override + public SpecificPlaylist handle(int id, SpecificPlaylistProvider provider) throws Exception { + chameleon.playlist.Playlist playlist = createChameleonGenericPlaylistFromDBId(id); + return provider.toSpecificPlaylist(playlist); + } + + Playlist createChameleonGenericPlaylistFromDBId(int id) { + Playlist newPlaylist = new Playlist(); + List files = mediaFileDao.getFilesInPlaylist(id); + files.forEach(file -> { + Media component = new Media(); + Content content = new Content(file.getPath()); + component.setSource(content); + newPlaylist.getRootSequence().addComponent(component); + }); + return newPlaylist; + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistImportHandler.java b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistImportHandler.java new file mode 100644 index 00000000..be35bb46 --- /dev/null +++ b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistImportHandler.java @@ -0,0 +1,97 @@ +package org.libresonic.player.service.playlist; + +import chameleon.playlist.*; +import org.libresonic.player.domain.MediaFile; +import org.libresonic.player.service.MediaFileService; +import org.libresonic.player.util.Pair; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.Ordered; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +@Component +public class DefaultPlaylistImportHandler implements PlaylistImportHandler { + + @Autowired + MediaFileService mediaFileService; + + @Override + public boolean canHandle(Class playlistClass) { + return true; + } + + @Override + public Pair, List> handle( + SpecificPlaylist inputSpecificPlaylist + ) { + List mediaFiles = new ArrayList<>(); + List errors = new ArrayList<>(); + try { + inputSpecificPlaylist.toPlaylist().acceptDown(new PlaylistVisitor() { + @Override + public void beginVisitPlaylist(Playlist playlist) throws Exception { + + } + + @Override + public void endVisitPlaylist(Playlist playlist) throws Exception { + + } + + @Override + public void beginVisitParallel(Parallel parallel) throws Exception { + + } + + @Override + public void endVisitParallel(Parallel parallel) throws Exception { + + } + + @Override + public void beginVisitSequence(Sequence sequence) throws Exception { + + } + + @Override + public void endVisitSequence(Sequence sequence) throws Exception { + + } + + @Override + public void beginVisitMedia(Media media) throws Exception { + try { + URI uri = media.getSource().getURI(); + File file = new File(uri); + MediaFile mediaFile = mediaFileService.getMediaFile(file); + if(mediaFile != null) { + mediaFiles.add(mediaFile); + } else { + errors.add("Cannot find media file " + file); + } + } catch (Exception e) { + errors.add(e.getMessage()); + } + } + + @Override + public void endVisitMedia(Media media) throws Exception { + + } + }); + } catch (Exception e) { + errors.add(e.getMessage()); + } + + return Pair.create(mediaFiles, errors); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistExportHandler.java b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistExportHandler.java new file mode 100644 index 00000000..88b8aeaf --- /dev/null +++ b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistExportHandler.java @@ -0,0 +1,11 @@ +package org.libresonic.player.service.playlist; + +import chameleon.playlist.SpecificPlaylist; +import chameleon.playlist.SpecificPlaylistProvider; +import org.springframework.core.Ordered; + +public interface PlaylistExportHandler extends Ordered { + boolean canHandle(Class providerClass); + + SpecificPlaylist handle(int id, SpecificPlaylistProvider provider) throws Exception; +} diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistImportHandler.java b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistImportHandler.java new file mode 100644 index 00000000..55408d13 --- /dev/null +++ b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistImportHandler.java @@ -0,0 +1,14 @@ +package org.libresonic.player.service.playlist; + +import chameleon.playlist.SpecificPlaylist; +import org.libresonic.player.domain.MediaFile; +import org.libresonic.player.util.Pair; +import org.springframework.core.Ordered; + +import java.util.List; + +public interface PlaylistImportHandler extends Ordered { + boolean canHandle(Class playlistClass); + + Pair,List> handle(SpecificPlaylist inputSpecificPlaylist); +} diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistExportHandler.java b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistExportHandler.java new file mode 100644 index 00000000..e349f4c5 --- /dev/null +++ b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistExportHandler.java @@ -0,0 +1,66 @@ +package org.libresonic.player.service.playlist; + +import chameleon.playlist.SpecificPlaylist; +import chameleon.playlist.SpecificPlaylistProvider; +import chameleon.playlist.xspf.Location; +import chameleon.playlist.xspf.Track; +import chameleon.playlist.xspf.XspfProvider; +import org.libresonic.player.dao.MediaFileDao; +import org.libresonic.player.dao.PlaylistDao; +import org.libresonic.player.domain.MediaFile; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.List; + +@Component +public class XspfPlaylistExportHandler implements PlaylistExportHandler { + + @Autowired + MediaFileDao mediaFileDao; + + @Autowired + PlaylistDao playlistDao; + + @Override + public boolean canHandle(Class providerClass) { + return XspfProvider.class.equals(providerClass); + } + + @Override + public SpecificPlaylist handle(int id, SpecificPlaylistProvider provider) throws Exception { + chameleon.playlist.xspf.Playlist playlist = createXsfpPlaylistFromDBId(id); + return playlist; + } + + chameleon.playlist.xspf.Playlist createXsfpPlaylistFromDBId(int id) { + chameleon.playlist.xspf.Playlist newPlaylist = new chameleon.playlist.xspf.Playlist(); + org.libresonic.player.domain.Playlist playlist = playlistDao.getPlaylist(id); + newPlaylist.setTitle(playlist.getName()); + newPlaylist.setCreator("Libresonic user " + playlist.getUsername()); + newPlaylist.setDate(new Date()); + List files = mediaFileDao.getFilesInPlaylist(id); + + files.stream().map(mediaFile -> { + Track track = new Track(); + track.setTrackNumber(mediaFile.getTrackNumber()); + track.setCreator(mediaFile.getArtist()); + track.setTitle(mediaFile.getTitle()); + track.setAlbum(mediaFile.getAlbumName()); + track.setDuration(mediaFile.getDurationSeconds()); + track.setImage(mediaFile.getCoverArtPath()); + Location location = new Location(); + location.setText(mediaFile.getPath()); + track.getStringContainers().add(location); + return track; + }).forEach(newPlaylist::addTrack); + + return newPlaylist; + } + + @Override + public int getOrder() { + return 0; + } +} diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistImportHandler.java b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistImportHandler.java new file mode 100644 index 00000000..74acd504 --- /dev/null +++ b/libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistImportHandler.java @@ -0,0 +1,73 @@ +package org.libresonic.player.service.playlist; + +import chameleon.playlist.SpecificPlaylist; +import chameleon.playlist.xspf.Location; +import chameleon.playlist.xspf.Playlist; +import chameleon.playlist.xspf.StringContainer; +import org.libresonic.player.Logger; +import org.libresonic.player.domain.MediaFile; +import org.libresonic.player.service.MediaFileService; +import org.libresonic.player.util.Pair; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class XspfPlaylistImportHandler implements PlaylistImportHandler { + + private static final Logger LOG = Logger.getLogger(XspfPlaylistImportHandler.class); + + @Autowired + MediaFileService mediaFileService; + + @Override + public boolean canHandle(Class playlistClass) { + return Playlist.class.equals(playlistClass); + } + + @Override + public Pair, List> handle(SpecificPlaylist inputSpecificPlaylist) { + List mediaFiles = new ArrayList<>(); + List errors = new ArrayList<>(); + Playlist xspfPlaylist = (Playlist) inputSpecificPlaylist; + xspfPlaylist.getTracks().forEach(track -> { + MediaFile mediaFile = null; + for(StringContainer sc : track.getStringContainers()) { + if(sc instanceof Location) { + Location location = (Location) sc; + try { + File file = new File(new URI(location.getText())); + mediaFile = mediaFileService.getMediaFile(file); + } catch (Exception ignored) {} + + if(mediaFile == null) { + try { + File file = new File(sc.getText()); + mediaFile = mediaFileService.getMediaFile(file); + } catch (Exception ignored) {} + } + } + } + if(mediaFile != null) { + mediaFiles.add(mediaFile); + } else { + String errorMsg = "Could not find media file matching "; + try { + errorMsg += track.getStringContainers().stream().map(StringContainer::getText).collect(Collectors.joining(",")); + } catch (Exception ignored) {} + errors.add(errorMsg); + } + }); + return Pair.create(mediaFiles, errors); + } + + @Override + public int getOrder() { + return 40; + } +} diff --git a/libresonic-main/src/main/resources/META-INF/services/chameleon.playlist.SpecificPlaylistProvider b/libresonic-main/src/main/resources/META-INF/services/chameleon.playlist.SpecificPlaylistProvider new file mode 100644 index 00000000..e21c0035 --- /dev/null +++ b/libresonic-main/src/main/resources/META-INF/services/chameleon.playlist.SpecificPlaylistProvider @@ -0,0 +1,21 @@ +# First one, as it is a binary format that can easily be recognized. +chameleon.playlist.pla.PLAProvider +chameleon.playlist.asx.AsxProvider +chameleon.playlist.b4s.B4sProvider +# BEFORE SMIL (same root element). +chameleon.playlist.wpl.WplProvider +chameleon.playlist.smil.SmilProvider +chameleon.playlist.rss.RSSProvider +chameleon.playlist.atom.AtomProvider +# Before XSPF, because this format is very close to XSPF, +# but its XML format is strictly checked (and XSPF's format is not) +chameleon.playlist.hypetape.HypetapeProvider +chameleon.playlist.xspf.XspfProvider +chameleon.playlist.rmp.RmpProvider +chameleon.playlist.plist.PlistProvider +chameleon.playlist.kpl.KplProvider +chameleon.playlist.pls.PLSProvider +chameleon.playlist.mpcpl.MPCPLProvider +chameleon.playlist.plp.PLPProvider +# Shall be last, as the M3U format can match almost everything. +chameleon.playlist.m3u.M3UProvider diff --git a/libresonic-main/src/main/resources/applicationContext-service.xml b/libresonic-main/src/main/resources/applicationContext-service.xml index ac370495..f7b8111d 100644 --- a/libresonic-main/src/main/resources/applicationContext-service.xml +++ b/libresonic-main/src/main/resources/applicationContext-service.xml @@ -129,13 +129,10 @@ - - - - - - - + + + + diff --git a/libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestExport.java b/libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestExport.java new file mode 100644 index 00000000..dabf5192 --- /dev/null +++ b/libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestExport.java @@ -0,0 +1,108 @@ +package org.libresonic.player.service; + +import com.google.common.collect.Lists; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.output.ByteArrayOutputStream; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.libresonic.player.dao.MediaFileDao; +import org.libresonic.player.dao.PlaylistDao; +import org.libresonic.player.domain.MediaFile; +import org.libresonic.player.domain.Playlist; +import org.libresonic.player.service.playlist.DefaultPlaylistExportHandler; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class PlaylistServiceTestExport { + + PlaylistService playlistService; + + @InjectMocks + DefaultPlaylistExportHandler defaultPlaylistExportHandler; + + @Mock + MediaFileDao mediaFileDao; + + @Mock + PlaylistDao playlistDao; + + @Mock + MediaFileService mediaFileService; + + @Mock + SettingsService settingsService; + + @Mock + SecurityService securityService; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + @Captor + ArgumentCaptor actual; + + @Captor + ArgumentCaptor> medias; + + @Before + public void setup() { + playlistService = new PlaylistService(mediaFileDao, + playlistDao, + securityService, + settingsService, + Lists.newArrayList( + defaultPlaylistExportHandler), + Collections.emptyList()); + } + + @Test + public void testExportToM3U() throws Exception { + + when(mediaFileDao.getFilesInPlaylist(eq(23))).thenReturn(getPlaylistFiles()); + when(settingsService.getPlaylistExportFormat()).thenReturn("m3u"); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + playlistService.exportPlaylist(23, outputStream); + String actual = outputStream.toString(); + Assert.assertEquals(IOUtils.toString(getClass().getResourceAsStream("/PLAYLISTS/23.m3u")), actual); + } + + private List getPlaylistFiles() { + List mediaFiles = new ArrayList<>(); + + MediaFile mf1 = new MediaFile(); + mf1.setId(142); + mf1.setPath("/some/path/to_album/to_artist/name - of - song.mp3"); + mf1.setPresent(true); + mediaFiles.add(mf1); + + MediaFile mf2 = new MediaFile(); + mf2.setId(1235); + mf2.setPath("/some/path/to_album2/to_artist/another song.mp3"); + mf2.setPresent(true); + mediaFiles.add(mf2); + + MediaFile mf3 = new MediaFile(); + mf3.setId(198403); + mf3.setPath("/some/path/to_album2/to_artist/another song2.mp3"); + mf3.setPresent(false); + mediaFiles.add(mf3); + + return mediaFiles; + } +} diff --git a/libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestImport.java b/libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestImport.java new file mode 100644 index 00000000..7543ca8d --- /dev/null +++ b/libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestImport.java @@ -0,0 +1,210 @@ +package org.libresonic.player.service; + +import com.google.common.collect.Lists; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.libresonic.player.dao.MediaFileDao; +import org.libresonic.player.dao.PlaylistDao; +import org.libresonic.player.domain.MediaFile; +import org.libresonic.player.domain.Playlist; +import org.libresonic.player.service.playlist.DefaultPlaylistExportHandler; +import org.libresonic.player.service.playlist.DefaultPlaylistImportHandler; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.stubbing.Answer; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class PlaylistServiceTestImport { + + PlaylistService playlistService; + + @InjectMocks + DefaultPlaylistImportHandler defaultPlaylistImportHandler; + + @Mock + MediaFileDao mediaFileDao; + + @Mock + PlaylistDao playlistDao; + + @Mock + MediaFileService mediaFileService; + + @Mock + SettingsService settingsService; + + @Mock + SecurityService securityService; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + @Captor + ArgumentCaptor actual; + + @Captor + ArgumentCaptor> medias; + + @Before + public void setup() { + playlistService = new PlaylistService( + mediaFileDao, + playlistDao, + securityService, + settingsService, + Collections.emptyList(), + Lists.newArrayList(defaultPlaylistImportHandler)); + + } + + @Test + public void testImportFromM3U() throws Exception { + String username = "testUser"; + String playlistName = "test-playlist"; + StringBuilder builder = new StringBuilder(); + builder.append("#EXTM3U\n"); + File mf1 = folder.newFile(); + FileUtils.touch(mf1); + File mf2 = folder.newFile(); + FileUtils.touch(mf2); + File mf3 = folder.newFile(); + FileUtils.touch(mf3); + builder.append(mf1.getAbsolutePath() + "\n"); + builder.append(mf2.getAbsolutePath() + "\n"); + builder.append(mf3.getAbsolutePath() + "\n"); + doAnswer(new PersistPlayList(23)).when(playlistDao).createPlaylist(any()); + doAnswer(new MediaFileHasEverything()).when(mediaFileService).getMediaFile(any(File.class)); + InputStream inputStream = new ByteArrayInputStream(builder.toString().getBytes("UTF-8")); + String path = "/path/to/"+playlistName+".m3u"; + playlistService.importPlaylist(username, playlistName, path, inputStream, null); + verify(playlistDao).createPlaylist(actual.capture()); + verify(playlistDao).setFilesInPlaylist(eq(23), medias.capture()); + Playlist expected = new Playlist(); + expected.setUsername(username); + expected.setName(playlistName); + expected.setComment("Auto-imported from " + path); + expected.setImportedFrom(path); + expected.setShared(true); + expected.setId(23); + assertTrue("\n" + ToStringBuilder.reflectionToString(actual.getValue()) + "\n\n did not equal \n\n" + ToStringBuilder.reflectionToString(expected), EqualsBuilder.reflectionEquals(actual.getValue(), expected, "created", "changed")); + List mediaFiles = medias.getValue(); + assertEquals(3, mediaFiles.size()); + } + + @Test + public void testImportFromPLS() throws Exception { + String username = "testUser"; + String playlistName = "test-playlist"; + StringBuilder builder = new StringBuilder(); + builder.append("[playlist]\n"); + File mf1 = folder.newFile(); + FileUtils.touch(mf1); + File mf2 = folder.newFile(); + FileUtils.touch(mf2); + File mf3 = folder.newFile(); + FileUtils.touch(mf3); + builder.append("File1=" + mf1.getAbsolutePath() + "\n"); + builder.append("File2=" + mf2.getAbsolutePath() + "\n"); + builder.append("File3=" + mf3.getAbsolutePath() + "\n"); + doAnswer(new PersistPlayList(23)).when(playlistDao).createPlaylist(any()); + doAnswer(new MediaFileHasEverything()).when(mediaFileService).getMediaFile(any(File.class)); + InputStream inputStream = new ByteArrayInputStream(builder.toString().getBytes("UTF-8")); + String path = "/path/to/"+playlistName+".pls"; + playlistService.importPlaylist(username, playlistName, path, inputStream, null); + verify(playlistDao).createPlaylist(actual.capture()); + verify(playlistDao).setFilesInPlaylist(eq(23), medias.capture()); + Playlist expected = new Playlist(); + expected.setUsername(username); + expected.setName(playlistName); + expected.setComment("Auto-imported from " + path); + expected.setImportedFrom(path); + expected.setShared(true); + expected.setId(23); + assertTrue("\n" + ToStringBuilder.reflectionToString(actual.getValue()) + "\n\n did not equal \n\n" + ToStringBuilder.reflectionToString(expected), EqualsBuilder.reflectionEquals(actual.getValue(), expected, "created", "changed")); + List mediaFiles = medias.getValue(); + assertEquals(3, mediaFiles.size()); + } + + @Test + public void testImportFromXSPF() throws Exception { + String username = "testUser"; + String playlistName = "test-playlist"; + StringBuilder builder = new StringBuilder(); + builder.append("\n" + + "\n" + + " \n"); + File mf1 = folder.newFile(); + FileUtils.touch(mf1); + File mf2 = folder.newFile(); + FileUtils.touch(mf2); + File mf3 = folder.newFile(); + FileUtils.touch(mf3); + builder.append("file://" + mf1.getAbsolutePath() + "\n"); + builder.append("file://" + mf2.getAbsolutePath() + "\n"); + builder.append("file://" + mf3.getAbsolutePath() + "\n"); + builder.append(" \n" + "\n"); + doAnswer(new PersistPlayList(23)).when(playlistDao).createPlaylist(any()); + doAnswer(new MediaFileHasEverything()).when(mediaFileService).getMediaFile(any(File.class)); + InputStream inputStream = new ByteArrayInputStream(builder.toString().getBytes("UTF-8")); + String path = "/path/to/"+playlistName+".xspf"; + playlistService.importPlaylist(username, playlistName, path, inputStream, null); + verify(playlistDao).createPlaylist(actual.capture()); + verify(playlistDao).setFilesInPlaylist(eq(23), medias.capture()); + Playlist expected = new Playlist(); + expected.setUsername(username); + expected.setName(playlistName); + expected.setComment("Auto-imported from " + path); + expected.setImportedFrom(path); + expected.setShared(true); + expected.setId(23); + assertTrue("\n" + ToStringBuilder.reflectionToString(actual.getValue()) + "\n\n did not equal \n\n" + ToStringBuilder.reflectionToString(expected), EqualsBuilder.reflectionEquals(actual.getValue(), expected, "created", "changed")); + List mediaFiles = medias.getValue(); + assertEquals(3, mediaFiles.size()); + } + + private class PersistPlayList implements Answer { + private final int id; + public PersistPlayList(int id) { + this.id = id; + } + + @Override + public Object answer(InvocationOnMock invocationOnMock) throws Throwable { + Playlist playlist = invocationOnMock.getArgument(0); + playlist.setId(id); + return null; + } + } + + private class MediaFileHasEverything implements Answer { + + @Override + public Object answer(InvocationOnMock invocationOnMock) throws Throwable { + File file = invocationOnMock.getArgument(0); + MediaFile mediaFile = new MediaFile(); + mediaFile.setPath(file.getPath()); + return mediaFile; + } + } +} diff --git a/libresonic-main/src/test/resources/PLAYLISTS/23.m3u b/libresonic-main/src/test/resources/PLAYLISTS/23.m3u new file mode 100644 index 00000000..e5b2409a --- /dev/null +++ b/libresonic-main/src/test/resources/PLAYLISTS/23.m3u @@ -0,0 +1,3 @@ +/some/path/to_album/to_artist/name - of - song.mp3 +/some/path/to_album2/to_artist/another song.mp3 +/some/path/to_album2/to_artist/another song2.mp3 diff --git a/libresonic-main/src/test/resources/PLAYLISTS/23.pls b/libresonic-main/src/test/resources/PLAYLISTS/23.pls new file mode 100644 index 00000000..f043cdb2 --- /dev/null +++ b/libresonic-main/src/test/resources/PLAYLISTS/23.pls @@ -0,0 +1,6 @@ +[playlist] +File1=/some/path/to_album/to_artist/name - of - song.mp3 +File2=/some/path/to_album2/to_artist/another song.mp3 +File3=/some/path/to_album2/to_artist/another song2.mp3 +NumberOfEntries=3 +Version=2 diff --git a/libresonic-main/src/test/resources/PLAYLISTS/23.xspf b/libresonic-main/src/test/resources/PLAYLISTS/23.xspf new file mode 100644 index 00000000..4c6e494b --- /dev/null +++ b/libresonic-main/src/test/resources/PLAYLISTS/23.xspf @@ -0,0 +1,8 @@ + + + + file:///some/path/to_album/to_artist/name - of - song.mp3 + file:///some/path/to_album2/to_artist/another song.mp3 + file:///some/path/to_album2/to_artist/another song2.mp3 + +