2 # Copyright 2009 James Bunton <jamesbunton@fastmail.fm>
3 # Licensed for distribution under the GPL version 2, check COPYING for details
12 from Foundation
import *
15 def read_plist(filename
):
17 data
= buffer(open(filename
).read())
20 plist
, fmt
, err
= NSPropertyListSerialization
.propertyListFromData_mutabilityOption_format_errorDescription_(data
, NSPropertyListMutableContainers
, None, None)
22 errStr
= err
.encode("utf-8")
23 err
.release() # Doesn't follow Cocoa conventions for some reason
24 raise TypeError(errStr
)
27 class Playlist(NSObject
):
31 def set(self
, name
, pid
, ptype
, tracks
, parent
):
38 if parent
is not None:
39 parent
.children
.append(self
)
41 class ITunesLibrary(NSObject
):
42 def load_(self
, filename
=None):
44 filename
= getattr(self
, "filename", None)
46 filename
= os
.path
.expanduser("~/Music/iTunes/iTunes Music Library.xml")
47 self
.filename
= filename
48 self
.mtime
= os
.stat(filename
).st_mtime
49 yield "Reading library..."
50 plist
= read_plist(os
.path
.expanduser(filename
))
52 raise Exception("Could not find music library: " + filename
)
53 self
.folder
= self
.loc2name(plist
["Music Folder"])
54 pl_tracks
= plist
["Tracks"]
57 self
.track2playlist
= collections
.defaultdict(set)
58 self
.track2filename
= {}
59 for pl_playlist
in plist
["Playlists"]:
60 playlist
= self
.make_playlist(pl_playlist
, pl_tracks
, pl_lookup
)
63 yield "Read playlist: " + playlist
.name
64 self
.playlists
.append(playlist
)
65 pl_lookup
[playlist
.pid
] = playlist
67 def needs_reload(self
):
68 return os
.stat(self
.filename
).st_mtime
> self
.mtime
70 def loc2name(self
, location
):
71 return urllib
.splithost(urllib
.splittype(urllib
.unquote(location
))[1])[1]
73 def make_playlist(self
, pl_playlist
, pl_tracks
, pl_lookup
):
74 if int(pl_playlist
.get("Master", 0)):
76 kind
= int(pl_playlist
.get("Distinguished Kind", -1))
81 name
= pl_playlist
["Name"]
82 pid
= pl_playlist
["Playlist Persistent ID"]
93 }.get(kind
, "playlist")
94 elif pl_playlist
.has_key("Smart Info"):
95 ptype
= "smart-playlist"
96 elif int(pl_playlist
.get("Folder", 0)):
103 parent_pid
= pl_playlist
["Parent Persistent ID"]
104 parent
= pl_lookup
[parent_pid
]
109 for item
in pl_playlist
.get("Playlist Items", []):
110 trackID
= item
["Track ID"]
111 item
= pl_tracks
[str(trackID
)]
112 self
.track2playlist
[trackID
].add(pid
)
113 tracks
.append(trackID
)
114 if trackID
not in self
.track2filename
:
115 if item
["Track Type"] != "File":
117 filename
= str(item
["Location"])
118 filename
= self
.loc2name(filename
)
119 filename
= filename
.decode("utf-8")
120 if not filename
.startswith(self
.folder
):
121 logging
.warn("Skipping: " + filename
)
123 filename
= strip_prefix(filename
, self
.folder
)
124 self
.track2filename
[trackID
] = filename
126 playlist
= Playlist
.alloc().init()
127 playlist
.set(name
, pid
, ptype
, tracks
, parent
)
130 def has_playlist_name(self
, name
):
131 for p
in self
.get_playlists():
136 def get_playlist_name(self
, name
):
137 for playlist
in self
.get_playlists():
138 if playlist
.name
== name
:
141 def get_playlist_pid(self
, pid
):
142 for playlist
in self
.get_playlists():
143 if playlist
.pid
== pid
:
146 def get_track_filename(self
, trackID
):
147 return self
.track2filename
.get(trackID
, None)
149 def get_track_playlists(self
, trackID
):
150 return self
.track2playlist
.get(trackID
, [])
152 def get_playlists(self
):
153 return self
.playlists
157 valid_chars
= frozenset("\\/-_.() abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
158 def encode_filename(filename
):
160 return encoded_names
[filename
]
163 orig_filename
= filename
164 filename
= filename
.encode("ascii", "ignore")
165 filename
= "".join(c
for c
in filename
if c
in valid_chars
)
166 if filename
in encoded_names
:
167 a
, b
= os
.path
.splitext(filename
)
170 encoded_names
[orig_filename
] = filename
173 def strip_prefix(s
, prefix
):
174 assert s
.startswith(prefix
)
176 if s
.startswith("/"):
181 if os
.path
.isdir(path
):
185 path
= os
.path
.split(path
)[0]
187 for path
in reversed(paths
):
193 def delete_playlists(dry_run
, dest
):
194 dest
= os
.path
.join(dest
, "-Playlists-")
196 filenames
= os
.listdir(dest
)
200 for filename
in filenames
:
201 if not filename
.lower().endswith(".m3u"):
203 filename
= os
.path
.join(dest
, filename
)
204 logging
.info("Deleting: " + filename
)
211 def export_m3u(dry_run
, dest
, path_prefix
, playlist_name
, files
):
212 dest
= os
.path
.join(dest
, "-Playlists-")
214 playlist_name
= playlist_name
.replace("/", "-")
215 playlist_file
= os
.path
.join(dest
, playlist_name
) + ".m3u"
216 playlist_file
= encode_filename(playlist_file
)
217 logging
.info("Writing: " + playlist_file
)
223 if path_prefix
.find("\\") > 0:
225 if path_prefix
[-1] != sep
:
228 f
= open(playlist_file
, "w")
229 for filename
in files
:
231 filename
= filename
.replace("/", "\\")
232 filename
= encode_filename(filename
)
233 f
.write("%s%s\n" % (path_prefix
, filename
))
236 def sync(dry_run
, source
, dest
, files_to_copy
):
239 logging
.info("Calculating files to sync and deleting old files")
240 source
= source
.encode("utf-8")
241 dest
= dest
.encode("utf-8")
243 class SyncFile(object): pass
244 for f
in files_to_copy
:
246 sf
.orig_filename
= f
.encode("utf-8")
247 sf
.encoded_filename
= encode_filename(f
)
248 filemap
[sf
.encoded_filename
.lower()] = sf
249 files_to_copy
= set(filemap
)
251 for dirpath
, dirnames
, filenames
in os
.walk(dest
):
252 full_dirpath
= dirpath
253 dirpath
= strip_prefix(dirpath
, dest
)
255 for filename
in filenames
:
256 filename
= join(dirpath
, filename
)
258 # Whenever 'file' is deleted OSX will helpfully remove '._file'
259 if not os
.path
.exists(join(dest
, filename
)):
262 if filename
.lower() in files_to_copy
:
263 source_filename
= filemap
[filename
.lower()].orig_filename
264 sourcestat
= os
.stat(join(source
, source_filename
))
265 deststat
= os
.stat(join(dest
, filename
))
266 same_time
= abs(sourcestat
.st_mtime
- deststat
.st_mtime
) < 5
267 same_size
= sourcestat
.st_size
== deststat
.st_size
268 if same_time
and same_size
:
269 files_to_copy
.remove(filename
.lower())
270 yield "Keep: " + filename
272 yield "Update: " + filename
274 elif not filename
.startswith("-Playlists-"):
275 yield "Delete: " + filename
277 os
.unlink(join(dest
, filename
))
279 if len(os
.listdir(full_dirpath
)) == 0:
280 yield "Delete: " + dirpath
282 os
.rmdir(full_dirpath
)
285 logging
.info("Copying new files")
286 files_to_copy
= list(files_to_copy
)
288 for filename
in files_to_copy
:
289 yield "Copy: " + filemap
[filename
].orig_filename
291 source_file
= join(source
, filemap
[filename
].orig_filename
)
292 dest_file
= join(dest
, filemap
[filename
].encoded_filename
)
293 mkdirhier(os
.path
.dirname(dest_file
))
294 shutil
.copy2(source_file
, dest_file
)