]> code.delx.au - notipod/blob - libnotipod.py
49af34a64288ff5056169be1c11875981f75c3da
[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, tracks, parent):
31 self.name = name
32 self.pid = pid
33 self.children = []
34 self.tracks = tracks
35 self.parent = parent
36 if parent is not None:
37 parent.children.append(self)
38
39 class ITunesLibrary(NSObject):
40 def load_(self, filename):
41 if filename is None:
42 filename = "~/Music/iTunes/iTunes Music Library.xml"
43 filename = os.path.expanduser(filename)
44 yield "Reading library..."
45 plist = read_plist(os.path.expanduser(filename))
46 self.folder = self.loc2name(plist["Music Folder"])
47 pl_tracks = plist["Tracks"]
48 self.playlists = {}
49 for pl_playlist in plist["Playlists"]:
50 playlist = self.make_playlist(pl_playlist, pl_tracks)
51 yield "Read playlist: " + playlist.name
52 self.playlists[playlist.pid] = playlist
53
54 def loc2name(self, location):
55 return urllib.splithost(urllib.splittype(urllib.unquote(location))[1])[1]
56
57 def make_playlist(self, pl_playlist, pl_tracks):
58 name = pl_playlist["Name"]
59 pid = pl_playlist["Playlist Persistent ID"]
60 parent = None
61 try:
62 parent_pid = pl_playlist["Parent Persistent ID"]
63 parent = self.playlists.get(parent_pid)
64 except KeyError:
65 pass
66 tracks = []
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 if not filename.startswith(self.folder):
73 logging.warn("Skipping: " + filename)
74 continue
75 filename = strip_prefix(filename, self.folder)
76 tracks.append(filename)
77 playlist = Playlist.alloc().init()
78 playlist.set(name, pid, tracks, parent)
79 return playlist
80
81 def has_playlist_name(self, name):
82 for p in self.get_playlists():
83 if p.name == name:
84 return True
85 return False
86
87 def get_playlist_name(self, name):
88 for playlist in self.get_playlists():
89 if playlist.name == name:
90 return playlist
91
92 def get_playlist_pid(self, pid):
93 for playlist in self.get_playlists():
94 if playlist.pid == pid:
95 return playlist
96
97 def get_playlists(self):
98 return self.playlists.values()
99
100 def outlineView_numberOfChildrenOfItem_(self, view, item):
101 if item == None:
102 return len(self.playlists)
103 else:
104 return 0
105
106 def outlineView_isItemExpandable_(self, view, item):
107 return False
108
109 def outlineView_child_ofItem_(self, view, index, item):
110 if item == None:
111 return self.playlists[index]
112 else:
113 return None
114
115 def outlineView_objectValueForTableColumn_byItem_(self, view, column, item):
116 return item.name
117
118
119 encoded_names = {}
120 valid_chars = frozenset("\\/-_.() abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
121 def encode_filename(filename):
122 try:
123 return encoded_names[filename]
124 except KeyError:
125 pass
126 orig_filename = filename
127 filename = filename.encode("ascii", "ignore")
128 filename = "".join(c for c in filename if c in valid_chars)
129 if filename in encoded_names:
130 a, b = os.path.splitext(filename)
131 a += "-dup"
132 filename = a + b
133 encoded_names[orig_filename] = filename
134 return filename
135
136 def strip_prefix(s, prefix):
137 assert s.startswith(prefix)
138 s = s[len(prefix):]
139 if s.startswith("/"):
140 s = s[1:]
141 return s
142
143 def mkdirhier(path):
144 if os.path.isdir(path):
145 return
146 paths = [path]
147 while path != "/":
148 path = os.path.split(path)[0]
149 paths.append(path)
150 for path in reversed(paths):
151 try:
152 os.mkdir(path)
153 except OSError:
154 pass
155
156 def export_m3u(dry_run, dest, path_prefix, playlist_name, files):
157 if dry_run:
158 return
159 if not path_prefix:
160 path_prefix = "../"
161 playlist_file = os.path.join(dest, "-Playlists-", playlist_name) + ".m3u"
162 mkdirhier(os.path.dirname(playlist_file))
163 logging.info("Writing: " + playlist_file)
164 f = open(playlist_file, "w")
165 for filename in files:
166 if path_prefix.find("\\") > 0:
167 filename = filename.replace("/", "\\")
168 filename = encode_filename(filename)
169 f.write("%s%s\n" % (path_prefix, filename))
170 f.close()
171
172 def sync(dry_run, source, dest, files_to_copy):
173 join = os.path.join
174
175 logging.info("Calculating files to sync and deleting old files")
176 source = source.encode("utf-8")
177 dest = dest.encode("utf-8")
178 filemap = {}
179 class SyncFile(object): pass
180 for f in files_to_copy:
181 sf = SyncFile()
182 sf.orig_filename = f.encode("utf-8")
183 sf.encoded_filename = encode_filename(f)
184 filemap[sf.encoded_filename.lower()] = sf
185 files_to_copy = set(filemap)
186
187 for dirpath, dirnames, filenames in os.walk(dest):
188 full_dirpath = dirpath
189 dirpath = strip_prefix(dirpath, dest)
190
191 for filename in filenames:
192 filename = join(dirpath, filename)
193
194 # Whenever 'file' is deleted OSX will helpfully remove '._file'
195 if not os.path.exists(join(dest, filename)):
196 continue
197
198 if filename.lower() in files_to_copy:
199 source_filename = filemap[filename.lower()].orig_filename
200 sourcestat = os.stat(join(source, source_filename))
201 deststat = os.stat(join(dest, filename))
202 same_time = abs(sourcestat.st_mtime - deststat.st_mtime) < 5
203 same_size = sourcestat.st_size == deststat.st_size
204 if same_time and same_size:
205 files_to_copy.remove(filename.lower())
206 yield "Keep: " + filename
207 else:
208 yield "Update: " + filename
209
210 elif not filename.startswith("-Playlists-"):
211 yield "Delete: " + filename
212 if not dry_run:
213 os.unlink(join(dest, filename))
214
215 if len(os.listdir(full_dirpath)) == 0:
216 yield "Delete: " + dirpath
217 if not dry_run:
218 os.rmdir(full_dirpath)
219
220
221 logging.info("Copying new files")
222 files_to_copy = list(files_to_copy)
223 files_to_copy.sort()
224 for filename in files_to_copy:
225 yield "Copy: " + filemap[filename].orig_filename
226 if not dry_run:
227 source_file = join(source, filemap[filename].orig_filename)
228 dest_file = join(dest, filemap[filename].encoded_filename)
229 mkdirhier(os.path.dirname(dest_file))
230 shutil.copy2(source_file, dest_file)
231
232