Created
June 19, 2015 01:06
-
-
Save autermann/f3991d207d5caa80e8c8 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package org.autermann.itunessync; | |
import java.io.IOException; | |
import java.io.Reader; | |
import java.net.URI; | |
import java.nio.charset.Charset; | |
import java.nio.file.FileVisitResult; | |
import java.nio.file.Files; | |
import java.nio.file.Path; | |
import java.nio.file.Paths; | |
import java.nio.file.SimpleFileVisitor; | |
import java.nio.file.attribute.BasicFileAttributes; | |
import java.nio.file.attribute.FileTime; | |
import java.time.Instant; | |
import java.time.format.DateTimeParseException; | |
import java.util.ArrayList; | |
import java.util.Arrays; | |
import java.util.Base64; | |
import java.util.HashMap; | |
import java.util.List; | |
import java.util.Map; | |
import java.util.Set; | |
import java.util.regex.Pattern; | |
import java.util.stream.Collectors; | |
import java.util.stream.IntStream; | |
import java.util.stream.Stream; | |
import javax.xml.stream.XMLInputFactory; | |
import javax.xml.stream.XMLStreamException; | |
import javax.xml.stream.XMLStreamReader; | |
/** | |
* @author Christian Autermann | |
*/ | |
public class PlaylistSyncer { | |
public static void main(String[] args) throws Exception { | |
new PlaylistSyncer().run(args); | |
} | |
public void run(String[] args) throws IOException { | |
run(args[0], Paths.get(args[1])); | |
} | |
private void run(String playlistName, Path destination) throws IOException { | |
LibaryAnalyzer analyzer = new LibaryAnalyzer(getLibraryPath()); | |
Playlist playlist = analyzer.getTracks(playlistName); | |
Syncer syncer = new Syncer(playlist.getBasePath(), destination); | |
syncer.sync(playlist.getTracks()); | |
} | |
private static Path getLibraryPath() { | |
String home = System.getProperty("user.home"); | |
return Paths.get(home, "iTunes", "iTunes Music Library.xml"); | |
} | |
private static class Syncer { | |
private final Path source; | |
private final Path destination; | |
public Syncer(Path source, Path destination) { | |
this.source = source; | |
this.destination = destination; | |
} | |
public void sync(List<Path> paths) throws IOException { | |
Set<Path> sourcePaths = paths.stream() | |
.flatMap(this::getParentPaths) | |
.collect(Collectors.toSet()); | |
if (Files.exists(destination)) { | |
Files.walkFileTree(destination, new DestinationCleaner(sourcePaths)); | |
} | |
for (Path path : paths) { | |
Path dst = destination.resolve(path); | |
if (!Files.exists(dst)) { | |
Path src = source.resolve(path); | |
System.out.printf("Copying %s\n", path); | |
Files.createDirectories(dst.getParent()); | |
Files.copy(src, dst); | |
FileTime lmt = Files.getLastModifiedTime(src); | |
Files.setLastModifiedTime(dst, lmt); | |
} else { | |
System.out.printf("Skipping %s\n", path); | |
} | |
} | |
} | |
private Stream<Path> getParentPaths(Path path) { | |
return IntStream.rangeClosed(1, path.getNameCount()) | |
.mapToObj(i -> path.subpath(0, i)); | |
} | |
private static class DeletingFileVisitor extends SimpleFileVisitor<Path> { | |
@Override | |
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { | |
Files.delete(file); | |
return FileVisitResult.CONTINUE; | |
} | |
@Override | |
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { | |
Files.delete(dir); | |
return FileVisitResult.CONTINUE; | |
} | |
} | |
private class DestinationCleaner extends SimpleFileVisitor<Path> { | |
private final Set<Path> sourcePaths; | |
public DestinationCleaner(Set<Path> sourcePaths) { | |
this.sourcePaths = sourcePaths; | |
} | |
@Override | |
public FileVisitResult preVisitDirectory(Path destinationDir, BasicFileAttributes attrs) throws IOException { | |
if (destinationDir.equals(destination)) { | |
return FileVisitResult.CONTINUE; | |
} | |
Path relativePath = destination.relativize(destinationDir); | |
Path sourceDir = source.resolve(relativePath); | |
if (!sourcePaths.contains(relativePath) || !Files.isDirectory(sourceDir)) { | |
System.out.printf("Deleting %s\n", destinationDir); | |
Files.walkFileTree(destinationDir, new DeletingFileVisitor()); | |
return FileVisitResult.SKIP_SUBTREE; | |
} else { | |
return FileVisitResult.CONTINUE; | |
} | |
} | |
@Override | |
public FileVisitResult visitFile(Path destinationFile, BasicFileAttributes attrs) throws IOException { | |
Path relativePath = destination.relativize(destinationFile); | |
Path sourceFile = source.resolve(relativePath); | |
if (!sourcePaths.contains(relativePath) || | |
Files.isDirectory(sourceFile) || | |
Files.size(sourceFile) != Files.size(destinationFile) || | |
attrs.lastModifiedTime().compareTo(Files.getLastModifiedTime(sourceFile)) != 0) { | |
System.out.printf("Deleting %s\n", destinationFile); | |
Files.delete(destinationFile); | |
} | |
return FileVisitResult.CONTINUE; | |
} | |
} | |
} | |
private static class LibaryAnalyzer { | |
private static final String NAME = "Name"; | |
private static final String PLAYLISTS = "Playlists"; | |
private static final String TRACK_ID = "Track ID"; | |
private static final String PLAYLIST_ITEMS = "Playlist Items"; | |
private static final String LOCATION = "Location"; | |
private static final String TRACKS = "Tracks"; | |
private static final String MUSIC_FOLDER = "Music Folder"; | |
private final Map<String, Object> library; | |
public LibaryAnalyzer(Path path) throws IOException { | |
LibraryReader reader = new LibraryReader(); | |
this.library = asDict(reader.read(path).get(0)); | |
} | |
public Playlist getTracks(String playListName) throws IOException { | |
Path musicFolder = getMusicFolder(); | |
List<Path> files = getPaths(playListName) | |
.map(musicFolder::relativize) | |
.collect(Collectors.toList()); | |
return new Playlist(musicFolder, files); | |
} | |
private Path getMusicFolder() { | |
return asPath((String) library.get(MUSIC_FOLDER)); | |
} | |
private Stream<Path> getPaths(String playlistName) { | |
Map<String, Object> tracks = getTracks(); | |
return getPlaylist(playlistName) | |
.map(x -> Long.toString((long) x.get(TRACK_ID))) | |
.map(tracks::get).map(this::asDict) | |
.map(x -> (String) x.get(LOCATION)) | |
.map(this::asPath); | |
} | |
private Map<String, Object> getTracks() { | |
return asDict(library.get(TRACKS)); | |
} | |
private Stream<Map<String, Object>> getPlaylist(String playlistName) { | |
return getPlaylists().parallelStream().map(this::asDict) | |
.filter(x -> x.get(NAME) != null && x.get(NAME).equals(playlistName)) | |
.findAny().map(x -> x.get(PLAYLIST_ITEMS)).map(this::asArray) | |
.map(List::parallelStream).orElseGet(Stream::empty).map(this::asDict); | |
} | |
private List<Object> getPlaylists() { | |
return asArray(library.get(PLAYLISTS)); | |
} | |
private Path asPath(String path) { | |
return Paths.get(URI.create(path.replace("file://localhost/", "file:///"))); | |
} | |
private Map<String, Object> asDict(Object o) { | |
return (Map<String, Object>) o; | |
} | |
private List<Object> asArray(Object o) { | |
return (List<Object>) o; | |
} | |
} | |
private static class Playlist { | |
private final Path basePath; | |
private final List<Path> tracks; | |
public Playlist(Path basePath, List<Path> tracks) { | |
this.basePath = basePath; | |
this.tracks = tracks; | |
} | |
public Path getBasePath() { | |
return basePath; | |
} | |
public List<Path> getTracks() { | |
return tracks; | |
} | |
} | |
private static class LibraryReader { | |
private static final Pattern LINE_BREAK = Pattern.compile("\\r?\\n"); | |
private static final String PREMATURE_EOF = "Premature end of file"; | |
private static final String PLIST = "plist"; | |
private static final String DATE_TYPE = "date"; | |
private static final String DATA_TYPE = "data"; | |
private static final String REAL_TYPE = "real"; | |
private static final String FALSE_TYPE = "false"; | |
private static final String TRUE_TYPE = "true"; | |
private static final String INTEGER_TYPE = "integer"; | |
private static final String STRING_TYPE = "string"; | |
private static final String KEY_TYPE = "key"; | |
private static final String DICT_TYPE = "dict"; | |
private static final String ARRAY_TYPE = "array"; | |
private static final Charset UTF_8 = Charset.forName("UTF-8"); | |
private final XMLInputFactory inputFactory = XMLInputFactory.newFactory(); | |
public List<Object> read(Path file) throws IOException { | |
return read(Files.newBufferedReader(file, UTF_8)); | |
} | |
public List<Object> read(Reader in) throws IOException { | |
XMLStreamReader reader = null; | |
try (Reader inr = in) { | |
reader = this.inputFactory.createXMLStreamReader(inr); | |
return readDocument(reader); | |
} catch (XMLStreamException ex) { | |
throw new IOException(ex); | |
} finally { | |
if (reader != null) { | |
try { | |
reader.close(); | |
} catch (XMLStreamException ex) { | |
//ignore | |
} | |
} | |
} | |
} | |
private List<Object> readDocument(XMLStreamReader reader) throws XMLStreamException { | |
while (reader.hasNext()) { | |
int eventType = reader.next(); | |
switch (eventType) { | |
case XMLStreamReader.START_ELEMENT: | |
String elementName = reader.getLocalName(); | |
if (elementName.equals(PLIST)) { | |
return readPList(reader); | |
} | |
break; | |
case XMLStreamReader.END_ELEMENT: | |
break; | |
} | |
} | |
throw new XMLStreamException(PREMATURE_EOF); | |
} | |
private List<Object> readPList(XMLStreamReader reader) throws XMLStreamException { | |
return readList(reader); | |
} | |
private List<Object> readList(XMLStreamReader reader) throws XMLStreamException { | |
List<Object> objects = new ArrayList<>(); | |
while (reader.hasNext()) { | |
int eventType = reader.next(); | |
switch (eventType) { | |
case XMLStreamReader.START_ELEMENT: | |
objects.add(readValue(reader)); | |
break; | |
case XMLStreamReader.END_ELEMENT: | |
return objects; | |
} | |
} | |
throw new XMLStreamException(PREMATURE_EOF); | |
} | |
private Object readValue(XMLStreamReader reader) throws XMLStreamException { | |
final String name = reader.getLocalName(); | |
switch (name) { | |
case ARRAY_TYPE: | |
return readArray(reader); | |
case DICT_TYPE: | |
return readDict(reader); | |
case STRING_TYPE: | |
return readString(reader); | |
case INTEGER_TYPE: | |
return readInteger(reader); | |
case TRUE_TYPE: | |
return consumeElement(reader, true); | |
case FALSE_TYPE: | |
return consumeElement(reader, false); | |
case REAL_TYPE: | |
return readReal(reader); | |
case DATA_TYPE: | |
return readData(reader); | |
case DATE_TYPE: | |
return readDate(reader); | |
} | |
throw new XMLStreamException("Unknown type: " + name); | |
} | |
private List<Object> readArray(XMLStreamReader reader) throws XMLStreamException { | |
return readList(reader); | |
} | |
private Map<String, Object> readDict(XMLStreamReader reader) throws XMLStreamException { | |
Map<String, Object> objects = new HashMap<>(); | |
String key = null; | |
while (reader.hasNext()) { | |
int eventType = reader.next(); | |
switch (eventType) { | |
case XMLStreamReader.START_ELEMENT: | |
String name = reader.getLocalName(); | |
if (name.equals(KEY_TYPE)) { | |
if (key != null) { | |
throw new XMLStreamException("Missing value for key " + key); | |
} else { | |
key = readKey(reader); | |
} | |
} else { | |
if (key == null) { | |
throw new XMLStreamException("Missing key for value"); | |
} else { | |
objects.put(key, readValue(reader)); | |
key = null; | |
} | |
} | |
break; | |
case XMLStreamReader.END_ELEMENT: | |
return objects; | |
} | |
} | |
throw new XMLStreamException(PREMATURE_EOF); | |
} | |
private String readKey(XMLStreamReader reader) throws XMLStreamException { | |
return readCharacters(reader); | |
} | |
private long readInteger(XMLStreamReader reader) throws XMLStreamException { | |
String chars = readCharacters(reader); | |
try { | |
return Long.valueOf(chars); | |
} catch (NumberFormatException e) { | |
throw new XMLStreamException("Invalid integer " + chars); | |
} | |
} | |
private String readString(XMLStreamReader reader) throws XMLStreamException { | |
return readCharacters(reader); | |
} | |
private float readReal(XMLStreamReader reader) throws XMLStreamException { | |
String chars = readCharacters(reader); | |
try { | |
return Float.valueOf(chars); | |
} catch (NumberFormatException e) { | |
throw new XMLStreamException("Invalid float " + chars); | |
} | |
} | |
private <T> T consumeElement(XMLStreamReader reader, T value) | |
throws XMLStreamException { | |
while (reader.hasNext()) { | |
int eventType = reader.next(); | |
switch (eventType) { | |
case XMLStreamReader.END_ELEMENT: | |
return value; | |
} | |
} | |
throw new XMLStreamException(PREMATURE_EOF); | |
} | |
private Instant readDate(XMLStreamReader reader) throws XMLStreamException { | |
String chars = readCharacters(reader); | |
try { | |
return Instant.parse(chars); | |
} catch (DateTimeParseException e) { | |
throw new XMLStreamException("Invalid date time " + chars, e); | |
} | |
} | |
private byte[] readData(XMLStreamReader reader) throws XMLStreamException { | |
String chars = readCharacters(reader); | |
return Base64.getDecoder().decode(Arrays | |
.stream(LINE_BREAK.split(chars)) | |
.map(String::trim) | |
.collect(Collectors.joining())); | |
} | |
private String readCharacters(XMLStreamReader reader) throws XMLStreamException { | |
StringBuilder result = new StringBuilder(); | |
while (reader.hasNext()) { | |
int eventType = reader.next(); | |
switch (eventType) { | |
case XMLStreamReader.CHARACTERS: | |
case XMLStreamReader.CDATA: | |
int length = reader.getTextLength(); | |
int offset = reader.getTextStart(); | |
char[] chars = reader.getTextCharacters(); | |
result.append(chars, offset, length); | |
break; | |
case XMLStreamReader.END_ELEMENT: | |
return result.toString(); | |
} | |
} | |
throw new XMLStreamException(PREMATURE_EOF); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment