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 <lostonamountain@gmail.com>
master
Andrew DeMaria 8 years ago
parent e36d64dc04
commit 9584bfaea5
No known key found for this signature in database
GPG Key ID: 0A3F5E91F8364EDF
  1. 28
      libresonic-main/pom.xml
  2. 4
      libresonic-main/src/main/java/org/libresonic/player/controller/ImportPlaylistController.java
  3. 340
      libresonic-main/src/main/java/org/libresonic/player/service/PlaylistService.java
  4. 6
      libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java
  5. 49
      libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistExportHandler.java
  6. 97
      libresonic-main/src/main/java/org/libresonic/player/service/playlist/DefaultPlaylistImportHandler.java
  7. 11
      libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistExportHandler.java
  8. 14
      libresonic-main/src/main/java/org/libresonic/player/service/playlist/PlaylistImportHandler.java
  9. 66
      libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistExportHandler.java
  10. 73
      libresonic-main/src/main/java/org/libresonic/player/service/playlist/XspfPlaylistImportHandler.java
  11. 21
      libresonic-main/src/main/resources/META-INF/services/chameleon.playlist.SpecificPlaylistProvider
  12. 11
      libresonic-main/src/main/resources/applicationContext-service.xml
  13. 108
      libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestExport.java
  14. 210
      libresonic-main/src/test/java/org/libresonic/player/service/PlaylistServiceTestImport.java
  15. 3
      libresonic-main/src/test/resources/PLAYLISTS/23.m3u
  16. 6
      libresonic-main/src/test/resources/PLAYLISTS/23.pls
  17. 8
      libresonic-main/src/test/resources/PLAYLISTS/23.xspf

@ -14,6 +14,7 @@
<properties>
<metrics.version>3.1.0</metrics.version>
<chameleon.version>1.2.0-RELEASE</chameleon.version>
</properties>
<dependencies>
@ -41,6 +42,13 @@
</dependency>
<!-- END Metrics -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>2.4.1</version>
<scope>test</scope>
</dependency>
<!-- Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
@ -389,6 +397,26 @@
<version>3.3.9</version>
</dependency>
<dependency>
<groupId>com.github.muff1nman.chameleon</groupId>
<artifactId>core</artifactId>
<version>${chameleon.version}</version>
</dependency>
<dependency>
<groupId>com.github.muff1nman.chameleon</groupId>
<artifactId>playlist-xspf</artifactId>
<version>${chameleon.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.muff1nman.chameleon</groupId>
<artifactId>playlist-all</artifactId>
<version>${chameleon.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>

@ -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";
}
}

@ -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<PlaylistExportHandler> exportHandlers;
private List<PlaylistImportHandler> importHandlers;
public PlaylistService(
MediaFileDao mediaFileDao,
PlaylistDao playlistDao,
SecurityService securityService,
SettingsService settingsService,
List<PlaylistExportHandler> exportHandlers,
List<PlaylistImportHandler> 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<Playlist> 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<MediaFile>, List<String>> result = importHandler.handle(inputSpecificPlaylist);
Pair<List<MediaFile>, List<String>> 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<MediaFile>, List<String>> parseFiles(byte[] playlist, PlaylistFormat playlistFormat) throws IOException {
Pair<List<MediaFile>, List<String>> 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<MediaFile>, List<String>> 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<MediaFile>, List<String>> 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<Playlist> 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<MediaFile>, List<String>> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException;
public abstract void format(List<MediaFile> 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<MediaFile>, List<String>> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException {
List<MediaFile> ok = new ArrayList<MediaFile>();
List<String> error = new ArrayList<String>();
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<MediaFile>, List<String>>(ok, error);
}
public void format(List<MediaFile> 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<MediaFile>, List<String>> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException {
List<MediaFile> ok = new ArrayList<MediaFile>();
List<String> error = new ArrayList<String>();
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<MediaFile>, List<String>>(ok, error);
}
public void format(List<MediaFile> 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<MediaFile>, List<String>> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException {
List<MediaFile> ok = new ArrayList<MediaFile>();
List<String> error = new ArrayList<String>();
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<MediaFile>, List<String>>(ok, error);
}
public void format(List<MediaFile> files, PrintWriter writer) throws IOException {
writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
writer.println("<playlist version=\"1\" xmlns=\"http://xspf.org/ns/0/\">");
writer.println(" <trackList>");
for (MediaFile file : files) {
writer.println(" <track><location>file://" + StringEscapeUtils.escapeXml(file.getPath()) + "</location></track>");
}
writer.println(" </trackList>");
writer.println("</playlist>");
if (writer.checkError()) {
throw new IOException("Error when writing playlist.");
}
}
}
private static class PlaylistComparator implements Comparator<Playlist> {
@Override
public int compare(Playlist p1, Playlist p2) {

@ -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);
}
}

@ -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<MediaFile> 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;
}
}

@ -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<MediaFile>, List<String>> handle(
SpecificPlaylist inputSpecificPlaylist
) {
List<MediaFile> mediaFiles = new ArrayList<>();
List<String> 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;
}
}

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

