Work around missing seriesIndex API by using keyword=index
[python-iview.git] / iview-gtk
blob01a03ee79514619c96ff9fff292f182af04a77a8
1 #!/usr/bin/env python3
3 import iview.config
4 import iview.comm
5 import iview.fetch
6 import sys
7 import os.path
8 from urllib.error import HTTPError
10 try:
11     import gi
12     gi.require_version('Gtk', '3.0')
13     gi.require_version('Gdk', '3.0')
14     from gi.repository import Gtk, Gdk
15 except (ImportError, ValueError) as err:
16     raise ImportError("""\
17 This program requires the Py G Object and GTK 3 packages; see the readme file""") \
18         from err
20 num_windows = 0
21 save_location = None
23 def add_window():
24     global num_windows
25     num_windows += 1
27 def del_window(widget=None):
28     global num_windows
29     num_windows -= 1
30     if num_windows == 0:
31         Gtk.main_quit()
33 def die(markup):
34     message = Gtk.MessageDialog(
35         parent=window,
36         message_type=Gtk.MessageType.ERROR,
37         buttons=Gtk.ButtonsType.CLOSE)
38     message.set_markup(markup)
39     message.run()
40     sys.exit(1)
42 class Frontend:
43     def __init__(self, parent, size):
44         self.parent = parent
45         self.size = size
46         
47         self.resumable = False
48         
49         # Clear any "Download failed" message from previous run
50         self.parent.progress.set_text('')
51         self.parent.progress.set_show_text(True)
52     
53     def set_fraction(self, p):
54         self.size = None  # Stop calculating fraction based on size
55         self.show_fraction(p)
56     
57     def show_fraction(self, p):
58         Gdk.threads_enter()
59         self.parent.progress.set_fraction(p)
60         self.parent.set_title("({:.1%}) {}".format(p, self.parent.my_title))
61         Gdk.threads_leave()
62     
63     def set_size(self, size):
64         size = '{:.1f} MB'.format(size / 1e6)
65         Gdk.threads_enter()
66         self.parent.labels[2][1].set_text(size)
67         Gdk.threads_leave()
68         if self.size:
69             self.show_fraction(size / self.size)
70     
71     def done(self, stopped=False, failed=False):
72         Gdk.threads_enter()
74         if not stopped and not failed:
75             self.parent.progress.set_fraction(1.)
76             self.parent.progress.set_text('Download finished')
77             self.parent.set_title(self.parent.my_title)
78             self.parent.close_btn.set_label(Gtk.STOCK_CLOSE)
79             self.parent.pause_btn.hide()
80             self.parent.resume_btn.hide()
81         else:
82             if failed:
83                 self.parent.progress.set_text('Download failed')
84             self.parent.pause_btn.hide()
85             
86             label = 'Resume' if self.resumable else 'Retry'
87             self.parent.resume_btn.set_label(label)
88             self.parent.resume_btn.show()
90         Gdk.threads_leave()
92 class Downloader(Gtk.Window):
93     def __init__(self, target, title=None, dest_file=None):
94         self.target = target
95         self.dest_file = dest_file
97         Gtk.Window.__init__(self)
98         self.connect('destroy', del_window)
99         self.connect('destroy', self.on_destroy)
100         add_window()
102         if title is None:
103             title = target['url']
104         self.my_title = dest_file.split("/")[-1]
105         self.set_title(title)
106         self.set_resizable(False)
107         self.set_default_size(400,0)
108         self.set_border_width(10)
110         xpadding = 5
111         ypadding = 2
113         table = Gtk.Table(3, 3, homogeneous=False)
115         self.labels = []
116         for i in range(3):
117             label_term = Gtk.Label()
118             label_term.set_alignment(0., 0.5)
119             label_desc = Gtk.Label()
120             label_desc.set_alignment(1., 0.5)
121             self.labels.append([label_term, label_desc])
123             table.attach(label_term, 0,1, i,i+1, xpadding=xpadding, ypadding=ypadding)
124             table.attach(label_desc, 1,2, i,i+1, xpadding=xpadding, ypadding=ypadding)
126         self.labels[0][0].set_text('Name')
127         self.labels[0][1].set_text(title)
128         self.labels[1][0].set_text('Filename')
129         self.labels[1][1].set_text(dest_file.split('/')[-1])
130         self.labels[2][0].set_text('Download size')
131         self.labels[2][1].set_text('0.0 MB')
133         self.progress = Gtk.ProgressBar()
134         table.attach(self.progress, 0,2, 3,4, xpadding=xpadding, ypadding=8)
136         bb = Gtk.HButtonBox()
137         bb.set_layout(Gtk.ButtonBoxStyle.END)
138         self.pause_btn = Gtk.Button('Pause')
139         self.pause_btn.connect('clicked', self.pause_download)
140         self.resume_btn = Gtk.Button('Resume')
141         self.resume_btn.connect('clicked', self.start_download)
142         self.close_btn = Gtk.Button(stock=Gtk.STOCK_STOP)
143         self.close_btn.connect('clicked', self.destroy)
144         bb.pack_end(self.pause_btn, True, True, 0)
145         bb.pack_end(self.resume_btn, True, True, 0)
146         bb.pack_end(self.close_btn, True, True, 0)
148         vbox = Gtk.VBox()
149         vbox.pack_start(table, False, True, 0)
150         vbox.pack_start(bb, False, True, 0)
151         self.add(vbox)
153         self.show_all()
154         self.start_download() # kick off the DownloadWorker thread
156     def start_download(self, widget=None):
157         self.resume_btn.hide()
158         
159         frontend = Frontend(self, self.target['size'])
160         self.pause_btn.hide()
161         self.job = iview.fetch.fetch_program(
162             item=self.target,
163             dest_file=self.dest_file,
164             frontend=frontend,
165         )
167         if not self.job:
168             message = Gtk.MessageDialog(
169                 parent=None,
170                 type=Gtk.MessageType.ERROR,
171                 buttons=Gtk.ButtonsType.CLOSE)
172             message.set_markup('<big><b>Download backend failed</b></big>\n\n' \
173                 'Either the download backend in question failed for some reason, or one could not be found with which to download iView programmes. Please check the README.md file for instructions on setting this up.')
174             message.run()
175             message.destroy()
176             return
178         if frontend.resumable:
179             self.pause_btn.show()
180         self.job.start()
182     def pause_download(self, widget=None):
183         self.job.terminate()
185     def on_destroy(self, widget=None):
186         self.job.terminate()
188     def destroy(self, null_param=None):
189         """Allow destroy() to be called with a parameter, thus allowing it to
190         be attached to the "clicked" event of a button.
191         """
192         Gtk.Window.destroy(self)
194 def on_download_clicked(widget, data=None):
195     global window, save_location
197     model, selected_iter = listing.get_selection().get_selected()
198     if selected_iter is None:
199         return
200     item = model[selected_iter]
201     if item is None or 'id' in item[1]:  # Series, or loading placeholder
202         return
204     save_dialog = Gtk.FileChooserDialog('Save Video',
205         parent=window,
206         action=Gtk.FileChooserAction.SAVE,
207         buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
208                  Gtk.STOCK_SAVE, Gtk.ResponseType.OK))
209     url = item[1]['url']
210     resumable = iview.fetch.is_resumable(url)
211     save_dialog.set_do_overwrite_confirmation(not resumable)
213     if not resumable and save_location is None:
214         home = os.path.expanduser("~")
215         if home != "~":
216             save_location = home
217     if save_location is not None:
218         save_dialog.set_current_folder(save_location)
220     # build a nice filename
221     # could use this ugly version instead:
222     #   filename = iview.fetch.get_filename(item[1]['url'])
223     # TODO: This may hamper (future) GTK+-based subtitle downloading unless
224     # this is made into a shared function
226     program = model.get_value(model.iter_parent(selected_iter), 0)
227     title = item[0]
228     filename = iview.fetch.descriptive_filename(program, title, url)
229     if not resumable and save_location is not None:
230         # Method to increment file names inside brackets
231         count = 1
232         [root, ext] = os.path.splitext(filename)
233         # increment numbers until the file doesn't exist
234         while os.path.exists(os.path.join(save_location, filename)):
235             filename = "{} ({}){}".format(root, count, ext)
236             count = count + 1
237     save_dialog.set_current_name(filename)
239     save_dialog.set_local_only(False) # allow saving to, e.g., SFTP
240     save_response = save_dialog.run()
242     if save_response == Gtk.ResponseType.OK:
243         dest_file = save_dialog.get_filename()
244         new_location = save_dialog.get_current_folder()
245         if new_location is not None:
246             save_location = new_location
247         Downloader(item[1], item[0], dest_file)
249     save_dialog.hide()
250     save_dialog.destroy()
252 def on_listing_cursor_changed(selection):
253     global description
255     description.set_text('')
256     download_btn.set_sensitive(False)
258     model, selected_iter = selection.get_selected()
259     if selected_iter is None:
260         return
261     item = model.get_value(selected_iter, 1)
262     if item is None or 'id' in item:  # Series or unloaded episode
263         return
265     description.set_text(item['description'])
266     download_btn.set_sensitive(True)
268 def load_programme():
269     global programme
271     for series in iview.comm.get_index():
272         item = [series['title'], dict(id=series['id'])]
273         series_iter = programme.append(None, item)
274         programme.append(series_iter, ['Loading...', None])
276 def load_series_items(widget, iter, path):
277     model = widget.get_model()
278     child = model.iter_children(iter)
280     if model.get_value(child, 1) is not None:
281         # This is not a "Loading..." item, so we've already fetched this.
282         # Better pull out.
283         return
285     series_id = model.get_value(iter, 1)['id']
286     items = iview.comm.get_series_items(series_id)
288     for item in items:
289         target = dict(
290             url=item['url'],
291             description=item['description'],
292             size=item.get('size'),
293         )
294         model.append(iter, [item['title'], target])
296     model.remove(child)
298 def about(widget, data=None):
299     d = Gtk.AboutDialog()
301     d.set_version(iview.config.version)
302     d.set_copyright(
303         'Copyright \N{COPYRIGHT SIGN} 2009-2010 by Jeremy Visser')
304     d.set_license("""This program is free software: you can redistribute it and/or modify
305 it under the terms of the GNU General Public License as published by
306 the Free Software Foundation, either version 3 of the License, or
307 (at your option) any later version.
309 This program is distributed in the hope that it will be useful,
310 but WITHOUT ANY WARRANTY; without even the implied warranty of
311 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
312 GNU General Public License for more details.
314 You should have received a copy of the GNU General Public License
315 along with this program.  If not, see <http://www.gnu.org/licenses/>.""")
317     d.run()
318     d.destroy()
320 def main():
321     # Seems to be necessary for other threads to be scheduled properly.
322     # Hinted at
323     # http://stackoverflow.com/questions/8120860/python-doing-some-work-on-background-with-gtk-gui
324     from gi.repository import GLib
325     GLib.threads_init()
327     Gdk.threads_init()
329     global window
330     window = Gtk.Window()
331     window.set_title('iView')
332     window.set_default_size(400,450)
333     window.set_border_width(5)
334     window.connect('destroy', del_window)
335     add_window()
337     vbox = Gtk.VBox()
339     programme_label = Gtk.Label()
340     programme_label.set_markup('<big><b>iView Programme</b></big>')
342     vbox.pack_start(programme_label, False, True, 0)
344     listing_scroller = Gtk.ScrolledWindow()
345     listing_scroller.set_policy(
346         Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
347     listing_scroller.set_shadow_type(Gtk.ShadowType.IN)
349     # Columns: 0=name, 1=target.
350     # Target has 'id' key only for series items, and
351     # is None for the unloaded episode marker.
352     global programme
353     programme = Gtk.TreeStore(str, object)
355     global listing
356     listing = Gtk.TreeView(programme)
357     listing.set_headers_visible(False)
358     selection = listing.get_selection()
359     selection.set_mode(Gtk.SelectionMode.SINGLE)
360     selection.connect('changed', on_listing_cursor_changed)
361     listing.connect('row-expanded', load_series_items)
363     tvcolumn = Gtk.TreeViewColumn('Program Name')
364     listing.append_column(tvcolumn)
365     cell = Gtk.CellRendererText()
366     tvcolumn.pack_start(cell, True)
367     tvcolumn.add_attribute(cell, 'text', 0)
369     listing_scroller.set_border_width(5)
371     listing_scroller.add(listing)
372     vbox.pack_start(listing_scroller, True, True, 0)
374     global description
375     description = Gtk.Label()
376     description.set_line_wrap(True)
377     vbox.pack_start(description, False, True, 0)
379     bb = Gtk.HButtonBox()
380     bb.set_layout(Gtk.ButtonBoxStyle.EDGE)
381     bb.set_border_width(5)
383     about_btn = Gtk.Button(stock=Gtk.STOCK_ABOUT)
384     about_btn.connect('clicked', about)
385     
386     global download_btn
387     download_btn = Gtk.Button('Download')
388     download_btn.set_sensitive(False)
389     download_btn.connect('clicked', on_download_clicked)
391     bb.pack_start(about_btn, True, True, 0)
392     bb.pack_start(download_btn, True, True, 0)
394     vbox.pack_start(bb, False, True, 0)
396     window.add(vbox)
398     if len(sys.argv) >= 2 and sys.argv[1] in ('-c', '--cache'):
399         iview.config.cache = sys.argv[2]
401     try:
402         iview.comm.get_config()
403         load_programme()
404     except HTTPError as error:
405         die('<big><b>Download failed</b></big>\n\n'
406             'Could not retrieve an important configuration file from iView.'
407             ' Please make sure'
408             ' you are connected to the Internet.\n\n'
409             'If iView works fine in your web browser, then'
410             ' the iView API has most likely changed.'
411             ' Try and find an updated version of this program,'
412             ' or contact the author.\n\n'
413             'URL: {}'.format(error.url))
414     except EnvironmentError as error:
415         from traceback import format_exception_only, print_exc
416         print_exc()
417         die('<big><b>Failed getting programme list</b></big>\n\n' +
418             ''.join(format_exception_only(type(error), error)))
420     window.show_all()
421     Gtk.main()
423 if __name__ == "__main__":
424     main()