2 # Copyright 2009 James Bunton <jamesbunton@fastmail.fm>
3 # Licensed for distribution under the GPL version 2, check COPYING for details
11 from Foundation
import *
14 def read_plist(filename
):
16 data
= buffer(open(filename
).read())
19 plist
, fmt
, err
= NSPropertyListSerialization
.propertyListFromData_mutabilityOption_format_errorDescription_(data
, NSPropertyListMutableContainers
, None, None)
21 errStr
= err
.encode("utf-8")
22 err
.release() # Doesn't follow Cocoa conventions for some reason
23 raise TypeError(errStr
)
26 class Playlist(NSObject
):
30 def set(self
, name
, pid
, tracks
, parent
):
36 if parent
is not None:
37 parent
.children
.append(self
)
39 class ITunesLibrary(NSObject
):
41 return self
.initWithFilename_("~/Music/iTunes/iTunes Music Library.xml")
43 def initWithFilename_(self
, filename
):
44 filename
= os
.path
.expanduser(filename
)
45 plist
= read_plist(os
.path
.expanduser(filename
))
46 self
.folder
= self
.loc2name(plist
["Music Folder"])
47 pl_tracks
= plist
["Tracks"]
49 for pl_playlist
in plist
["Playlists"]:
50 playlist
= self
.make_playlist(pl_playlist
, pl_tracks
)
51 self
.playlists
[playlist
.pid
] = playlist
54 def loc2name(self
, location
):
55 return urllib
.splithost(urllib
.splittype(urllib
.unquote(location
))[1])[1]
57 def make_playlist(self
, pl_playlist
, pl_tracks
):
58 name
= pl_playlist
["Name"]
59 pid
= pl_playlist
["Playlist Persistent ID"]
62 parent_pid
= pl_playlist
["Parent Persistent ID"]
63 parent
= self
.playlists
.get(parent_pid
)
67 for item
in pl_playlist
.get("Playlist Items", []):
68 trackID
= item
["Track ID"]
69 filename
= str(pl_tracks
[str(trackID
)]["Location"])
70 filename
= self
.loc2name(filename
)
71 filename
= filename
.decode("utf-8")
72 ### filename = eval(repr(filename).lstrip("u")).decode("utf-8")
73 if not filename
.startswith(self
.folder
):
74 logging
.warn("Skipping: " + filename
)
76 filename
= strip_prefix(filename
, self
.folder
)
77 tracks
.append(filename
)
78 playlist
= Playlist
.alloc().init()
79 playlist
.set(name
, pid
, tracks
, parent
)
82 def has_playlist_name(self
, name
):
83 for p
in self
.get_playlists():
88 def get_playlist_name(self
, name
):
89 for playlist
in self
.get_playlists():
90 if playlist
.name
== name
:
93 def get_playlist_pid(self
, pid
):
94 for playlist
in self
.get_playlists():
95 if playlist
.pid
== pid
:
98 def get_playlists(self
):
99 return self
.playlists
.values()
101 def outlineView_numberOfChildrenOfItem_(self
, view
, item
):
103 return len(self
.playlists
)
107 def outlineView_isItemExpandable_(self
, view
, item
):
110 def outlineView_child_ofItem_(self
, view
, index
, item
):
112 return self
.playlists
[index
]
116 def outlineView_objectValueForTableColumn_byItem_(self
, view
, column
, item
):
121 valid_chars
= frozenset("\\/-_.() abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
122 def encode_filename(filename
):
124 return encoded_names
[filename
]
127 orig_filename
= filename
128 filename
= filename
.encode("ascii", "ignore")
129 filename
= "".join(c
for c
in filename
if c
in valid_chars
)
130 if filename
in encoded_names
:
131 a
, b
= os
.path
.splitext(filename
)
134 encoded_names
[orig_filename
] = filename
137 def export_m3u(dry_run
, dest
, path_prefix
, playlist_name
, files
):
140 playlist_file
= os
.path
.join(dest
, playlist_name
) + ".m3u"
141 logging
.info("Writing: " + playlist_file
)
142 f
= open(playlist_file
, "w")
143 for filename
in files
:
144 if path_prefix
.find("\\") > 0:
145 filename
= filename
.replace("/", "\\")
146 filename
= encode_filename(filename
)
147 f
.write("%s%s\n" % (path_prefix
, filename
))
150 def strip_prefix(s
, prefix
):
151 assert s
.startswith(prefix
)
153 if s
.startswith("/"):
158 if os
.path
.isdir(path
):
162 path
= os
.path
.split(path
)[0]
164 for path
in reversed(paths
):
170 def sync(dry_run
, source
, dest
, files
):
173 logging
.info("Calculating files to sync and deleting old files")
174 source
= source
.encode("utf-8")
175 dest
= dest
.encode("utf-8")
178 filemap
[encode_filename(f
)] = f
.encode("utf-8")
179 files
= set(filemap
.keys())
180 for dirpath
, dirnames
, filenames
in os
.walk(dest
):
181 full_dirpath
= dirpath
182 dirpath
= strip_prefix(dirpath
, dest
)
184 for filename
in filenames
:
185 filename
= join(dirpath
, filename
)
187 # Whenever 'file' is deleted OSX will helpfully remove '._file'
188 if not os
.path
.exists(join(dest
, filename
)):
191 if filename
in files
:
192 sourcestat
= os
.stat(join(source
, filename
))
193 deststat
= os
.stat(join(dest
, filename
))
194 same_time
= abs(sourcestat
.st_mtime
- deststat
.st_mtime
) < 5
195 same_size
= sourcestat
.st_size
== deststat
.st_size
196 if same_time
and same_size
:
197 files
.remove(filename
)
198 logging
.debug("keep: " + filename
)
200 logging
.debug("update: " + filename
)
202 elif not filename
.endswith(".m3u"):
203 logging
.debug("delete: " + filename
)
205 os
.unlink(join(dest
, filename
))
207 if len(os
.listdir(full_dirpath
)) == 0:
208 logging
.debug("rmdir: " + dirpath
)
210 os
.rmdir(full_dirpath
)
213 logging
.info("Copying new files")
216 for filename
in files
:
217 logging
.debug("copy: " + filename
)
219 mkdirhier(os
.path
.dirname(join(dest
, filename
)))
220 shutil
.copy2(join(source
, filemap
[filename
]), join(dest
, filename
))