add some comments,
[enigma2-plugins.git] / ftpbrowser / src / FTPBrowser.py
1 # for localized messages
2 from . import _
3
4 # Core
5 from enigma import RT_HALIGN_LEFT, eListboxPythonMultiContent
6
7 # Tools
8 from Tools.Directories import SCOPE_SKIN_IMAGE, resolveFilename
9 from Tools.LoadPixmap import LoadPixmap
10 from Tools.Notifications import AddPopup
11
12 # GUI (Screens)
13 from Screens.Screen import Screen
14 from Screens.HelpMenu import HelpableScreen
15 from Screens.MessageBox import MessageBox
16 from Screens.ChoiceBox import ChoiceBox
17 from Screens.InfoBarGenerics import InfoBarNotifications
18
19 # GUI (Components)
20 from Components.ActionMap import ActionMap, HelpableActionMap
21 from Components.Label import Label
22 from Components.FileList import FileList, FileEntryComponent, EXTENSIONS
23 from Components.Button import Button
24 from VariableProgressSource import VariableProgressSource
25
26 # FTP Client
27 from twisted.internet import reactor, defer
28 from twisted.internet.protocol import Protocol, ClientCreator
29 from twisted.protocols.ftp import FTPClient, FTPFileListProtocol
30 from twisted.protocols.basic import FileSender
31
32 # For new and improved _parse
33 from urlparse import urlparse, urlunparse
34
35 # System
36 from os import path as os_path
37 from time import time
38
39 def _parse(url, defaultPort = None):
40         url = url.strip()
41         parsed = urlparse(url)
42         scheme = parsed[0]
43         path = urlunparse(('','')+parsed[2:])
44
45         if defaultPort is None:
46                 if scheme == 'https':
47                         defaultPort = 443
48                 elif scheme == 'ftp':
49                         defaultPort = 21
50                 else:
51                         defaultPort = 80
52
53         host, port = parsed[1], defaultPort
54
55         if '@' in host:
56                 username, host = host.split('@')
57                 if ':' in username:
58                         username, password = username.split(':')
59                 else:
60                         password = ""
61         else:
62                 username = ""
63                 password = ""
64
65         if ':' in host:
66                 host, port = host.split(':')
67                 port = int(port)
68
69         if path == "":
70                 path = "/"
71
72         return scheme, host, port, path, username, password
73
74 def FTPFileEntryComponent(file, directory):
75         isDir = True if file['filetype'] == 'd' else False
76         name = file['filename']
77         absolute = directory + name
78         if isDir:
79                 absolute += '/'
80
81         res = [
82                 (absolute, isDir, file['size']),
83                 (eListboxPythonMultiContent.TYPE_TEXT, 35, 1, 470, 20, 0, RT_HALIGN_LEFT, name)
84         ]
85         if isDir:
86                 png = LoadPixmap(resolveFilename(SCOPE_SKIN_IMAGE, "extensions/directory.png"))
87         else:
88                 extension = name.split('.')
89                 extension = extension[-1].lower()
90                 if EXTENSIONS.has_key(extension):
91                         png = LoadPixmap(resolveFilename(SCOPE_SKIN_IMAGE, "extensions/" + EXTENSIONS[extension] + ".png"))
92                 else:
93                         png = None
94         if png is not None:
95                 res.append((eListboxPythonMultiContent.TYPE_PIXMAP_ALPHATEST, 10, 2, 20, 20, png))
96
97         return res
98
99 class FTPFileList(FileList):
100         def __init__(self):
101                 self.ftpclient = None
102                 self.select = None
103                 FileList.__init__(self, "/")
104
105         def changeDir(self, directory, select = None):
106                 if self.ftpclient is None:
107                         self.list = []
108                         self.l.setList(self.list)
109                         return
110
111                 self.current_directory = directory
112                 self.select = select
113
114                 self.filelist = FTPFileListProtocol()
115                 d = self.ftpclient.list(directory, self.filelist)
116                 d.addCallback(self.listRcvd).addErrback(self.listFailed)
117
118         def listRcvd(self, *args):
119                 # TODO: is any of the 'advanced' features useful (and more of all can they be implemented) here?
120                 list = [FTPFileEntryComponent(file, self.current_directory) for file in self.filelist.files]
121                 list.sort(key = lambda x: (not x[0][1], x[0][0]))
122                 if self.current_directory != "/":
123                         list.insert(0, FileEntryComponent(name = "<" +_("Parent Directory") + ">", absolute = '/'.join(self.current_directory.split('/')[:-2]) + '/', isDir = True))
124
125                 self.l.setList(list)
126                 self.list = list
127
128                 select = self.select
129                 if select is not None:
130                         i = 0
131                         self.moveToIndex(0)
132                         for x in list:
133                                 p = x[0][0]
134
135                                 if p == select:
136                                         self.moveToIndex(i)
137                                         break
138                                 i += 1
139
140         def listFailed(self, *args):
141                 # XXX: we might end up here if login fails, we might want to add some check for this (e.g. send a dummy command before doing actual work)
142                 if self.current_directory != "/":
143                         self.list = [FileEntryComponent(name = "<" +_("Parent Directory") + ">", absolute = '/'.join(self.current_directory.split('/')[:-2]) + '/', isDir = True)]
144                 else:
145                         self.list = []
146                 self.l.setList(self.list)
147
148 class FTPBrowser(Screen, Protocol, InfoBarNotifications, HelpableScreen):
149         skin = """
150                 <screen name="FTPBrowser" position="center,center" size="560,440" title="FTP Browser">
151                         <widget name="localText" position="20,10" size="200,20" font="Regular;18" />
152                         <widget name="local" position="20,40" size="255,320" scrollbarMode="showOnDemand" />
153                         <widget name="remoteText" position="285,10" size="200,20" font="Regular;18" />
154                         <widget name="remote" position="285,40" size="255,320" scrollbarMode="showOnDemand" />
155                         <widget name="eta" position="20,360" size="200,30" font="Regular;23" />
156                         <widget name="speed" position="330,360" size="200,30" halign="right" font="Regular;23" />
157                         <widget source="progress" render="Progress" position="20,390" size="520,10" />
158                         <ePixmap name="green" position="10,400" zPosition="4" size="140,40" pixmap="skin_default/buttons/green.png" transparent="1" alphatest="on" />
159                         <ePixmap name="yellow" position="180,400" zPosition="4" size="140,40" pixmap="skin_default/buttons/yellow.png" transparent="1" alphatest="on" />
160                         <ePixmap name="blue" position="350,400" zPosition="4" size="140,40" pixmap="skin_default/buttons/blue.png" transparent="1" alphatest="on" />
161                         <widget name="key_green" position="10,400" zPosition="5" size="140,40" valign="center" halign="center" font="Regular;21" transparent="1" foregroundColor="white" shadowColor="black" shadowOffset="-1,-1" />
162                         <widget name="key_yellow" position="180,400" zPosition="5" size="140,40" valign="center" halign="center" font="Regular;21" transparent="1" foregroundColor="white" shadowColor="black" shadowOffset="-1,-1" />
163                         <widget name="key_blue" position="350,400" zPosition="5" size="140,40" valign="center" halign="center" font="Regular;21" transparent="1" foregroundColor="white" shadowColor="black" shadowOffset="-1,-1" />
164                         <ePixmap position="515,408" zPosition="1" size="35,25" pixmap="skin_default/buttons/key_menu.png" alphatest="on" />
165                 </screen>"""
166
167         def __init__(self, session):
168                 Screen.__init__(self, session)
169                 HelpableScreen.__init__(self)
170                 InfoBarNotifications.__init__(self)
171                 self.ftpclient = None
172                 self.file = None
173                 self.currlist = "remote"
174
175                 # Init what we need for dl progress
176                 self.currentLength = 0
177                 self.lastLength = 0
178                 self.lastTime = 0
179                 self.lastApprox = 0
180                 self.fileSize = 0
181
182                 self["localText"] = Label(_("Local"))
183                 self["local"] = FileList("/media/hdd/", showMountpoints = False)
184                 self["remoteText"] = Label(_("Remote"))
185                 self["remote"] = FTPFileList()
186                 self["eta"] = Label("")
187                 self["speed"] = Label("")
188                 self["progress"] = VariableProgressSource()
189                 self["key_red"] = Button(_("Exit"))
190                 self["key_green"] = Button("")
191                 self["key_yellow"] = Button("")
192                 self["key_blue"] = Button("")
193
194                 self.URI = "ftp://"
195
196                 self["ftpbrowserBaseActions"] = HelpableActionMap(self, "ftpbrowserBaseActions",
197                         {
198                                 "ok": (self.ok, _("enter directory/get file/put file")),
199                                 "cancel": (self.cancel , _("close")),
200                                 "menu": (self.menu, _("open menu")),
201                         }, -2)
202
203                 self["ftpbrowserListActions"] = HelpableActionMap(self, "ftpbrowserListActions",
204                         {
205                                 "channelUp": (self.setLocal, _("Select local file list")),
206                                 "channelDown": (self.setRemote, _("Select remote file list")),
207                         })
208
209                 self["actions"] = ActionMap(["ftpbrowserDirectionActions"],
210                         {
211                                 "up": self.up,
212                                 "down": self.down,
213                                 "left": self.left,
214                                 "right": self.right,
215                         }, -2)
216
217                 self.onExecBegin.append(self.reinitialize)
218
219         def reinitialize(self):
220                 # NOTE: this will clear the remote file list if we are not currently connected. this behavior is intended.
221                 # XXX: but do we also want to do this when we just returned from a notification?
222                 self["remote"].refresh()
223                 self["local"].refresh()
224
225                 if not self.ftpclient:
226                         self.connect(self.URI)
227                 # XXX: Actually everything else should be taken care of... recheck this!
228
229         def inputCallback(self, res):
230                 if res:
231                         self.connect(res)
232
233         def menu(self):
234                 # TODO: add more options, currently only input for URI
235                 from Screens.InputBox import InputBox
236                 self.session.openWithCallback(
237                         self.inputCallback,
238                         InputBox,
239                         title = _("Enter URI of FTP Server:"),
240                         text = self.URI,
241                 )
242
243         def setLocal(self):
244                 self.currlist = "local"
245
246         def setRemote(self):
247                 self.currlist = "remote"
248
249         def okQuestion(self, res = None):
250                 if res:
251                         self.ok(force = True)
252
253         def ok(self, force = False):
254                 if self.currlist == "remote":
255                         # Get file/change directory
256                         if self["remote"].canDescent():
257                                 self["remote"].descent()
258                         else:
259                                 if self.file:
260                                         self.session.open(
261                                                 MessageBox,
262                                                 _("There already is an active transfer."),
263                                                 type = MessageBox.TYPE_WARNING
264                                         )
265                                         return
266
267                                 remoteFile = self["remote"].getSelection()
268                                 if not remoteFile:
269                                         return
270
271                                 absRemoteFile = remoteFile[0]
272                                 fileName = absRemoteFile.split('/')[-1]
273                                 localFile = self["local"].getCurrentDirectory() + fileName
274                                 if not force and os_path.exists(localFile):
275                                         self.session.openWithCallback(
276                                                 self.okQuestion,
277                                                 MessageBox,
278                                                 _("A file with this name already exists locally.\nDo you want to overwrite it?"),
279                                         )
280                                 else:
281                                         self.currentLength = 0
282                                         self.lastLength = 0
283                                         self.lastTime = 0
284                                         self.lastApprox = 0
285                                         self.fileSize = remoteFile[2]
286
287                                         try:
288                                                 self.file = open(localFile, 'w')
289                                         except IOError, ie:
290                                                 # TODO: handle this
291                                                 raise ie
292                                         else:
293                                                 d = self.ftpclient.retrieveFile(absRemoteFile, self, offset = 0)
294                                                 d.addCallback(self.getFinished).addErrback(self.getFailed)
295                 else:
296                         # Put file/change directory
297                         assert(self.currlist == "local")
298                         if self["local"].canDescent():
299                                 self["local"].descent()
300                         else:
301                                 if self.file:
302                                         self.session.open(
303                                                 MessageBox,
304                                                 _("There already is an active transfer."),
305                                                 type = MessageBox.TYPE_WARNING
306                                         )
307                                         return
308
309                                 localFile = self["local"].getSelection()
310                                 if not localFile:
311                                         return
312
313                                 def remoteFileExists(absName):
314                                         for file in self["remote"].getFileList():
315                                                 if file[0][0] == absName:
316                                                         return True
317                                         return False
318
319                                 # XXX: isn't this supposed to be an absolute filename? well, it's not for me :-/
320                                 fileName = localFile[0]
321                                 absLocalFile = self["local"].getCurrentDirectory() + fileName
322                                 directory = self["remote"].getCurrentDirectory()
323                                 sep = '/' if directory != '/' else ''
324                                 remoteFile = directory + sep + fileName
325                                 if not force and remoteFileExists(remoteFile):
326                                         self.session.openWithCallback(
327                                                 self.okQuestion,
328                                                 MessageBox,
329                                                 _("A file with this name already exists on the remote host.\nDo you want to overwrite it?"),
330                                         )
331                                 else:
332                                         self.currentLength = 0
333                                         self.lastLength = 0
334                                         self.lastTime = 0
335                                         self.lastApprox = 0
336
337                                         def sendfile(consumer, fileObj):
338                                                 FileSender().beginFileTransfer(fileObj, consumer, transform = self.putProgress).addCallback(  
339                                                         lambda _: consumer.finish()).addCallback(
340                                                         self.putComplete).addErrback(self.putFailed)
341
342                                         try:
343                                                 self.fileSize = int(os_path.getsize(absLocalFile))
344                                                 self.file = open(absLocalFile, 'rb')
345                                         except (IOError, OSError), e:
346                                                 # TODO: handle this
347                                                 raise e
348                                         else:
349                                                 dC, dL = self.ftpclient.storeFile(remoteFile)
350                                                 dC.addCallback(sendfile, self.file)
351
352         def transferFinished(self, msg, type, toRefresh):
353                 AddPopup(msg, type, -1)
354
355                 self["eta"].setText("")
356                 self["speed"].setText("")
357                 self["progress"].invalidate()
358                 self[toRefresh].refresh()
359                 self.file.close()
360                 self.file = None
361
362         def putComplete(self, *args):
363                 self.transferFinished(
364                         _("Upload finished."),
365                         MessageBox.TYPE_INFO,
366                         "remote"
367                 )
368
369         def putFailed(self, *args):
370                 self.transferFinished(
371                         _("Error during download."),
372                         MessageBox.TYPE_ERROR,
373                         "remote"
374                 )
375
376         def getFinished(self, *args):
377                 self.transferFinished(
378                         _("Download finished."),
379                         MessageBox.TYPE_INFO,
380                         "local"
381                 )
382
383         def getFailed(self, *args):
384                 self.transferFinished(
385                         _("Error during download."),
386                         MessageBox.TYPE_ERROR,
387                         "local"
388                 )
389
390         def putProgress(self, chunk):
391                 self.currentLength += len(chunk)
392                 self.gotProgress(self.currentLength, self.fileSize)
393                 return chunk
394
395         def gotProgress(self, pos, max):
396                 self["progress"].writeValues(pos, max)
397
398                 newTime = time()
399                 # Check if we're called the first time (got total)
400                 lastTime = self.lastTime
401                 if lastTime == 0:
402                         self.lastTime = newTime
403
404                 # We dont want to update more often than every two sec (could be done by a timer, but this should give a more accurate result though it might lag)
405                 elif int(newTime - lastTime) >= 2:
406                         lastApprox = round(((pos - self.lastLength) / (newTime - lastTime) / 1024), 2)
407
408                         secLen = int(round(((max-pos) / 1024) / lastApprox))
409                         self["eta"].setText(_("ETA %d:%02d min") % (secLen / 60, secLen % 60))
410                         self["speed"].setText(_("%d kb/s") % (lastApprox))
411
412                         self.lastApprox = lastApprox
413                         self.lastLength = pos
414                         self.lastTime = newTime
415
416         def dataReceived(self, data):
417                 if not self.file:
418                         return
419
420                 self.currentLength += len(data)
421                 self.gotProgress(self.currentLength, self.fileSize)
422
423                 try:
424                         self.file.write(data)
425                 except IOError, ie:
426                         # TODO: handle this
427                         self.file = None
428                         raise ie
429
430         def cancelQuestion(self, res = None):
431                 res = res and res[1]
432                 if res:
433                         if res == 1:
434                                 self.file.close()
435                                 self.file = None
436                                 self.disconnect()
437                         self.close()
438
439         def cancel(self):
440                 if self.file is not None:
441                         self.session.openWithCallback(
442                                 self.cancelQuestion,
443                                 ChoiceBox,
444                                 title = _("A transfer is currently in progress.\nWhat do you want to do?"),
445                                 list = (
446                                         (_("Run in Background"), 2),
447                                         (_("Abort transfer"), 1),
448                                         (_("Cancel"), 0)
449                                 )
450                         )
451                         return
452
453                 self.disconnect()
454                 self.close()
455
456         def up(self):
457                 self[self.currlist].up()
458
459         def down(self):
460                 self[self.currlist].down()
461
462         def left(self):
463                 self[self.currlist].pageUp()
464
465         def right(self):
466                 self[self.currlist].pageDown()
467
468         def disconnect(self):
469                 if self.ftpclient:
470                         # XXX: according to the docs we should wait for the servers answer to our quit request, we just hope everything goes well here
471                         self.ftpclient.quit()
472                         self.ftpclient = None
473                         self["remote"].ftpclient = None
474
475         def connect(self, address):
476                 self.disconnect()
477
478                 self.URI = address
479
480                 scheme, host, port, path, username, password = _parse(address)
481                 if not host:
482                         return
483
484                 if not username:
485                         username = 'anonymous'
486                         password = 'my@email.com'
487
488                 timeout = 30 # TODO: make configurable
489                 passive = True # TODO: make configurable
490
491                 creator = ClientCreator(reactor, FTPClient, username, password, passive = passive)
492                 creator.connectTCP(host, port, timeout).addCallback(self.controlConnectionMade).addErrback(self.connectionFailed)
493
494         def controlConnectionMade(self, ftpclient):
495                 print "[FTPBrowser] connection established"
496                 self.ftpclient = ftpclient
497                 self["remote"].ftpclient = ftpclient
498
499                 scheme, host, port, path, username, password = _parse(self.URI)
500                 self["remote"].changeDir(path)
501
502         def connectionFailed(self, *args):
503                 print "[FTPBrowser] connection failed", args
504
505                 self.URI = "ftp://"
506                 self.session.open(
507                                 MessageBox,
508                                 _("Could not connect to ftp server!"),
509                                 type = MessageBox.TYPE_ERROR,
510                                 timeout = 3,
511                 )
512