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