@ -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<MediaFile>,List<String>> handle(SpecificPlaylist inputSpecificPlaylist);
}

@ -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<MediaFile> 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;
}
}

@ -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<MediaFile>, List<String>> handle(SpecificPlaylist inputSpecificPlaylist) {
List<MediaFile> mediaFiles = new ArrayList<>();
List<String> 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;
}
}

@ -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

@ -129,13 +129,10 @@
<property name="transcodingService" ref="transcodingService"/>
</bean>
<bean id="playlistService" class="org.libresonic.player.service.PlaylistService">
<property name="mediaFileService" ref="mediaFileService"/>
<property name="securityService" ref="securityService"/>
<property name="settingsService" ref="settingsService"/>
<property name="mediaFileDao" ref="mediaFileDao"/>
<property name="playlistDao" ref="playlistDao"/>
</bean>
<context:component-scan base-package="org.libresonic.player.service.playlist" />
<!--suppress SpringBeanConstructorArgInspection, AutowiredDependenciesInspection -->
<bean id="playlistService" class="org.libresonic.player.service.PlaylistService" autowire="constructor"/>
<bean id="versionService" class="org.libresonic.player.service.VersionService" />

@ -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<Playlist> actual;
@Captor
ArgumentCaptor<List<MediaFile>> 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<MediaFile> getPlaylistFiles() {
List<MediaFile> 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;
}
}

@ -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<Playlist> actual;
@Captor
ArgumentCaptor<List<MediaFile>> 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<MediaFile> 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<MediaFile> 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<playlist version=\"1\" xmlns=\"http://xspf.org/ns/0/\">\n"
+ " <trackList>\n");
File mf1 = folder.newFile();
FileUtils.touch(mf1);
File mf2 = folder.newFile();
FileUtils.touch(mf2);
File mf3 = folder.newFile();
FileUtils.touch(mf3);
builder.append("<track><location>file://" + mf1.getAbsolutePath() + "</location></track>\n");
builder.append("<track><location>file://" + mf2.getAbsolutePath() + "</location></track>\n");
builder.append("<track><location>file://" + mf3.getAbsolutePath() + "</location></track>\n");
builder.append(" </trackList>\n" + "</playlist>\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<MediaFile> 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;
}
}
}

@ -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

@ -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

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<playlist version="1" xmlns="http://xspf.org/ns/0/">
<trackList>
<track><location>file:///some/path/to_album/to_artist/name - of - song.mp3</location></track>
<track><location>file:///some/path/to_album2/to_artist/another song.mp3</location></track>
<track><location>file:///some/path/to_album2/to_artist/another song2.mp3</location></track>
</trackList>
</playlist>
Loading…
Cancel
Save