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(" ");
- }
- 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 extends SpecificPlaylistProvider> 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 extends SpecificPlaylist> 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 extends SpecificPlaylistProvider> 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 extends SpecificPlaylist> 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 extends SpecificPlaylistProvider> 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 extends SpecificPlaylist> 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("\n");
+ builder.append("\n");
+ builder.append("\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 @@
+
+
+
+
+
+
+
+