]> code.delx.au - notipod/blob - libnotipod.py
Added icons
[notipod] / libnotipod.py
1 #!/usr/bin/env python
2 # Copyright 2009 James Bunton <jamesbunton@fastmail.fm>
3 # Licensed for distribution under the GPL version 2, check COPYING for details
4
5 import logging
6 import os
7 import shutil
8 import sys
9 import urllib
10
11 from Foundation import *
12
13
14 def read_plist(filename):
15 try:
16 data = buffer(open(filename).read())
17 except IOError:
18 return None
19 plist, fmt, err = NSPropertyListSerialization.propertyListFromData_mutabilityOption_format_errorDescription_(data, NSPropertyListMutableContainers, None, None)
20 if err is not None:
21 errStr = err.encode("utf-8")
22 err.release() # Doesn't follow Cocoa conventions for some reason
23 raise TypeError(errStr)
24 return plist
25
26 class Playlist(NSObject):
27 def init(self):
28 return self
29
30 def set(self, name, pid, ptype, tracks, parent):
31 self.name = name
32 self.pid = pid
33 self.ptype = ptype
34 self.children = []
35 self.tracks = tracks
36 self.parent = parent
37 if parent is not None:
38 parent.children.append(self)
39
40 class ITunesLibrary(NSObject):
41 def load_(self, filename):
42 if filename is None:
43 filename = "~/Music/iTunes/iTunes Music Library.xml"
44 filename = os.path.expanduser(filename)
45 yield "Reading library..."
46 plist = read_plist(os.path.expanduser(filename))
47 self.folder = self.loc2name(plist["Music Folder"])
48 pl_tracks = plist["Tracks"]
49 self.playlists = {}
50 for pl_playlist in plist["Playlists"]:
51 playlist = self.make_playlist(pl_playlist, pl_tracks)
52 if not playlist:
53 continue
54 yield "Read playlist: " + playlist.name
55 self.playlists[playlist.pid] = playlist
56
57 def loc2name(self, location):
58 return urllib.splithost(urllib.splittype(urllib.unquote(location))[1])[1]
59
60 def make_playlist(self, pl_playlist, pl_tracks):
61 if int(pl_playlist.get("Master", 0)):
62 return
63 kind = int(pl_playlist.get("Distinguished Kind", -1))
64 if kind == 26:
65 # Don't do genius
66 return
67
68 name = pl_playlist["Name"]
69 pid = pl_playlist["Playlist Persistent ID"]
70 if kind > 0:
71 ptype = {
72 2: "movies",
73 3: "tv-shows",
74 4: "music",
75 5: "books",
76 10: "podcasts",
77 19: "purchased",
78 22: "itunes-dj",
79 31: "itunes-u",
80 }.get(kind, "playlist")
81 elif pl_playlist.has_key("Smart Info"):
82 ptype = "smart-playlist"
83 elif int(pl_playlist.get("Folder", 0)):
84 ptype = "folder"
85 else:
86 ptype = "playlist"
87
88 parent = None
89 try:
90 parent_pid = pl_playlist["Parent Persistent ID"]
91 parent = self.playlists.get(parent_pid)
92 except KeyError:
93 pass
94 tracks = []
95 for item in pl_playlist.get("Playlist Items", []):
96 trackID = item["Track ID"]
97 filename = str(pl_tracks[str(trackID)]["Location"])
98 filename = self.loc2name(filename)
99 filename = filename.decode("utf-8")
100 if not filename.startswith(self.folder):
101 logging.warn("Skipping: " + filename)
102 continue
103 filename = strip_prefix(filename, self.folder)
104 tracks.append(filename)
105 playlist = Playlist.alloc().init()
106 playlist.set(name, pid, ptype, tracks, parent)
107 return playlist
108
109 def has_playlist_name(self, name):
110 for p in self.get_playlists():
111 if p.name == name:
112 return True
113 return False
114
115 def get_playlist_name(self, name):
116 for playlist in self.get_playlists():
117 if playlist.name == name:
118 return playlist
119
120 def get_playlist_pid(self, pid):
121 for playlist in self.get_playlists():
122 if playlist.pid == pid:
123 return playlist
124
125 def get_playlists(self):
126 return self.playlists.values()
127
128 def outlineView_numberOfChildrenOfItem_(self, view, item):
129 if item == None:
130 return len(self.playlists)
131 else:
132 return 0
133
134 def outlineView_isItemExpandable_(self, view, item):
135 return False
136
137 def outlineView_child_ofItem_(self, view, index, item):
138 if item == None:
139 return self.playlists[index]
140 else:
141 return None
142
143 def outlineView_objectValueForTableColumn_byItem_(self, view, column, item):
144 return item.name
145
146
147 encoded_names = {}
148 valid_chars = frozenset("\\/-_.() abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
149 def encode_filename(filename):
150 try:
151 return encoded_names[filename]
152 except KeyError:
153 pass
154 orig_filename = filename
155 filename = filename.encode("ascii", "ignore")
156 filename = "".join(c for c in filename if c in valid_chars)
157 if filename in encoded_names:
158 a, b = os.path.splitext(filename)
159 a += "-dup"
160 filename = a + b
161 encoded_names[orig_filename] = filename
162 return filename
163
164 def strip_prefix(s, prefix):
165 assert s.startswith(prefix)
166 s = s[len(prefix):]
167 if s.startswith("/"):
168 s = s[1:]
169 return s
170
171 def mkdirhier(path):
172 if os.path.isdir(path):
173 return
174 paths = [path]
175 while path != "/":
176 path = os.path.split(path)[0]
177 paths.append(path)
178 for path in reversed(paths):
179 try:
180 os.mkdir(path)
181 except OSError:
182 pass
183
184 def export_m3u(dry_run, dest, path_prefix, playlist_name, files):
185 if dry_run:
186 return
187 if not path_prefix:
188 path_prefix = "../"
189 playlist_file = os.path.join(dest, "-Playlists-", playlist_name) + ".m3u"
190 mkdirhier(os.path.dirname(playlist_file))
191 logging.info("Writing: " + playlist_file)
192 f = open(playlist_file, "w")
193 for filename in files:
194 if path_prefix.find("\\") > 0:
195 filename = filename.replace("/", "\\")
196 filename = encode_filename(filename)
197 f.write("%s%s\n" % (path_prefix, filename))
198 f.close()
199
200 def sync(dry_run, source, dest, files_to_copy):
201 join = os.path.join
202
203 logging.info("Calculating files to sync and deleting old files")
204 source = source.encode("utf-8")
205 dest = dest.encode("utf-8")
206 filemap = {}
207 class SyncFile(object): pass
208 for f in files_to_copy:
209 sf = SyncFile()
210 sf.orig_filename = f.encode("utf-8")
211 sf.encoded_filename = encode_filename(f)
212 filemap[sf.encoded_filename.lower()] = sf
213 files_to_copy = set(filemap)
214
215 for dirpath, dirnames, filenames in os.walk(dest):
216 full_dirpath = dirpath
217 dirpath = strip_prefix(dirpath, dest)
218
219 for filename in filenames:
220 filename = join(dirpath, filename)
221
222 # Whenever 'file' is deleted OSX will helpfully remove '._file'
223 if not os.path.exists(join(dest, filename)):
224 continue
225
226 if filename.lower() in files_to_copy:
227 source_filename = filemap[filename.lower()].orig_filename
228 sourcestat = os.stat(join(source, source_filename))
229 deststat = os.stat(join(dest, filename))
230 same_time = abs(sourcestat.st_mtime - deststat.st_mtime) < 5
231 same_size = sourcestat.st_size == deststat.st_size
232 if same_time and same_size:
233 files_to_copy.remove(filename.lower())
234 yield "Keep: " + filename
235 else:
236 yield "Update: " + filename
237
238 elif not filename.startswith("-Playlists-"):
239 yield "Delete: " + filename
240 if not dry_run:
241 os.unlink(join(dest, filename))
242
243 if len(os.listdir(full_dirpath)) == 0:
244 yield "Delete: " + dirpath
245 if not dry_run:
246 os.rmdir(full_dirpath)
247
248
249 logging.info("Copying new files")
250 files_to_copy = list(files_to_copy)
251 files_to_copy.sort()
252 for filename in files_to_copy:
253 yield "Copy: " + filemap[filename].orig_filename
254 if not dry_run:
255 source_file = join(source, filemap[filename].orig_filename)
256 dest_file = join(dest, filemap[filename].encoded_filename)
257 mkdirhier(os.path.dirname(dest_file))
258 shutil.copy2(source_file, dest_file)
259
260