1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2012 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
46 from gpodder
import core
47 from gpodder
import feedcore
48 from gpodder
import util
49 from gpodder
import opml
50 from gpodder
import download
51 from gpodder
import my
52 from gpodder
import youtube
53 from gpodder
import player
56 logger
= logging
.getLogger(__name__
)
61 from gpodder
.gtkui
.model
import Model
62 from gpodder
.gtkui
.model
import PodcastListModel
63 from gpodder
.gtkui
.model
import EpisodeListModel
64 from gpodder
.gtkui
.config
import UIConfig
65 from gpodder
.gtkui
.services
import CoverDownloader
66 from gpodder
.gtkui
.widgets
import SimpleMessageArea
67 from gpodder
.gtkui
.desktopfile
import UserAppsReader
69 from gpodder
.gtkui
.draw
import draw_text_box_centered
, draw_cake_pixbuf
70 from gpodder
.gtkui
.draw
import EPISODE_LIST_ICON_SIZE
72 from gpodder
.gtkui
.interface
.common
import BuilderWidget
73 from gpodder
.gtkui
.interface
.common
import TreeViewHelper
74 from gpodder
.gtkui
.interface
.addpodcast
import gPodderAddPodcast
76 from gpodder
.gtkui
.download
import DownloadStatusModel
78 from gpodder
.gtkui
.desktop
.welcome
import gPodderWelcome
79 from gpodder
.gtkui
.desktop
.channel
import gPodderChannel
80 from gpodder
.gtkui
.desktop
.preferences
import gPodderPreferences
81 from gpodder
.gtkui
.desktop
.shownotes
import gPodderShownotes
82 from gpodder
.gtkui
.desktop
.episodeselector
import gPodderEpisodeSelector
83 from gpodder
.gtkui
.desktop
.podcastdirectory
import gPodderPodcastDirectory
84 from gpodder
.gtkui
.interface
.progress
import ProgressIndicator
86 from gpodder
.dbusproxy
import DBusPodcastsProxy
87 from gpodder
import extensions
91 if gpodder
.osx
and getattr(gtk
.gdk
, 'WINDOWING', 'x11') == 'quartz':
93 from gtk_osxapplication
import *
94 macapp
= OSXApplication()
96 print >> sys
.stderr
, """
97 Warning: gtk-mac-integration not found, disabling native menus
101 class gPodder(BuilderWidget
, dbus
.service
.Object
):
102 # Width (in pixels) of episode list icon
103 EPISODE_LIST_ICON_WIDTH
= 40
105 def __init__(self
, bus_name
, gpodder_core
):
106 dbus
.service
.Object
.__init
__(self
, object_path
=gpodder
.dbus_gui_object_path
, bus_name
=bus_name
)
107 self
.podcasts_proxy
= DBusPodcastsProxy(lambda: self
.channels
,
108 self
.on_itemUpdate_activate
,
109 self
.playback_episodes
,
110 self
.download_episode_list
,
111 self
.episode_object_by_uri
,
113 self
.core
= gpodder_core
114 self
.config
= self
.core
.config
115 self
.db
= self
.core
.db
116 self
.model
= self
.core
.model
117 BuilderWidget
.__init
__(self
, None)
120 gpodder
.user_extensions
.on_ui_object_available('gpodder-gtk', self
)
121 self
.toolbar
.set_property('visible', self
.config
.show_toolbar
)
123 self
.bluetooth_available
= util
.bluetooth_available()
125 self
.config
.connect_gtk_window(self
.main_window
, 'main_window')
127 self
.config
.connect_gtk_paned('paned_position', self
.channelPaned
)
129 self
.main_window
.show()
131 self
.player_receiver
= player
.MediaPlayerDBusReceiver(self
.on_played
)
133 self
.gPodder
.connect('key-press-event', self
.on_key_press
)
135 self
.episode_columns_menu
= None
136 self
.config
.add_observer(self
.on_config_changed
)
138 self
.episode_shownotes_window
= None
139 self
.new_episodes_window
= None
141 # Mac OS X-specific UI tweaks: Native main menu integration
142 # http://sourceforge.net/apps/trac/gtk-osx/wiki/Integrate
143 if macapp
is not None:
144 # Move the menu bar from the window to the Mac menu bar
146 macapp
.set_menu_bar(self
.mainMenu
)
148 # Reparent some items to the "Application" menu
149 item
= self
.uimanager1
.get_widget('/mainMenu/menuHelp/itemAbout')
150 macapp
.insert_app_menu_item(item
, 0)
151 macapp
.insert_app_menu_item(gtk
.SeparatorMenuItem(), 1)
152 item
= self
.uimanager1
.get_widget('/mainMenu/menuPodcasts/itemPreferences')
153 macapp
.insert_app_menu_item(item
, 2)
155 quit_item
= self
.uimanager1
.get_widget('/mainMenu/menuPodcasts/itemQuit')
157 # end Mac OS X specific UI tweaks
159 self
.download_status_model
= DownloadStatusModel()
160 self
.download_queue_manager
= download
.DownloadQueueManager(self
.config
)
162 self
.itemShowAllEpisodes
.set_active(self
.config
.podcast_list_view_all
)
163 self
.itemShowToolbar
.set_active(self
.config
.show_toolbar
)
164 self
.itemShowDescription
.set_active(self
.config
.episode_list_descriptions
)
166 self
.config
.connect_gtk_spinbutton('max_downloads', self
.spinMaxDownloads
)
167 self
.config
.connect_gtk_togglebutton('max_downloads_enabled', self
.cbMaxDownloads
)
168 self
.config
.connect_gtk_spinbutton('limit_rate_value', self
.spinLimitDownloads
)
169 self
.config
.connect_gtk_togglebutton('limit_rate', self
.cbLimitDownloads
)
171 self
.config
.connect_gtk_togglebutton('podcast_list_sections', self
.item_podcast_sections
)
173 # When the amount of maximum downloads changes, notify the queue manager
174 changed_cb
= lambda spinbutton
: self
.download_queue_manager
.spawn_threads()
175 self
.spinMaxDownloads
.connect('value-changed', changed_cb
)
177 self
.default_title
= None
178 self
.set_title(_('gPodder'))
180 self
.cover_downloader
= CoverDownloader()
182 # Generate list models for podcasts and their episodes
183 self
.podcast_list_model
= PodcastListModel(self
.cover_downloader
)
185 self
.cover_downloader
.register('cover-available', self
.cover_download_finished
)
187 # Source IDs for timeouts for search-as-you-type
188 self
._podcast
_list
_search
_timeout
= None
189 self
._episode
_list
_search
_timeout
= None
191 # Init the treeviews that we use
192 self
.init_podcast_list_treeview()
193 self
.init_episode_list_treeview()
194 self
.init_download_list_treeview()
196 if self
.config
.podcast_list_hide_boring
:
197 self
.item_view_hide_boring_podcasts
.set_active(True)
199 self
.currently_updating
= False
201 self
.download_tasks_seen
= set()
202 self
.download_list_update_enabled
= False
203 self
.download_task_monitors
= set()
205 # Subscribed channels
206 self
.active_channel
= None
207 self
.channels
= self
.model
.get_podcasts()
209 gpodder
.user_extensions
.on_ui_initialized(self
.model
,
210 self
.extensions_podcast_update_cb
,
211 self
.extensions_episode_download_cb
)
213 # load list of user applications for audio playback
214 self
.user_apps_reader
= UserAppsReader(['audio', 'video'])
215 threading
.Thread(target
=self
.user_apps_reader
.read
).start()
217 # Set up the first instance of MygPoClient
218 self
.mygpo_client
= my
.MygPoClient(self
.config
)
220 # Now, update the feed cache, when everything's in place
221 self
.btnUpdateFeeds
.show()
222 self
.feed_cache_update_cancelled
= False
223 self
.update_podcast_list_model()
225 self
.message_area
= None
227 def find_partial_downloads():
228 # Look for partial file downloads
229 partial_files
= glob
.glob(os
.path
.join(gpodder
.downloads
, '*', '*.partial'))
230 count
= len(partial_files
)
231 resumable_episodes
= []
233 util
.idle_add(self
.wNotebook
.set_current_page
, 1)
234 indicator
= ProgressIndicator(_('Loading incomplete downloads'),
235 _('Some episodes have not finished downloading in a previous session.'),
236 False, self
.get_dialog_parent())
237 indicator
.on_message(N_('%(count)d partial file', '%(count)d partial files', count
) % {'count':count
})
239 candidates
= [f
[:-len('.partial')] for f
in partial_files
]
242 for c
in self
.channels
:
243 for e
in c
.get_all_episodes():
244 filename
= e
.local_filename(create
=False, check_only
=True)
245 if filename
in candidates
:
247 indicator
.on_message(e
.title
)
248 indicator
.on_progress(float(found
)/count
)
249 candidates
.remove(filename
)
250 partial_files
.remove(filename
+'.partial')
252 if os
.path
.exists(filename
):
253 # The file has already been downloaded;
254 # remove the leftover partial file
255 util
.delete_file(filename
+'.partial')
257 resumable_episodes
.append(e
)
265 for f
in partial_files
:
266 logger
.warn('Partial file without episode: %s', f
)
269 util
.idle_add(indicator
.on_finished
)
271 if len(resumable_episodes
):
272 def offer_resuming():
273 self
.download_episode_list_paused(resumable_episodes
)
274 resume_all
= gtk
.Button(_('Resume all'))
275 def on_resume_all(button
):
276 selection
= self
.treeDownloads
.get_selection()
277 selection
.select_all()
278 selected_tasks
, can_queue
, can_cancel
, can_pause
, can_remove
, can_force
= self
.downloads_list_get_selection()
279 selection
.unselect_all()
280 self
._for
_each
_task
_set
_status
(selected_tasks
, download
.DownloadTask
.QUEUED
)
281 self
.message_area
.hide()
282 resume_all
.connect('clicked', on_resume_all
)
284 self
.message_area
= SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all
,))
285 self
.vboxDownloadStatusWidgets
.pack_start(self
.message_area
, expand
=False)
286 self
.vboxDownloadStatusWidgets
.reorder_child(self
.message_area
, 0)
287 self
.message_area
.show_all()
288 self
.clean_up_downloads(delete_partial
=False)
289 util
.idle_add(offer_resuming
)
291 util
.idle_add(self
.wNotebook
.set_current_page
, 0)
293 util
.idle_add(self
.clean_up_downloads
, True)
294 threading
.Thread(target
=find_partial_downloads
).start()
296 # Start the auto-update procedure
297 self
._auto
_update
_timer
_source
_id
= None
298 if self
.config
.auto_update_feeds
:
299 self
.restart_auto_update_timer()
301 # Find expired (old) episodes and delete them
302 old_episodes
= list(self
.get_expired_episodes())
303 if len(old_episodes
) > 0:
304 self
.delete_episode_list(old_episodes
, confirm
=False)
305 updated_urls
= set(e
.channel
.url
for e
in old_episodes
)
306 self
.update_podcast_list_model(updated_urls
)
308 # Do the initial sync with the web service
309 util
.idle_add(self
.mygpo_client
.flush
, True)
311 # First-time users should be asked if they want to see the OPML
312 if not self
.channels
:
313 self
.on_itemUpdate_activate()
314 elif self
.config
.software_update
.check_on_startup
:
315 # Check for software updates from gpodder.org
316 diff
= time
.time() - self
.config
.software_update
.last_check
317 if diff
> (60*60*24)*self
.config
.software_update
.interval
:
318 self
.config
.software_update
.last_check
= int(time
.time())
319 self
.check_for_updates(silent
=True)
321 def episode_object_by_uri(self
, uri
):
322 """Get an episode object given a local or remote URI
324 This can be used to quickly access an episode object
325 when all we have is its download filename or episode
326 URL (e.g. from external D-Bus calls / signals, etc..)
328 if uri
.startswith('/'):
329 uri
= 'file://' + urllib
.quote(uri
)
331 prefix
= 'file://' + urllib
.quote(gpodder
.downloads
)
333 # By default, assume we can't pre-select any channel
334 # but can match episodes simply via the download URL
335 is_channel
= lambda c
: True
336 is_episode
= lambda e
: e
.url
== uri
338 if uri
.startswith(prefix
):
339 # File is on the local filesystem in the download folder
340 # Try to reduce search space by pre-selecting the channel
341 # based on the folder name of the local file
343 filename
= urllib
.unquote(uri
[len(prefix
):])
344 file_parts
= filter(None, filename
.split(os
.sep
))
346 if len(file_parts
) != 2:
349 foldername
, filename
= file_parts
351 is_channel
= lambda c
: c
.download_folder
== foldername
352 is_episode
= lambda e
: e
.download_filename
== filename
354 # Deep search through channels and episodes for a match
355 for channel
in filter(is_channel
, self
.channels
):
356 for episode
in filter(is_episode
, channel
.get_all_episodes()):
361 def on_played(self
, start
, end
, total
, file_uri
):
362 """Handle the "played" signal from a media player"""
363 if start
== 0 and end
== 0 and total
== 0:
364 # Ignore bogus play event
366 elif end
< start
+ 5:
367 # Ignore "less than five seconds" segments,
368 # as they can happen with seeking, etc...
371 logger
.debug('Received play action: %s (%d, %d, %d)', file_uri
, start
, end
, total
)
372 episode
= self
.episode_object_by_uri(file_uri
)
374 if episode
is not None:
375 file_type
= episode
.file_type()
379 episode
.total_time
= total
381 # Assume the episode's total time for the action
382 total
= episode
.total_time
384 assert (episode
.current_position_updated
is None or
385 now
>= episode
.current_position_updated
)
387 episode
.current_position
= end
388 episode
.current_position_updated
= now
389 episode
.mark(is_played
=True)
392 self
.update_episode_list_icons([episode
.url
])
393 self
.update_podcast_list_model([episode
.channel
.url
])
395 # Submit this action to the webservice
396 self
.mygpo_client
.on_playback_full(episode
, start
, end
, total
)
398 def on_add_remove_podcasts_mygpo(self
):
399 actions
= self
.mygpo_client
.get_received_actions()
403 existing_urls
= [c
.url
for c
in self
.channels
]
405 # Columns for the episode selector window - just one...
407 ('description', None, None, _('Action')),
410 # A list of actions that have to be chosen from
413 # Actions that are ignored (already carried out)
416 for action
in actions
:
417 if action
.is_add
and action
.url
not in existing_urls
:
418 changes
.append(my
.Change(action
))
419 elif action
.is_remove
and action
.url
in existing_urls
:
420 podcast_object
= None
421 for podcast
in self
.channels
:
422 if podcast
.url
== action
.url
:
423 podcast_object
= podcast
425 changes
.append(my
.Change(action
, podcast_object
))
427 ignored
.append(action
)
429 # Confirm all ignored changes
430 self
.mygpo_client
.confirm_received_actions(ignored
)
432 def execute_podcast_actions(selected
):
433 add_list
= [c
.action
.url
for c
in selected
if c
.action
.is_add
]
434 remove_list
= [c
.podcast
for c
in selected
if c
.action
.is_remove
]
436 # Apply the accepted changes locally
437 self
.add_podcast_list(add_list
)
438 self
.remove_podcast_list(remove_list
, confirm
=False)
440 # All selected items are now confirmed
441 self
.mygpo_client
.confirm_received_actions(c
.action
for c
in selected
)
443 # Revert the changes on the server
444 rejected
= [c
.action
for c
in changes
if c
not in selected
]
445 self
.mygpo_client
.reject_received_actions(rejected
)
448 # We're abusing the Episode Selector again ;) -- thp
449 gPodderEpisodeSelector(self
.main_window
, \
450 title
=_('Confirm changes from gpodder.net'), \
451 instructions
=_('Select the actions you want to carry out.'), \
454 size_attribute
=None, \
455 stock_ok_button
=gtk
.STOCK_APPLY
, \
456 callback
=execute_podcast_actions
, \
459 # There are some actions that need the user's attention
464 # We have no remaining actions - no selection happens
467 def rewrite_urls_mygpo(self
):
468 # Check if we have to rewrite URLs since the last add
469 rewritten_urls
= self
.mygpo_client
.get_rewritten_urls()
472 for rewritten_url
in rewritten_urls
:
473 if not rewritten_url
.new_url
:
476 for channel
in self
.channels
:
477 if channel
.url
== rewritten_url
.old_url
:
478 logger
.info('Updating URL of %s to %s', channel
,
479 rewritten_url
.new_url
)
480 channel
.url
= rewritten_url
.new_url
486 util
.idle_add(self
.update_episode_list_model
)
488 def on_send_full_subscriptions(self
):
489 # Send the full subscription list to the gpodder.net client
490 # (this will overwrite the subscription list on the server)
491 indicator
= ProgressIndicator(_('Uploading subscriptions'), \
492 _('Your subscriptions are being uploaded to the server.'), \
493 False, self
.get_dialog_parent())
496 self
.mygpo_client
.set_subscriptions([c
.url
for c
in self
.channels
])
497 util
.idle_add(self
.show_message
, _('List uploaded successfully.'))
502 message
= e
.__class
__.__name
__
503 self
.show_message(message
, \
504 _('Error while uploading'), \
506 util
.idle_add(show_error
, e
)
508 util
.idle_add(indicator
.on_finished
)
510 def on_podcast_selected(self
, treeview
, path
, column
):
512 model
= treeview
.get_model()
513 channel
= model
.get_value(model
.get_iter(path
), \
514 PodcastListModel
.C_CHANNEL
)
515 self
.active_channel
= channel
516 self
.update_episode_list_model()
517 self
.episodes_window
.channel
= self
.active_channel
518 self
.episodes_window
.show()
520 def on_button_subscribe_clicked(self
, button
):
521 self
.on_itemImportChannels_activate(button
)
523 def on_button_downloads_clicked(self
, widget
):
524 self
.downloads_window
.show()
526 def for_each_episode_set_task_status(self
, episodes
, status
):
527 episode_urls
= set(episode
.url
for episode
in episodes
)
528 model
= self
.treeDownloads
.get_model()
529 selected_tasks
= [(gtk
.TreeRowReference(model
, row
.path
), \
530 model
.get_value(row
.iter, \
531 DownloadStatusModel
.C_TASK
)) for row
in model \
532 if model
.get_value(row
.iter, DownloadStatusModel
.C_TASK
).url \
534 self
._for
_each
_task
_set
_status
(selected_tasks
, status
)
536 def on_treeview_button_pressed(self
, treeview
, event
):
537 if event
.window
!= treeview
.get_bin_window():
540 role
= getattr(treeview
, TreeViewHelper
.ROLE
)
541 if role
== TreeViewHelper
.ROLE_PODCASTS
:
542 return self
.currently_updating
543 elif (role
== TreeViewHelper
.ROLE_EPISODES
and event
.button
== 1):
544 # Toggle episode "new" status by clicking the icon (bug 1432)
545 result
= treeview
.get_path_at_pos(int(event
.x
), int(event
.y
))
546 if result
is not None:
547 path
, column
, x
, y
= result
548 # The user clicked the icon if she clicked in the first column
549 # and the x position is in the area where the icon resides
550 if (x
< self
.EPISODE_LIST_ICON_WIDTH
and
551 column
== treeview
.get_columns()[0]):
552 model
= treeview
.get_model()
553 cursor_episode
= model
.get_value(model
.get_iter(path
),
554 EpisodeListModel
.C_EPISODE
)
556 new_value
= cursor_episode
.is_new
557 selected_episodes
= self
.get_selected_episodes()
559 # Avoid changing anything if the clicked episode is not
560 # selected already - otherwise update all selected
561 if cursor_episode
in selected_episodes
:
562 for episode
in selected_episodes
:
563 episode
.mark(is_played
=new_value
)
565 self
.update_episode_list_icons(selected
=True)
566 self
.update_podcast_list_model(selected
=True)
569 return event
.button
== 3
571 def on_treeview_podcasts_button_released(self
, treeview
, event
):
572 if event
.window
!= treeview
.get_bin_window():
575 return self
.treeview_channels_show_context_menu(treeview
, event
)
577 def on_treeview_episodes_button_released(self
, treeview
, event
):
578 if event
.window
!= treeview
.get_bin_window():
581 return self
.treeview_available_show_context_menu(treeview
, event
)
583 def on_treeview_downloads_button_released(self
, treeview
, event
):
584 if event
.window
!= treeview
.get_bin_window():
587 return self
.treeview_downloads_show_context_menu(treeview
, event
)
589 def on_entry_search_podcasts_changed(self
, editable
):
590 if self
.hbox_search_podcasts
.get_property('visible'):
591 def set_search_term(self
, text
):
592 self
.podcast_list_model
.set_search_term(text
)
593 self
._podcast
_list
_search
_timeout
= None
596 if self
._podcast
_list
_search
_timeout
is not None:
597 gobject
.source_remove(self
._podcast
_list
_search
_timeout
)
598 self
._podcast
_list
_search
_timeout
= gobject
.timeout_add(
599 self
.config
.ui
.gtk
.live_search_delay
,
600 set_search_term
, self
, editable
.get_chars(0, -1))
602 def on_entry_search_podcasts_key_press(self
, editable
, event
):
603 if event
.keyval
== gtk
.keysyms
.Escape
:
604 self
.hide_podcast_search()
607 def hide_podcast_search(self
, *args
):
608 if self
._podcast
_list
_search
_timeout
is not None:
609 gobject
.source_remove(self
._podcast
_list
_search
_timeout
)
610 self
._podcast
_list
_search
_timeout
= None
611 self
.hbox_search_podcasts
.hide()
612 self
.entry_search_podcasts
.set_text('')
613 self
.podcast_list_model
.set_search_term(None)
614 self
.treeChannels
.grab_focus()
616 def show_podcast_search(self
, input_char
):
617 self
.hbox_search_podcasts
.show()
618 self
.entry_search_podcasts
.insert_text(input_char
, -1)
619 self
.entry_search_podcasts
.grab_focus()
620 self
.entry_search_podcasts
.set_position(-1)
622 def init_podcast_list_treeview(self
):
623 # Set up podcast channel tree view widget
624 column
= gtk
.TreeViewColumn('')
625 iconcell
= gtk
.CellRendererPixbuf()
626 iconcell
.set_property('width', 45)
627 column
.pack_start(iconcell
, False)
628 column
.add_attribute(iconcell
, 'pixbuf', PodcastListModel
.C_COVER
)
629 column
.add_attribute(iconcell
, 'visible', PodcastListModel
.C_COVER_VISIBLE
)
631 namecell
= gtk
.CellRendererText()
632 namecell
.set_property('ellipsize', pango
.ELLIPSIZE_END
)
633 column
.pack_start(namecell
, True)
634 column
.add_attribute(namecell
, 'markup', PodcastListModel
.C_DESCRIPTION
)
636 iconcell
= gtk
.CellRendererPixbuf()
637 iconcell
.set_property('xalign', 1.0)
638 column
.pack_start(iconcell
, False)
639 column
.add_attribute(iconcell
, 'pixbuf', PodcastListModel
.C_PILL
)
640 column
.add_attribute(iconcell
, 'visible', PodcastListModel
.C_PILL_VISIBLE
)
642 self
.treeChannels
.append_column(column
)
644 self
.treeChannels
.set_model(self
.podcast_list_model
.get_filtered_model())
646 # When no podcast is selected, clear the episode list model
647 selection
= self
.treeChannels
.get_selection()
648 def select_function(selection
, model
, path
, path_currently_selected
):
649 url
= model
.get_value(model
.get_iter(path
), PodcastListModel
.C_URL
)
651 selection
.set_select_function(select_function
, full
=True)
653 # Set up type-ahead find for the podcast list
654 def on_key_press(treeview
, event
):
655 if event
.keyval
== gtk
.keysyms
.Right
:
656 self
.treeAvailable
.grab_focus()
657 elif event
.keyval
in (gtk
.keysyms
.Up
, gtk
.keysyms
.Down
):
658 # If section markers exist in the treeview, we want to
659 # "jump over" them when moving the cursor up and down
660 selection
= self
.treeChannels
.get_selection()
661 model
, it
= selection
.get_selected()
663 if event
.keyval
== gtk
.keysyms
.Up
:
668 path
= model
.get_path(it
)
670 path
= (path
[0]+step
,)
673 # Valid paths must have a value >= 0
677 it
= model
.get_iter(path
)
679 # Already at the end of the list
682 if model
.get_value(it
, PodcastListModel
.C_URL
) != '-':
685 self
.treeChannels
.set_cursor(path
)
686 elif event
.keyval
== gtk
.keysyms
.Escape
:
687 self
.hide_podcast_search()
688 elif event
.state
& gtk
.gdk
.CONTROL_MASK
:
689 # Don't handle type-ahead when control is pressed (so shortcuts
690 # with the Ctrl key still work, e.g. Ctrl+A, ...)
693 unicode_char_id
= gtk
.gdk
.keyval_to_unicode(event
.keyval
)
694 if unicode_char_id
== 0:
696 input_char
= unichr(unicode_char_id
)
697 self
.show_podcast_search(input_char
)
699 self
.treeChannels
.connect('key-press-event', on_key_press
)
701 self
.treeChannels
.connect('popup-menu', self
.treeview_channels_show_context_menu
)
703 # Enable separators to the podcast list to separate special podcasts
704 # from others (this is used for the "all episodes" view)
705 self
.treeChannels
.set_row_separator_func(PodcastListModel
.row_separator_func
)
707 TreeViewHelper
.set(self
.treeChannels
, TreeViewHelper
.ROLE_PODCASTS
)
709 def on_entry_search_episodes_changed(self
, editable
):
710 if self
.hbox_search_episodes
.get_property('visible'):
711 def set_search_term(self
, text
):
712 self
.episode_list_model
.set_search_term(text
)
713 self
._episode
_list
_search
_timeout
= None
716 if self
._episode
_list
_search
_timeout
is not None:
717 gobject
.source_remove(self
._episode
_list
_search
_timeout
)
718 self
._episode
_list
_search
_timeout
= gobject
.timeout_add(
719 self
.config
.ui
.gtk
.live_search_delay
,
720 set_search_term
, self
, editable
.get_chars(0, -1))
722 def on_entry_search_episodes_key_press(self
, editable
, event
):
723 if event
.keyval
== gtk
.keysyms
.Escape
:
724 self
.hide_episode_search()
727 def hide_episode_search(self
, *args
):
728 if self
._episode
_list
_search
_timeout
is not None:
729 gobject
.source_remove(self
._episode
_list
_search
_timeout
)
730 self
._episode
_list
_search
_timeout
= None
731 self
.hbox_search_episodes
.hide()
732 self
.entry_search_episodes
.set_text('')
733 self
.episode_list_model
.set_search_term(None)
734 self
.treeAvailable
.grab_focus()
736 def show_episode_search(self
, input_char
):
737 self
.hbox_search_episodes
.show()
738 self
.entry_search_episodes
.insert_text(input_char
, -1)
739 self
.entry_search_episodes
.grab_focus()
740 self
.entry_search_episodes
.set_position(-1)
742 def set_episode_list_column(self
, index
, new_value
):
745 self
.config
.episode_list_columns |
= mask
747 self
.config
.episode_list_columns
&= ~mask
749 def update_episode_list_columns_visibility(self
):
750 columns
= TreeViewHelper
.get_columns(self
.treeAvailable
)
751 for index
, column
in enumerate(columns
):
752 visible
= bool(self
.config
.episode_list_columns
& (1 << index
))
753 column
.set_visible(visible
)
754 self
.treeAvailable
.columns_autosize()
756 if self
.episode_columns_menu
is not None:
757 children
= self
.episode_columns_menu
.get_children()
758 for index
, child
in enumerate(children
):
759 active
= bool(self
.config
.episode_list_columns
& (1 << index
))
760 child
.set_active(active
)
762 def on_episode_list_header_clicked(self
, button
, event
):
763 if event
.button
!= 3:
766 if self
.episode_columns_menu
is not None:
767 self
.episode_columns_menu
.popup(None, None, None, event
.button
, \
772 def init_episode_list_treeview(self
):
773 # For loading the list model
774 self
.episode_list_model
= EpisodeListModel(self
.on_episode_list_filter_changed
)
776 if self
.config
.episode_list_view_mode
== EpisodeListModel
.VIEW_UNDELETED
:
777 self
.item_view_episodes_undeleted
.set_active(True)
778 elif self
.config
.episode_list_view_mode
== EpisodeListModel
.VIEW_DOWNLOADED
:
779 self
.item_view_episodes_downloaded
.set_active(True)
780 elif self
.config
.episode_list_view_mode
== EpisodeListModel
.VIEW_UNPLAYED
:
781 self
.item_view_episodes_unplayed
.set_active(True)
783 self
.item_view_episodes_all
.set_active(True)
785 self
.episode_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
787 self
.treeAvailable
.set_model(self
.episode_list_model
.get_filtered_model())
789 TreeViewHelper
.set(self
.treeAvailable
, TreeViewHelper
.ROLE_EPISODES
)
791 iconcell
= gtk
.CellRendererPixbuf()
792 episode_list_icon_size
= gtk
.icon_size_register('episode-list',
793 EPISODE_LIST_ICON_SIZE
, EPISODE_LIST_ICON_SIZE
)
794 iconcell
.set_property('stock-size', episode_list_icon_size
)
795 iconcell
.set_fixed_size(self
.EPISODE_LIST_ICON_WIDTH
, -1)
797 namecell
= gtk
.CellRendererText()
798 namecell
.set_property('ellipsize', pango
.ELLIPSIZE_END
)
799 namecolumn
= gtk
.TreeViewColumn(_('Episode'))
800 namecolumn
.pack_start(iconcell
, False)
801 namecolumn
.add_attribute(iconcell
, 'icon-name', EpisodeListModel
.C_STATUS_ICON
)
802 namecolumn
.pack_start(namecell
, True)
803 namecolumn
.add_attribute(namecell
, 'markup', EpisodeListModel
.C_DESCRIPTION
)
804 namecolumn
.set_sort_column_id(EpisodeListModel
.C_DESCRIPTION
)
805 namecolumn
.set_sizing(gtk
.TREE_VIEW_COLUMN_AUTOSIZE
)
806 namecolumn
.set_resizable(True)
807 namecolumn
.set_expand(True)
809 lockcell
= gtk
.CellRendererPixbuf()
810 lockcell
.set_fixed_size(40, -1)
811 lockcell
.set_property('stock-size', gtk
.ICON_SIZE_MENU
)
812 lockcell
.set_property('icon-name', 'emblem-readonly')
813 namecolumn
.pack_start(lockcell
, False)
814 namecolumn
.add_attribute(lockcell
, 'visible', EpisodeListModel
.C_LOCKED
)
816 sizecell
= gtk
.CellRendererText()
817 sizecell
.set_property('xalign', 1)
818 sizecolumn
= gtk
.TreeViewColumn(_('Size'), sizecell
, text
=EpisodeListModel
.C_FILESIZE_TEXT
)
819 sizecolumn
.set_sort_column_id(EpisodeListModel
.C_FILESIZE
)
821 timecell
= gtk
.CellRendererText()
822 timecell
.set_property('xalign', 1)
823 timecolumn
= gtk
.TreeViewColumn(_('Duration'), timecell
, text
=EpisodeListModel
.C_TIME
)
824 timecolumn
.set_sort_column_id(EpisodeListModel
.C_TOTAL_TIME
)
826 releasecell
= gtk
.CellRendererText()
827 releasecolumn
= gtk
.TreeViewColumn(_('Released'), releasecell
, text
=EpisodeListModel
.C_PUBLISHED_TEXT
)
828 releasecolumn
.set_sort_column_id(EpisodeListModel
.C_PUBLISHED
)
830 namecolumn
.set_reorderable(True)
831 self
.treeAvailable
.append_column(namecolumn
)
833 for itemcolumn
in (sizecolumn
, timecolumn
, releasecolumn
):
834 itemcolumn
.set_reorderable(True)
835 self
.treeAvailable
.append_column(itemcolumn
)
836 TreeViewHelper
.register_column(self
.treeAvailable
, itemcolumn
)
838 # Add context menu to all tree view column headers
839 for column
in self
.treeAvailable
.get_columns():
840 label
= gtk
.Label(column
.get_title())
842 column
.set_widget(label
)
844 w
= column
.get_widget()
845 while w
is not None and not isinstance(w
, gtk
.Button
):
848 w
.connect('button-release-event', self
.on_episode_list_header_clicked
)
850 # Create a new menu for the visible episode list columns
851 for child
in self
.mainMenu
.get_children():
852 if child
.get_name() == 'menuView':
853 submenu
= child
.get_submenu()
854 item
= gtk
.MenuItem(_('Visible columns'))
855 submenu
.append(gtk
.SeparatorMenuItem())
859 self
.episode_columns_menu
= gtk
.Menu()
860 item
.set_submenu(self
.episode_columns_menu
)
863 # For each column that can be shown/hidden, add a menu item
864 columns
= TreeViewHelper
.get_columns(self
.treeAvailable
)
865 for index
, column
in enumerate(columns
):
866 item
= gtk
.CheckMenuItem(column
.get_title())
867 self
.episode_columns_menu
.append(item
)
868 def on_item_toggled(item
, index
):
869 self
.set_episode_list_column(index
, item
.get_active())
870 item
.connect('toggled', on_item_toggled
, index
)
871 self
.episode_columns_menu
.show_all()
873 # Update the visibility of the columns and the check menu items
874 self
.update_episode_list_columns_visibility()
876 # Set up type-ahead find for the episode list
877 def on_key_press(treeview
, event
):
878 if event
.keyval
== gtk
.keysyms
.Left
:
879 self
.treeChannels
.grab_focus()
880 elif event
.keyval
== gtk
.keysyms
.Escape
:
881 self
.hide_episode_search()
882 elif event
.state
& gtk
.gdk
.CONTROL_MASK
:
883 # Don't handle type-ahead when control is pressed (so shortcuts
884 # with the Ctrl key still work, e.g. Ctrl+A, ...)
887 unicode_char_id
= gtk
.gdk
.keyval_to_unicode(event
.keyval
)
888 if unicode_char_id
== 0:
890 input_char
= unichr(unicode_char_id
)
891 self
.show_episode_search(input_char
)
893 self
.treeAvailable
.connect('key-press-event', on_key_press
)
895 self
.treeAvailable
.connect('popup-menu', self
.treeview_available_show_context_menu
)
897 self
.treeAvailable
.enable_model_drag_source(gtk
.gdk
.BUTTON1_MASK
, \
898 (('text/uri-list', 0, 0),), gtk
.gdk
.ACTION_COPY
)
899 def drag_data_get(tree
, context
, selection_data
, info
, timestamp
):
900 uris
= ['file://'+e
.local_filename(create
=False) \
901 for e
in self
.get_selected_episodes() \
902 if e
.was_downloaded(and_exists
=True)]
903 uris
.append('') # for the trailing '\r\n'
904 selection_data
.set(selection_data
.target
, 8, '\r\n'.join(uris
))
905 self
.treeAvailable
.connect('drag-data-get', drag_data_get
)
907 selection
= self
.treeAvailable
.get_selection()
908 selection
.set_mode(gtk
.SELECTION_MULTIPLE
)
909 # Update the sensitivity of the toolbar buttons on the Desktop
910 selection
.connect('changed', lambda s
: self
.play_or_download())
912 def init_download_list_treeview(self
):
913 # enable multiple selection support
914 self
.treeDownloads
.get_selection().set_mode(gtk
.SELECTION_MULTIPLE
)
915 self
.treeDownloads
.set_search_equal_func(TreeViewHelper
.make_search_equal_func(DownloadStatusModel
))
917 # columns and renderers for "download progress" tab
918 # First column: [ICON] Episodename
919 column
= gtk
.TreeViewColumn(_('Episode'))
921 cell
= gtk
.CellRendererPixbuf()
922 cell
.set_property('stock-size', gtk
.ICON_SIZE_BUTTON
)
923 column
.pack_start(cell
, expand
=False)
924 column
.add_attribute(cell
, 'icon-name', \
925 DownloadStatusModel
.C_ICON_NAME
)
927 cell
= gtk
.CellRendererText()
928 cell
.set_property('ellipsize', pango
.ELLIPSIZE_END
)
929 column
.pack_start(cell
, expand
=True)
930 column
.add_attribute(cell
, 'markup', DownloadStatusModel
.C_NAME
)
931 column
.set_sizing(gtk
.TREE_VIEW_COLUMN_AUTOSIZE
)
932 column
.set_expand(True)
933 self
.treeDownloads
.append_column(column
)
935 # Second column: Progress
936 cell
= gtk
.CellRendererProgress()
937 cell
.set_property('yalign', .5)
938 cell
.set_property('ypad', 6)
939 column
= gtk
.TreeViewColumn(_('Progress'), cell
,
940 value
=DownloadStatusModel
.C_PROGRESS
, \
941 text
=DownloadStatusModel
.C_PROGRESS_TEXT
)
942 column
.set_sizing(gtk
.TREE_VIEW_COLUMN_AUTOSIZE
)
943 column
.set_expand(False)
944 self
.treeDownloads
.append_column(column
)
945 column
.set_property('min-width', 150)
946 column
.set_property('max-width', 150)
948 self
.treeDownloads
.set_model(self
.download_status_model
)
949 TreeViewHelper
.set(self
.treeDownloads
, TreeViewHelper
.ROLE_DOWNLOADS
)
951 self
.treeDownloads
.connect('popup-menu', self
.treeview_downloads_show_context_menu
)
953 def on_treeview_expose_event(self
, treeview
, event
):
954 if event
.window
== treeview
.get_bin_window():
955 model
= treeview
.get_model()
956 if (model
is not None and model
.get_iter_first() is not None):
959 role
= getattr(treeview
, TreeViewHelper
.ROLE
, None)
963 ctx
= event
.window
.cairo_create()
964 ctx
.rectangle(event
.area
.x
, event
.area
.y
,
965 event
.area
.width
, event
.area
.height
)
968 x
, y
, width
, height
, depth
= event
.window
.get_geometry()
971 if role
== TreeViewHelper
.ROLE_EPISODES
:
972 if self
.currently_updating
:
973 text
= _('Loading episodes')
974 elif self
.config
.episode_list_view_mode
!= \
975 EpisodeListModel
.VIEW_ALL
:
976 text
= _('No episodes in current view')
978 text
= _('No episodes available')
979 elif role
== TreeViewHelper
.ROLE_PODCASTS
:
980 if self
.config
.episode_list_view_mode
!= \
981 EpisodeListModel
.VIEW_ALL
and \
982 self
.config
.podcast_list_hide_boring
and \
983 len(self
.channels
) > 0:
984 text
= _('No podcasts in this view')
986 text
= _('No subscriptions')
987 elif role
== TreeViewHelper
.ROLE_DOWNLOADS
:
988 text
= _('No active downloads')
990 raise Exception('on_treeview_expose_event: unknown role')
993 draw_text_box_centered(ctx
, treeview
, width
, height
, text
, font_desc
, progress
)
997 def enable_download_list_update(self
):
998 if not self
.download_list_update_enabled
:
999 self
.update_downloads_list()
1000 gobject
.timeout_add(1500, self
.update_downloads_list
)
1001 self
.download_list_update_enabled
= True
1003 def cleanup_downloads(self
):
1004 model
= self
.download_status_model
1006 all_tasks
= [(gtk
.TreeRowReference(model
, row
.path
), row
[0]) for row
in model
]
1007 changed_episode_urls
= set()
1008 for row_reference
, task
in all_tasks
:
1009 if task
.status
in (task
.DONE
, task
.CANCELLED
):
1010 model
.remove(model
.get_iter(row_reference
.get_path()))
1012 # We don't "see" this task anymore - remove it;
1013 # this is needed, so update_episode_list_icons()
1014 # below gets the correct list of "seen" tasks
1015 self
.download_tasks_seen
.remove(task
)
1016 except KeyError, key_error
:
1018 changed_episode_urls
.add(task
.url
)
1019 # Tell the task that it has been removed (so it can clean up)
1020 task
.removed_from_list()
1022 # Tell the podcasts tab to update icons for our removed podcasts
1023 self
.update_episode_list_icons(changed_episode_urls
)
1025 # Tell the shownotes window that we have removed the episode
1026 if self
.episode_shownotes_window
is not None and \
1027 self
.episode_shownotes_window
.episode
is not None and \
1028 self
.episode_shownotes_window
.episode
.url
in changed_episode_urls
:
1029 self
.episode_shownotes_window
._download
_status
_changed
(None)
1031 # Update the downloads list one more time
1032 self
.update_downloads_list(can_call_cleanup
=False)
1034 def on_tool_downloads_toggled(self
, toolbutton
):
1035 if toolbutton
.get_active():
1036 self
.wNotebook
.set_current_page(1)
1038 self
.wNotebook
.set_current_page(0)
1040 def add_download_task_monitor(self
, monitor
):
1041 self
.download_task_monitors
.add(monitor
)
1042 model
= self
.download_status_model
1046 task
= row
[self
.download_status_model
.C_TASK
]
1047 monitor
.task_updated(task
)
1049 def remove_download_task_monitor(self
, monitor
):
1050 self
.download_task_monitors
.remove(monitor
)
1052 def set_download_progress(self
, progress
):
1053 gpodder
.user_extensions
.on_download_progress(progress
)
1055 def update_downloads_list(self
, can_call_cleanup
=True):
1057 model
= self
.download_status_model
1059 downloading
, failed
, finished
, queued
, paused
, others
= 0, 0, 0, 0, 0, 0
1060 total_speed
, total_size
, done_size
= 0, 0, 0
1062 # Keep a list of all download tasks that we've seen
1063 download_tasks_seen
= set()
1065 # Remember the DownloadTask object for the episode that
1066 # has been opened in the episode shownotes dialog (if any)
1067 if self
.episode_shownotes_window
is not None:
1068 shownotes_episode
= self
.episode_shownotes_window
.episode
1069 shownotes_task
= None
1071 shownotes_episode
= None
1072 shownotes_task
= None
1074 # Do not go through the list of the model is not (yet) available
1079 self
.download_status_model
.request_update(row
.iter)
1081 task
= row
[self
.download_status_model
.C_TASK
]
1082 speed
, size
, status
, progress
= task
.speed
, task
.total_size
, task
.status
, task
.progress
1084 # Let the download task monitors know of changes
1085 for monitor
in self
.download_task_monitors
:
1086 monitor
.task_updated(task
)
1089 done_size
+= size
*progress
1091 if shownotes_episode
is not None and \
1092 shownotes_episode
.url
== task
.episode
.url
:
1093 shownotes_task
= task
1095 download_tasks_seen
.add(task
)
1097 if status
== download
.DownloadTask
.DOWNLOADING
:
1099 total_speed
+= speed
1100 elif status
== download
.DownloadTask
.FAILED
:
1102 elif status
== download
.DownloadTask
.DONE
:
1104 elif status
== download
.DownloadTask
.QUEUED
:
1106 elif status
== download
.DownloadTask
.PAUSED
:
1111 # Remember which tasks we have seen after this run
1112 self
.download_tasks_seen
= download_tasks_seen
1114 text
= [_('Downloads')]
1115 if downloading
+ failed
+ queued
> 0:
1118 s
.append(N_('%(count)d active', '%(count)d active', downloading
) % {'count':downloading
})
1120 s
.append(N_('%(count)d failed', '%(count)d failed', failed
) % {'count':failed
})
1122 s
.append(N_('%(count)d queued', '%(count)d queued', queued
) % {'count':queued
})
1123 text
.append(' (' + ', '.join(s
)+')')
1124 self
.labelDownloads
.set_text(''.join(text
))
1126 title
= [self
.default_title
]
1128 # Accessing task.status_changed has the side effect of re-setting
1129 # the changed flag, but we only do it once here so that's okay
1130 channel_urls
= [task
.podcast_url
for task
in
1131 self
.download_tasks_seen
if task
.status_changed
]
1132 episode_urls
= [task
.url
for task
in self
.download_tasks_seen
]
1134 count
= downloading
+ queued
1136 title
.append(N_('downloading %(count)d file', 'downloading %(count)d files', count
) % {'count':count
})
1139 percentage
= 100.0*done_size
/total_size
1142 self
.set_download_progress(percentage
/100.)
1143 total_speed
= util
.format_filesize(total_speed
)
1144 title
[1] += ' (%d%%, %s/s)' % (percentage
, total_speed
)
1146 self
.set_download_progress(1.)
1147 self
.downloads_finished(self
.download_tasks_seen
)
1148 gpodder
.user_extensions
.on_all_episodes_downloaded()
1149 logger
.info('All downloads have finished.')
1151 # Remove finished episodes
1152 if self
.config
.auto_cleanup_downloads
and can_call_cleanup
:
1153 self
.cleanup_downloads()
1155 # Stop updating the download list here
1156 self
.download_list_update_enabled
= False
1158 self
.gPodder
.set_title(' - '.join(title
))
1160 self
.update_episode_list_icons(episode_urls
)
1161 if self
.episode_shownotes_window
is not None:
1162 if (shownotes_task
and shownotes_task
.url
in episode_urls
) or \
1163 shownotes_task
!= self
.episode_shownotes_window
.task
:
1164 self
.episode_shownotes_window
._download
_status
_changed
(shownotes_task
)
1165 self
.episode_shownotes_window
._download
_status
_progress
()
1166 self
.play_or_download()
1168 self
.update_podcast_list_model(channel_urls
)
1170 return self
.download_list_update_enabled
1171 except Exception, e
:
1172 logger
.error('Exception happened while updating download list.', exc_info
=True)
1173 self
.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e
)), _('Unhandled exception'), important
=True)
1174 # We return False here, so the update loop won't be called again,
1175 # that's why we require the restart of gPodder in the message.
1178 def on_config_changed(self
, *args
):
1179 util
.idle_add(self
._on
_config
_changed
, *args
)
1181 def _on_config_changed(self
, name
, old_value
, new_value
):
1182 if name
== 'ui.gtk.toolbar':
1183 self
.toolbar
.set_property('visible', new_value
)
1184 elif name
== 'ui.gtk.episode_list.descriptions':
1185 self
.update_episode_list_model()
1186 elif name
in ('auto.update.enabled', 'auto.update.frequency'):
1187 self
.restart_auto_update_timer()
1188 elif name
in ('ui.gtk.podcast_list.all_episodes',
1189 'ui.gtk.podcast_list.sections'):
1190 # Force a update of the podcast list model
1191 self
.update_podcast_list_model()
1192 elif name
== 'ui.gtk.episode_list.columns':
1193 self
.update_episode_list_columns_visibility()
1195 def on_treeview_query_tooltip(self
, treeview
, x
, y
, keyboard_tooltip
, tooltip
):
1196 # With get_bin_window, we get the window that contains the rows without
1197 # the header. The Y coordinate of this window will be the height of the
1198 # treeview header. This is the amount we have to subtract from the
1199 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1200 (x_bin
, y_bin
) = treeview
.get_bin_window().get_position()
1203 (path
, column
, rx
, ry
) = treeview
.get_path_at_pos( x
, y
) or (None,)*4
1205 if not getattr(treeview
, TreeViewHelper
.CAN_TOOLTIP
) or x
> 50 or (column
is not None and column
!= treeview
.get_columns()[0]):
1206 setattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
, None)
1209 if path
is not None:
1210 model
= treeview
.get_model()
1211 iter = model
.get_iter(path
)
1212 role
= getattr(treeview
, TreeViewHelper
.ROLE
)
1214 if role
== TreeViewHelper
.ROLE_EPISODES
:
1215 id = model
.get_value(iter, EpisodeListModel
.C_URL
)
1216 elif role
== TreeViewHelper
.ROLE_PODCASTS
:
1217 id = model
.get_value(iter, PodcastListModel
.C_URL
)
1219 # Section header - no tooltip here (for now at least)
1222 last_tooltip
= getattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
)
1223 if last_tooltip
is not None and last_tooltip
!= id:
1224 setattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
, None)
1226 setattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
, id)
1228 if role
== TreeViewHelper
.ROLE_EPISODES
:
1229 description
= model
.get_value(iter, EpisodeListModel
.C_TOOLTIP
)
1231 tooltip
.set_text(description
)
1234 elif role
== TreeViewHelper
.ROLE_PODCASTS
:
1235 channel
= model
.get_value(iter, PodcastListModel
.C_CHANNEL
)
1236 if channel
is None or not hasattr(channel
, 'title'):
1238 error_str
= model
.get_value(iter, PodcastListModel
.C_ERROR
)
1240 error_str
= _('Feedparser error: %s') % cgi
.escape(error_str
.strip())
1241 error_str
= '<span foreground="#ff0000">%s</span>' % error_str
1242 table
= gtk
.Table(rows
=3, columns
=3)
1243 table
.set_row_spacings(5)
1244 table
.set_col_spacings(5)
1245 table
.set_border_width(5)
1247 heading
= gtk
.Label()
1248 heading
.set_alignment(0, 1)
1249 heading
.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (cgi
.escape(channel
.title
), cgi
.escape(channel
.url
)))
1250 table
.attach(heading
, 0, 1, 0, 1)
1252 table
.attach(gtk
.HSeparator(), 0, 3, 1, 2)
1254 if len(channel
.description
) < 500:
1255 description
= channel
.description
1257 pos
= channel
.description
.find('\n\n')
1258 if pos
== -1 or pos
> 500:
1259 description
= channel
.description
[:498]+'[...]'
1261 description
= channel
.description
[:pos
]
1263 description
= gtk
.Label(description
)
1265 description
.set_markup(error_str
)
1266 description
.set_alignment(0, 0)
1267 description
.set_line_wrap(True)
1268 table
.attach(description
, 0, 3, 2, 3)
1271 tooltip
.set_custom(table
)
1275 setattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
, None)
1278 def treeview_allow_tooltips(self
, treeview
, allow
):
1279 setattr(treeview
, TreeViewHelper
.CAN_TOOLTIP
, allow
)
1281 def treeview_handle_context_menu_click(self
, treeview
, event
):
1283 selection
= treeview
.get_selection()
1284 return selection
.get_selected_rows()
1286 x
, y
= int(event
.x
), int(event
.y
)
1287 path
, column
, rx
, ry
= treeview
.get_path_at_pos(x
, y
) or (None,)*4
1289 selection
= treeview
.get_selection()
1290 model
, paths
= selection
.get_selected_rows()
1292 if path
is None or (path
not in paths
and \
1294 # We have right-clicked, but not into the selection,
1295 # assume we don't want to operate on the selection
1298 if path
is not None and not paths
and \
1300 # No selection or clicked outside selection;
1301 # select the single item where we clicked
1302 treeview
.grab_focus()
1303 treeview
.set_cursor(path
, column
, 0)
1307 # Unselect any remaining items (clicked elsewhere)
1308 if hasattr(treeview
, 'is_rubber_banding_active'):
1309 if not treeview
.is_rubber_banding_active():
1310 selection
.unselect_all()
1312 selection
.unselect_all()
1316 def downloads_list_get_selection(self
, model
=None, paths
=None):
1317 if model
is None and paths
is None:
1318 selection
= self
.treeDownloads
.get_selection()
1319 model
, paths
= selection
.get_selected_rows()
1321 can_queue
, can_cancel
, can_pause
, can_remove
, can_force
= (True,)*5
1322 selected_tasks
= [(gtk
.TreeRowReference(model
, path
), \
1323 model
.get_value(model
.get_iter(path
), \
1324 DownloadStatusModel
.C_TASK
)) for path
in paths
]
1326 for row_reference
, task
in selected_tasks
:
1327 if task
.status
!= download
.DownloadTask
.QUEUED
:
1329 if task
.status
not in (download
.DownloadTask
.PAUSED
, \
1330 download
.DownloadTask
.FAILED
, \
1331 download
.DownloadTask
.CANCELLED
):
1333 if task
.status
not in (download
.DownloadTask
.PAUSED
, \
1334 download
.DownloadTask
.QUEUED
, \
1335 download
.DownloadTask
.DOWNLOADING
, \
1336 download
.DownloadTask
.FAILED
):
1338 if task
.status
not in (download
.DownloadTask
.QUEUED
, \
1339 download
.DownloadTask
.DOWNLOADING
):
1341 if task
.status
not in (download
.DownloadTask
.CANCELLED
, \
1342 download
.DownloadTask
.FAILED
, \
1343 download
.DownloadTask
.DONE
):
1346 return selected_tasks
, can_queue
, can_cancel
, can_pause
, can_remove
, can_force
1348 def downloads_finished(self
, download_tasks_seen
):
1349 finished_downloads
= [str(task
) for task
in download_tasks_seen
if task
.notify_as_finished()]
1350 failed_downloads
= [str(task
)+' ('+task
.error_message
+')' for task
in download_tasks_seen
if task
.notify_as_failed()]
1352 if finished_downloads
and failed_downloads
:
1353 message
= self
.format_episode_list(finished_downloads
, 5)
1354 message
+= '\n\n<i>%s</i>\n' % _('These downloads failed:')
1355 message
+= self
.format_episode_list(failed_downloads
, 5)
1356 self
.show_message(message
, _('Downloads finished'), True, widget
=self
.labelDownloads
)
1357 elif finished_downloads
:
1358 message
= self
.format_episode_list(finished_downloads
)
1359 self
.show_message(message
, _('Downloads finished'), widget
=self
.labelDownloads
)
1360 elif failed_downloads
:
1361 message
= self
.format_episode_list(failed_downloads
)
1362 self
.show_message(message
, _('Downloads failed'), True, widget
=self
.labelDownloads
)
1365 def format_episode_list(self
, episode_list
, max_episodes
=10):
1367 Format a list of episode names for notifications
1369 Will truncate long episode names and limit the amount of
1370 episodes displayed (max_episodes=10).
1372 The episode_list parameter should be a list of strings.
1374 MAX_TITLE_LENGTH
= 100
1377 for title
in episode_list
[:min(len(episode_list
), max_episodes
)]:
1378 if len(title
) > MAX_TITLE_LENGTH
:
1379 middle
= (MAX_TITLE_LENGTH
/2)-2
1380 title
= '%s...%s' % (title
[0:middle
], title
[-middle
:])
1381 result
.append(cgi
.escape(title
))
1384 more_episodes
= len(episode_list
) - max_episodes
1385 if more_episodes
> 0:
1386 result
.append('(...')
1387 result
.append(N_('%(count)d more episode', '%(count)d more episodes', more_episodes
) % {'count':more_episodes
})
1388 result
.append('...)')
1390 return (''.join(result
)).strip()
1392 def _for_each_task_set_status(self
, tasks
, status
, force_start
=False):
1393 episode_urls
= set()
1394 model
= self
.treeDownloads
.get_model()
1395 for row_reference
, task
in tasks
:
1396 if status
== download
.DownloadTask
.QUEUED
:
1397 # Only queue task when its paused/failed/cancelled (or forced)
1398 if task
.status
in (task
.PAUSED
, task
.FAILED
, task
.CANCELLED
) or force_start
:
1399 self
.download_queue_manager
.add_task(task
, force_start
)
1400 self
.enable_download_list_update()
1401 elif status
== download
.DownloadTask
.CANCELLED
:
1402 # Cancelling a download allowed when downloading/queued
1403 if task
.status
in (task
.QUEUED
, task
.DOWNLOADING
):
1404 task
.status
= status
1405 # Cancelling paused/failed downloads requires a call to .run()
1406 elif task
.status
in (task
.PAUSED
, task
.FAILED
):
1407 task
.status
= status
1408 # Call run, so the partial file gets deleted
1410 elif status
== download
.DownloadTask
.PAUSED
:
1411 # Pausing a download only when queued/downloading
1412 if task
.status
in (task
.DOWNLOADING
, task
.QUEUED
):
1413 task
.status
= status
1414 elif status
is None:
1415 # Remove the selected task - cancel downloading/queued tasks
1416 if task
.status
in (task
.QUEUED
, task
.DOWNLOADING
):
1417 task
.status
= task
.CANCELLED
1418 model
.remove(model
.get_iter(row_reference
.get_path()))
1419 # Remember the URL, so we can tell the UI to update
1421 # We don't "see" this task anymore - remove it;
1422 # this is needed, so update_episode_list_icons()
1423 # below gets the correct list of "seen" tasks
1424 self
.download_tasks_seen
.remove(task
)
1425 except KeyError, key_error
:
1427 episode_urls
.add(task
.url
)
1428 # Tell the task that it has been removed (so it can clean up)
1429 task
.removed_from_list()
1431 # We can (hopefully) simply set the task status here
1432 task
.status
= status
1433 # Tell the podcasts tab to update icons for our removed podcasts
1434 self
.update_episode_list_icons(episode_urls
)
1435 # Update the tab title and downloads list
1436 self
.update_downloads_list()
1438 def treeview_downloads_show_context_menu(self
, treeview
, event
=None):
1439 model
, paths
= self
.treeview_handle_context_menu_click(treeview
, event
)
1441 if not hasattr(treeview
, 'is_rubber_banding_active'):
1444 return not treeview
.is_rubber_banding_active()
1446 if event
is None or event
.button
== 3:
1447 selected_tasks
, can_queue
, can_cancel
, can_pause
, can_remove
, can_force
= \
1448 self
.downloads_list_get_selection(model
, paths
)
1450 def make_menu_item(label
, stock_id
, tasks
, status
, sensitive
, force_start
=False):
1451 # This creates a menu item for selection-wide actions
1452 item
= gtk
.ImageMenuItem(label
)
1453 item
.set_image(gtk
.image_new_from_stock(stock_id
, gtk
.ICON_SIZE_MENU
))
1454 item
.connect('activate', lambda item
: self
._for
_each
_task
_set
_status
(tasks
, status
, force_start
))
1455 item
.set_sensitive(sensitive
)
1460 item
= gtk
.ImageMenuItem(_('Episode details'))
1461 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_INFO
, gtk
.ICON_SIZE_MENU
))
1462 if len(selected_tasks
) == 1:
1463 row_reference
, task
= selected_tasks
[0]
1464 episode
= task
.episode
1465 item
.connect('activate', lambda item
: self
.show_episode_shownotes(episode
))
1467 item
.set_sensitive(False)
1469 menu
.append(gtk
.SeparatorMenuItem())
1471 menu
.append(make_menu_item(_('Start download now'), gtk
.STOCK_GO_DOWN
, selected_tasks
, download
.DownloadTask
.QUEUED
, True, True))
1473 menu
.append(make_menu_item(_('Download'), gtk
.STOCK_GO_DOWN
, selected_tasks
, download
.DownloadTask
.QUEUED
, can_queue
, False))
1474 menu
.append(make_menu_item(_('Cancel'), gtk
.STOCK_CANCEL
, selected_tasks
, download
.DownloadTask
.CANCELLED
, can_cancel
))
1475 menu
.append(make_menu_item(_('Pause'), gtk
.STOCK_MEDIA_PAUSE
, selected_tasks
, download
.DownloadTask
.PAUSED
, can_pause
))
1476 menu
.append(gtk
.SeparatorMenuItem())
1477 menu
.append(make_menu_item(_('Remove from list'), gtk
.STOCK_REMOVE
, selected_tasks
, None, can_remove
))
1482 func
= TreeViewHelper
.make_popup_position_func(treeview
)
1483 menu
.popup(None, None, func
, 3, 0)
1485 menu
.popup(None, None, None, event
.button
, event
.time
)
1488 def on_mark_episodes_as_old(self
, item
):
1489 assert self
.active_channel
is not None
1491 for episode
in self
.active_channel
.get_all_episodes():
1492 if not episode
.was_downloaded(and_exists
=True):
1493 episode
.mark(is_played
=True)
1495 self
.update_podcast_list_model(selected
=True)
1496 self
.update_episode_list_icons(all
=True)
1498 def on_open_download_folder(self
, item
):
1499 assert self
.active_channel
is not None
1500 util
.gui_open(self
.active_channel
.save_dir
)
1502 def treeview_channels_show_context_menu(self
, treeview
, event
=None):
1503 model
, paths
= self
.treeview_handle_context_menu_click(treeview
, event
)
1507 # Check for valid channel id, if there's no id then
1508 # assume that it is a proxy channel or equivalent
1509 # and cannot be operated with right click
1510 if self
.active_channel
.id is None:
1513 if event
is None or event
.button
== 3:
1516 item
= gtk
.ImageMenuItem( _('Update podcast'))
1517 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_REFRESH
, gtk
.ICON_SIZE_MENU
))
1518 item
.connect('activate', self
.on_itemUpdateChannel_activate
)
1521 menu
.append(gtk
.SeparatorMenuItem())
1523 item
= gtk
.MenuItem(_('Open download folder'))
1524 item
.connect('activate', self
.on_open_download_folder
)
1527 menu
.append(gtk
.SeparatorMenuItem())
1529 item
= gtk
.MenuItem(_('Mark episodes as old'))
1530 item
.connect('activate', self
.on_mark_episodes_as_old
)
1533 item
= gtk
.CheckMenuItem(_('Archive'))
1534 item
.set_active(self
.active_channel
.auto_archive_episodes
)
1535 item
.connect('activate', self
.on_channel_toggle_lock_activate
)
1538 item
= gtk
.ImageMenuItem(_('Remove podcast'))
1539 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_DELETE
, gtk
.ICON_SIZE_MENU
))
1540 item
.connect( 'activate', self
.on_itemRemoveChannel_activate
)
1543 menu
.append( gtk
.SeparatorMenuItem())
1545 item
= gtk
.ImageMenuItem(_('Podcast settings'))
1546 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_INFO
, gtk
.ICON_SIZE_MENU
))
1547 item
.connect('activate', self
.on_itemEditChannel_activate
)
1551 # Disable tooltips while we are showing the menu, so
1552 # the tooltip will not appear over the menu
1553 self
.treeview_allow_tooltips(self
.treeChannels
, False)
1554 menu
.connect('deactivate', lambda menushell
: self
.treeview_allow_tooltips(self
.treeChannels
, True))
1557 func
= TreeViewHelper
.make_popup_position_func(treeview
)
1558 menu
.popup(None, None, func
, 3, 0)
1560 menu
.popup(None, None, None, event
.button
, event
.time
)
1564 def cover_download_finished(self
, channel
, pixbuf
):
1566 The Cover Downloader calls this when it has finished
1567 downloading (or registering, if already downloaded)
1568 a new channel cover, which is ready for displaying.
1570 util
.idle_add(self
.podcast_list_model
.add_cover_by_channel
,
1573 def save_episodes_as_file(self
, episodes
):
1574 for episode
in episodes
:
1575 self
.save_episode_as_file(episode
)
1577 def save_episode_as_file(self
, episode
):
1578 PRIVATE_FOLDER_ATTRIBUTE
= '_save_episodes_as_file_folder'
1579 if episode
.was_downloaded(and_exists
=True):
1580 folder
= getattr(self
, PRIVATE_FOLDER_ATTRIBUTE
, None)
1581 copy_from
= episode
.local_filename(create
=False)
1582 assert copy_from
is not None
1583 copy_to
= util
.sanitize_filename(episode
.sync_filename())
1584 (result
, folder
) = self
.show_copy_dialog(src_filename
=copy_from
, dst_filename
=copy_to
, dst_directory
=folder
)
1585 setattr(self
, PRIVATE_FOLDER_ATTRIBUTE
, folder
)
1587 def copy_episodes_bluetooth(self
, episodes
):
1588 episodes_to_copy
= [e
for e
in episodes
if e
.was_downloaded(and_exists
=True)]
1590 def convert_and_send_thread(episode
):
1591 for episode
in episodes
:
1592 filename
= episode
.local_filename(create
=False)
1593 assert filename
is not None
1594 destfile
= os
.path
.join(tempfile
.gettempdir(), \
1595 util
.sanitize_filename(episode
.sync_filename()))
1596 (base
, ext
) = os
.path
.splitext(filename
)
1597 if not destfile
.endswith(ext
):
1601 shutil
.copyfile(filename
, destfile
)
1602 util
.bluetooth_send_file(destfile
)
1604 logger
.error('Cannot copy "%s" to "%s".', filename
, destfile
)
1605 self
.notification(_('Error converting file.'), _('Bluetooth file transfer'), important
=True)
1607 util
.delete_file(destfile
)
1609 threading
.Thread(target
=convert_and_send_thread
, args
=[episodes_to_copy
]).start()
1611 def treeview_available_show_context_menu(self
, treeview
, event
=None):
1612 model
, paths
= self
.treeview_handle_context_menu_click(treeview
, event
)
1614 if not hasattr(treeview
, 'is_rubber_banding_active'):
1617 return not treeview
.is_rubber_banding_active()
1619 if event
is None or event
.button
== 3:
1620 episodes
= self
.get_selected_episodes()
1621 any_locked
= any(e
.archive
for e
in episodes
)
1622 any_new
= any(e
.is_new
for e
in episodes
)
1623 downloaded
= all(e
.was_downloaded(and_exists
=True) for e
in episodes
)
1624 downloading
= any(e
.downloading
for e
in episodes
)
1628 (can_play
, can_download
, can_cancel
, can_delete
, open_instead_of_play
) = self
.play_or_download()
1630 if open_instead_of_play
:
1631 item
= gtk
.ImageMenuItem(gtk
.STOCK_OPEN
)
1633 item
= gtk
.ImageMenuItem(gtk
.STOCK_MEDIA_PLAY
)
1636 item
= gtk
.ImageMenuItem(_('Preview'))
1638 item
= gtk
.ImageMenuItem(_('Stream'))
1639 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_MEDIA_PLAY
, gtk
.ICON_SIZE_MENU
))
1641 item
.set_sensitive(can_play
)
1642 item
.connect('activate', self
.on_playback_selected_episodes
)
1646 item
= gtk
.ImageMenuItem(_('Download'))
1647 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_GO_DOWN
, gtk
.ICON_SIZE_MENU
))
1648 item
.set_sensitive(can_download
)
1649 item
.connect('activate', self
.on_download_selected_episodes
)
1652 item
= gtk
.ImageMenuItem(gtk
.STOCK_CANCEL
)
1653 item
.connect('activate', self
.on_item_cancel_download_activate
)
1656 item
= gtk
.ImageMenuItem(gtk
.STOCK_DELETE
)
1657 item
.set_sensitive(can_delete
)
1658 item
.connect('activate', self
.on_btnDownloadedDelete_clicked
)
1661 result
= gpodder
.user_extensions
.on_episodes_context_menu(episodes
)
1663 menu
.append(gtk
.SeparatorMenuItem())
1664 for label
, callback
in result
:
1665 item
= gtk
.MenuItem(label
)
1666 item
.connect('activate', lambda item
, callback
:
1667 callback(episodes
), callback
)
1670 # Ok, this probably makes sense to only display for downloaded files
1672 menu
.append(gtk
.SeparatorMenuItem())
1673 share_item
= gtk
.MenuItem(_('Send to'))
1674 menu
.append(share_item
)
1675 share_menu
= gtk
.Menu()
1677 item
= gtk
.ImageMenuItem(_('Local folder'))
1678 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_DIRECTORY
, gtk
.ICON_SIZE_MENU
))
1679 item
.connect('button-press-event', lambda w
, ee
: self
.save_episodes_as_file(episodes
))
1680 share_menu
.append(item
)
1681 if self
.bluetooth_available
:
1682 item
= gtk
.ImageMenuItem(_('Bluetooth device'))
1683 item
.set_image(gtk
.image_new_from_icon_name('bluetooth', gtk
.ICON_SIZE_MENU
))
1684 item
.connect('button-press-event', lambda w
, ee
: self
.copy_episodes_bluetooth(episodes
))
1685 share_menu
.append(item
)
1687 share_item
.set_submenu(share_menu
)
1689 menu
.append(gtk
.SeparatorMenuItem())
1691 item
= gtk
.CheckMenuItem(_('New'))
1692 item
.set_active(any_new
)
1694 item
.connect('activate', lambda w
: self
.mark_selected_episodes_old())
1696 item
.connect('activate', lambda w
: self
.mark_selected_episodes_new())
1700 item
= gtk
.CheckMenuItem(_('Archive'))
1701 item
.set_active(any_locked
)
1702 item
.connect('activate', lambda w
: self
.on_item_toggle_lock_activate( w
, False, not any_locked
))
1705 menu
.append(gtk
.SeparatorMenuItem())
1706 # Single item, add episode information menu item
1707 item
= gtk
.ImageMenuItem(_('Episode details'))
1708 item
.set_image(gtk
.image_new_from_stock( gtk
.STOCK_INFO
, gtk
.ICON_SIZE_MENU
))
1709 item
.connect('activate', lambda w
: self
.show_episode_shownotes(episodes
[0]))
1713 # Disable tooltips while we are showing the menu, so
1714 # the tooltip will not appear over the menu
1715 self
.treeview_allow_tooltips(self
.treeAvailable
, False)
1716 menu
.connect('deactivate', lambda menushell
: self
.treeview_allow_tooltips(self
.treeAvailable
, True))
1718 func
= TreeViewHelper
.make_popup_position_func(treeview
)
1719 menu
.popup(None, None, func
, 3, 0)
1721 menu
.popup(None, None, None, event
.button
, event
.time
)
1725 def set_title(self
, new_title
):
1726 self
.default_title
= new_title
1727 self
.gPodder
.set_title(new_title
)
1729 def update_episode_list_icons(self
, urls
=None, selected
=False, all
=False):
1731 Updates the status icons in the episode list.
1733 If urls is given, it should be a list of URLs
1734 of episodes that should be updated.
1736 If urls is None, set ONE OF selected, all to
1737 True (the former updates just the selected
1738 episodes and the latter updates all episodes).
1740 descriptions
= self
.config
.episode_list_descriptions
1742 if urls
is not None:
1743 # We have a list of URLs to walk through
1744 self
.episode_list_model
.update_by_urls(urls
, descriptions
)
1745 elif selected
and not all
:
1746 # We should update all selected episodes
1747 selection
= self
.treeAvailable
.get_selection()
1748 model
, paths
= selection
.get_selected_rows()
1749 for path
in reversed(paths
):
1750 iter = model
.get_iter(path
)
1751 self
.episode_list_model
.update_by_filter_iter(iter, descriptions
)
1752 elif all
and not selected
:
1753 # We update all (even the filter-hidden) episodes
1754 self
.episode_list_model
.update_all(descriptions
)
1756 # Wrong/invalid call - have to specify at least one parameter
1757 raise ValueError('Invalid call to update_episode_list_icons')
1759 def episode_list_status_changed(self
, episodes
):
1760 self
.update_episode_list_icons(set(e
.url
for e
in episodes
))
1761 self
.update_podcast_list_model(set(e
.channel
.url
for e
in episodes
))
1764 def clean_up_downloads(self
, delete_partial
=False):
1765 # Clean up temporary files left behind by old gPodder versions
1766 temporary_files
= glob
.glob('%s/*/.tmp-*' % gpodder
.downloads
)
1769 temporary_files
+= glob
.glob('%s/*/*.partial' % gpodder
.downloads
)
1771 for tempfile
in temporary_files
:
1772 util
.delete_file(tempfile
)
1775 def streaming_possible(self
):
1776 # User has to have a media player set on the Desktop, or else we
1777 # would probably open the browser when giving a URL to xdg-open..
1778 return (self
.config
.player
and self
.config
.player
!= 'default')
1780 def playback_episodes_for_real(self
, episodes
):
1781 groups
= collections
.defaultdict(list)
1782 for episode
in episodes
:
1783 file_type
= episode
.file_type()
1784 if file_type
== 'video' and self
.config
.videoplayer
and \
1785 self
.config
.videoplayer
!= 'default':
1786 player
= self
.config
.videoplayer
1787 elif file_type
== 'audio' and self
.config
.player
and \
1788 self
.config
.player
!= 'default':
1789 player
= self
.config
.player
1793 # Mark episode as played in the database
1794 episode
.playback_mark()
1795 self
.mygpo_client
.on_playback([episode
])
1797 fmt_id
= self
.config
.youtube_preferred_fmt_id
1798 allow_partial
= (player
!= 'default')
1799 filename
= episode
.get_playback_url(fmt_id
, allow_partial
)
1801 # Determine the playback resume position - if the file
1802 # was played 100%, we simply start from the beginning
1803 resume_position
= episode
.current_position
1804 if resume_position
== episode
.total_time
:
1807 # If Panucci is configured, use D-Bus on Maemo to call it
1808 if player
== 'panucci':
1810 PANUCCI_NAME
= 'org.panucci.panucciInterface'
1811 PANUCCI_PATH
= '/panucciInterface'
1812 PANUCCI_INTF
= 'org.panucci.panucciInterface'
1813 o
= gpodder
.dbus_session_bus
.get_object(PANUCCI_NAME
, PANUCCI_PATH
)
1814 i
= dbus
.Interface(o
, PANUCCI_INTF
)
1816 def on_reply(*args
):
1819 def error_handler(filename
, err
):
1820 logger
.error('Exception in D-Bus call: %s', str(err
))
1822 # Fallback: use the command line client
1823 for command
in util
.format_desktop_command('panucci', \
1825 logger
.info('Executing: %s', repr(command
))
1826 subprocess
.Popen(command
)
1828 on_error
= lambda err
: error_handler(filename
, err
)
1830 # This method only exists in Panucci > 0.9 ('new Panucci')
1831 i
.playback_from(filename
, resume_position
, \
1832 reply_handler
=on_reply
, error_handler
=on_error
)
1834 continue # This file was handled by the D-Bus call
1835 except Exception, e
:
1836 logger
.error('Calling Panucci using D-Bus', exc_info
=True)
1838 groups
[player
].append(filename
)
1840 # Open episodes with system default player
1841 if 'default' in groups
:
1842 # Special-casing for a single episode when the object is a PDF
1843 # file - this is needed on Maemo 5, so we only use gui_open()
1844 # for single PDF files, but still use the built-in media player
1845 # with an M3U file for single audio/video files. (The Maemo 5
1846 # media player behaves differently when opening a single-file
1847 # M3U playlist compared to opening the single file directly.)
1848 if len(groups
['default']) == 1:
1849 fn
= groups
['default'][0]
1850 # The list of extensions is taken from gui_open in util.py
1851 # where all special-cases of Maemo apps are listed
1852 for extension
in ('.pdf', '.jpg', '.jpeg', '.png'):
1853 if fn
.lower().endswith(extension
):
1855 groups
['default'] = []
1858 for filename
in groups
['default']:
1859 logger
.debug('Opening with system default: %s', filename
)
1860 util
.gui_open(filename
)
1861 del groups
['default']
1863 # For each type now, go and create play commands
1864 for group
in groups
:
1865 for command
in util
.format_desktop_command(group
, groups
[group
], resume_position
):
1866 logger
.debug('Executing: %s', repr(command
))
1867 subprocess
.Popen(command
)
1869 # Persist episode status changes to the database
1872 # Flush updated episode status
1873 self
.mygpo_client
.flush()
1875 def playback_episodes(self
, episodes
):
1876 # We need to create a list, because we run through it more than once
1877 episodes
= list(Model
.sort_episodes_by_pubdate(e
for e
in episodes
if \
1878 e
.was_downloaded(and_exists
=True) or self
.streaming_possible()))
1881 self
.playback_episodes_for_real(episodes
)
1882 except Exception, e
:
1883 logger
.error('Error in playback!', exc_info
=True)
1884 self
.show_message(_('Please check your media player settings in the preferences dialog.'), \
1885 _('Error opening player'), widget
=self
.toolPreferences
)
1887 channel_urls
= set()
1888 episode_urls
= set()
1889 for episode
in episodes
:
1890 channel_urls
.add(episode
.channel
.url
)
1891 episode_urls
.add(episode
.url
)
1892 self
.update_episode_list_icons(episode_urls
)
1893 self
.update_podcast_list_model(channel_urls
)
1895 def play_or_download(self
):
1896 if self
.wNotebook
.get_current_page() > 0:
1897 self
.toolCancel
.set_sensitive(True)
1900 if self
.currently_updating
:
1901 return (False, False, False, False, False, False)
1903 ( can_play
, can_download
, can_cancel
, can_delete
) = (False,)*4
1904 ( is_played
, is_locked
) = (False,)*2
1906 open_instead_of_play
= False
1908 selection
= self
.treeAvailable
.get_selection()
1909 if selection
.count_selected_rows() > 0:
1910 (model
, paths
) = selection
.get_selected_rows()
1914 episode
= model
.get_value(model
.get_iter(path
), EpisodeListModel
.C_EPISODE
)
1915 except TypeError, te
:
1916 logger
.error('Invalid episode at path %s', str(path
))
1919 if episode
.file_type() not in ('audio', 'video'):
1920 open_instead_of_play
= True
1922 if episode
.was_downloaded():
1923 can_play
= episode
.was_downloaded(and_exists
=True)
1924 is_played
= not episode
.is_new
1925 is_locked
= episode
.archive
1929 if episode
.downloading
:
1934 can_download
= can_download
and not can_cancel
1935 can_play
= self
.streaming_possible() or (can_play
and not can_cancel
and not can_download
)
1936 can_delete
= not can_cancel
1938 if open_instead_of_play
:
1939 self
.toolPlay
.set_stock_id(gtk
.STOCK_OPEN
)
1941 self
.toolPlay
.set_stock_id(gtk
.STOCK_MEDIA_PLAY
)
1942 self
.toolPlay
.set_sensitive( can_play
)
1943 self
.toolDownload
.set_sensitive( can_download
)
1944 self
.toolCancel
.set_sensitive( can_cancel
)
1946 self
.item_cancel_download
.set_sensitive(can_cancel
)
1947 self
.itemDownloadSelected
.set_sensitive(can_download
)
1948 self
.itemOpenSelected
.set_sensitive(can_play
)
1949 self
.itemPlaySelected
.set_sensitive(can_play
)
1950 self
.itemDeleteSelected
.set_sensitive(can_delete
)
1951 self
.item_toggle_played
.set_sensitive(can_play
)
1952 self
.item_toggle_lock
.set_sensitive(can_play
)
1953 self
.itemOpenSelected
.set_visible(open_instead_of_play
)
1954 self
.itemPlaySelected
.set_visible(not open_instead_of_play
)
1956 return (can_play
, can_download
, can_cancel
, can_delete
, open_instead_of_play
)
1958 def on_cbMaxDownloads_toggled(self
, widget
, *args
):
1959 self
.spinMaxDownloads
.set_sensitive(self
.cbMaxDownloads
.get_active())
1961 def on_cbLimitDownloads_toggled(self
, widget
, *args
):
1962 self
.spinLimitDownloads
.set_sensitive(self
.cbLimitDownloads
.get_active())
1964 def episode_new_status_changed(self
, urls
):
1965 self
.update_podcast_list_model()
1966 self
.update_episode_list_icons(urls
)
1968 def update_podcast_list_model(self
, urls
=None, selected
=False, select_url
=None,
1969 sections_changed
=False):
1970 """Update the podcast list treeview model
1972 If urls is given, it should list the URLs of each
1973 podcast that has to be updated in the list.
1975 If selected is True, only update the model contents
1976 for the currently-selected podcast - nothing more.
1978 The caller can optionally specify "select_url",
1979 which is the URL of the podcast that is to be
1980 selected in the list after the update is complete.
1981 This only works if the podcast list has to be
1982 reloaded; i.e. something has been added or removed
1983 since the last update of the podcast list).
1985 selection
= self
.treeChannels
.get_selection()
1986 model
, iter = selection
.get_selected()
1988 is_section
= lambda r
: r
[PodcastListModel
.C_URL
] == '-'
1989 is_separator
= lambda r
: r
[PodcastListModel
.C_SEPARATOR
]
1990 sections_active
= any(is_section(x
) for x
in self
.podcast_list_model
)
1992 if self
.config
.podcast_list_view_all
:
1993 # Update "all episodes" view in any case (if enabled)
1994 self
.podcast_list_model
.update_first_row()
1995 # List model length minus 1, because of "All"
1996 list_model_length
= len(self
.podcast_list_model
) - 1
1998 list_model_length
= len(self
.podcast_list_model
)
2000 force_update
= (sections_active
!= self
.config
.podcast_list_sections
or
2003 # Filter items in the list model that are not podcasts, so we get the
2004 # correct podcast list count (ignore section headers and separators)
2005 is_not_podcast
= lambda r
: is_section(r
) or is_separator(r
)
2006 list_model_length
-= len(filter(is_not_podcast
, self
.podcast_list_model
))
2008 if selected
and not force_update
:
2009 # very cheap! only update selected channel
2010 if iter is not None:
2011 # If we have selected the "all episodes" view, we have
2012 # to update all channels for selected episodes:
2013 if self
.config
.podcast_list_view_all
and \
2014 self
.podcast_list_model
.iter_is_first_row(iter):
2015 urls
= self
.get_podcast_urls_from_selected_episodes()
2016 self
.podcast_list_model
.update_by_urls(urls
)
2018 # Otherwise just update the selected row (a podcast)
2019 self
.podcast_list_model
.update_by_filter_iter(iter)
2021 if self
.config
.podcast_list_sections
:
2022 self
.podcast_list_model
.update_sections()
2023 elif list_model_length
== len(self
.channels
) and not force_update
:
2024 # we can keep the model, but have to update some
2026 # still cheaper than reloading the whole list
2027 self
.podcast_list_model
.update_all()
2029 # ok, we got a bunch of urls to update
2030 self
.podcast_list_model
.update_by_urls(urls
)
2031 if self
.config
.podcast_list_sections
:
2032 self
.podcast_list_model
.update_sections()
2034 if model
and iter and select_url
is None:
2035 # Get the URL of the currently-selected podcast
2036 select_url
= model
.get_value(iter, PodcastListModel
.C_URL
)
2038 # Update the podcast list model with new channels
2039 self
.podcast_list_model
.set_channels(self
.db
, self
.config
, self
.channels
)
2042 selected_iter
= model
.get_iter_first()
2043 # Find the previously-selected URL in the new
2044 # model if we have an URL (else select first)
2045 if select_url
is not None:
2046 pos
= model
.get_iter_first()
2047 while pos
is not None:
2048 url
= model
.get_value(pos
, PodcastListModel
.C_URL
)
2049 if url
== select_url
:
2052 pos
= model
.iter_next(pos
)
2054 if selected_iter
is not None:
2055 selection
.select_iter(selected_iter
)
2056 self
.on_treeChannels_cursor_changed(self
.treeChannels
)
2058 logger
.error('Cannot select podcast in list', exc_info
=True)
2060 def on_episode_list_filter_changed(self
, has_episodes
):
2063 def update_episode_list_model(self
):
2064 if self
.channels
and self
.active_channel
is not None:
2065 self
.currently_updating
= True
2066 self
.episode_list_model
.clear()
2069 descriptions
= self
.config
.episode_list_descriptions
2070 self
.episode_list_model
.replace_from_channel(self
.active_channel
, descriptions
)
2072 self
.treeAvailable
.get_selection().unselect_all()
2073 self
.treeAvailable
.scroll_to_point(0, 0)
2075 self
.currently_updating
= False
2076 self
.play_or_download()
2078 util
.idle_add(update
)
2080 self
.episode_list_model
.clear()
2082 @dbus.service
.method(gpodder
.dbus_interface
)
2083 def offer_new_episodes(self
, channels
=None):
2084 new_episodes
= self
.get_new_episodes(channels
)
2086 self
.new_episodes_show(new_episodes
)
2090 def add_podcast_list(self
, urls
, auth_tokens
=None):
2091 """Subscribe to a list of podcast given their URLs
2093 If auth_tokens is given, it should be a dictionary
2094 mapping URLs to (username, password) tuples."""
2096 if auth_tokens
is None:
2099 existing_urls
= set(podcast
.url
for podcast
in self
.channels
)
2101 # Sort and split the URL list into five buckets
2102 queued
, failed
, existing
, worked
, authreq
= [], [], [], [], []
2103 for input_url
in urls
:
2104 url
= util
.normalize_feed_url(input_url
)
2106 # Fail this one because the URL is not valid
2107 failed
.append(input_url
)
2108 elif url
in existing_urls
:
2109 # A podcast already exists in the list for this URL
2110 existing
.append(url
)
2112 # This URL has survived the first round - queue for add
2114 if url
!= input_url
and input_url
in auth_tokens
:
2115 auth_tokens
[url
] = auth_tokens
[input_url
]
2120 progress
= ProgressIndicator(_('Adding podcasts'), \
2121 _('Please wait while episode information is downloaded.'), \
2122 parent
=self
.get_dialog_parent())
2124 def on_after_update():
2125 progress
.on_finished()
2126 # Report already-existing subscriptions to the user
2128 title
= _('Existing subscriptions skipped')
2129 message
= _('You are already subscribed to these podcasts:') \
2130 + '\n\n' + '\n'.join(cgi
.escape(url
) for url
in existing
)
2131 self
.show_message(message
, title
, widget
=self
.treeChannels
)
2133 # Report subscriptions that require authentication
2137 title
= _('Podcast requires authentication')
2138 message
= _('Please login to %s:') % (cgi
.escape(url
),)
2139 success
, auth_tokens
= self
.show_login_dialog(title
, message
)
2141 retry_podcasts
[url
] = auth_tokens
2143 # Stop asking the user for more login data
2146 error_messages
[url
] = _('Authentication failed')
2150 # Report website redirections
2151 for url
in redirections
:
2152 title
= _('Website redirection detected')
2153 message
= _('The URL %(url)s redirects to %(target)s.') \
2154 + '\n\n' + _('Do you want to visit the website now?')
2155 message
= message
% {'url': url
, 'target': redirections
[url
]}
2156 if self
.show_confirmation(message
, title
):
2157 util
.open_website(url
)
2161 # Report failed subscriptions to the user
2163 title
= _('Could not add some podcasts')
2164 message
= _('Some podcasts could not be added to your list:') \
2165 + '\n\n' + '\n'.join(cgi
.escape('%s: %s' % (url
, \
2166 error_messages
.get(url
, _('Unknown')))) for url
in failed
)
2167 self
.show_message(message
, title
, important
=True)
2169 # Upload subscription changes to gpodder.net
2170 self
.mygpo_client
.on_subscribe(worked
)
2172 # Fix URLs if mygpo has rewritten them
2173 self
.rewrite_urls_mygpo()
2175 # If only one podcast was added, select it after the update
2176 if len(worked
) == 1:
2181 # Update the list of subscribed podcasts
2182 self
.update_podcast_list_model(select_url
=url
)
2184 # If we have authentication data to retry, do so here
2186 self
.add_podcast_list(retry_podcasts
.keys(), retry_podcasts
)
2187 # This will NOT show new episodes for podcasts that have
2188 # been added ("worked"), but it will prevent problems with
2189 # multiple dialogs being open at the same time ;)
2192 # Offer to download new episodes
2194 for podcast
in self
.channels
:
2195 if podcast
.url
in worked
:
2196 episodes
.extend(podcast
.get_all_episodes())
2199 episodes
= list(Model
.sort_episodes_by_pubdate(episodes
, \
2201 self
.new_episodes_show(episodes
, \
2202 selected
=[e
.check_is_new() for e
in episodes
])
2206 # After the initial sorting and splitting, try all queued podcasts
2207 length
= len(queued
)
2208 for index
, url
in enumerate(queued
):
2209 progress
.on_progress(float(index
)/float(length
))
2210 progress
.on_message(url
)
2212 # The URL is valid and does not exist already - subscribe!
2213 channel
= self
.model
.load_podcast(url
=url
, create
=True, \
2214 authentication_tokens
=auth_tokens
.get(url
, None), \
2215 max_episodes
=self
.config
.max_episodes_per_feed
)
2218 username
, password
= util
.username_password_from_url(url
)
2219 except ValueError, ve
:
2220 username
, password
= (None, None)
2222 if username
is not None and channel
.auth_username
is None and \
2223 password
is not None and channel
.auth_password
is None:
2224 channel
.auth_username
= username
2225 channel
.auth_password
= password
2228 self
._update
_cover
(channel
)
2229 except feedcore
.AuthenticationRequired
:
2230 if url
in auth_tokens
:
2231 # Fail for wrong authentication data
2232 error_messages
[url
] = _('Authentication failed')
2235 # Queue for login dialog later
2238 except feedcore
.WifiLogin
, error
:
2239 redirections
[url
] = error
.data
2241 error_messages
[url
] = _('Redirection detected')
2243 except Exception, e
:
2244 logger
.error('Subscription error: %s', e
, exc_info
=True)
2245 error_messages
[url
] = str(e
)
2249 assert channel
is not None
2250 worked
.append(channel
.url
)
2252 util
.idle_add(on_after_update
)
2253 threading
.Thread(target
=thread_proc
).start()
2255 def find_episode(self
, podcast_url
, episode_url
):
2256 """Find an episode given its podcast and episode URL
2258 The function will return a PodcastEpisode object if
2259 the episode is found, or None if it's not found.
2261 for podcast
in self
.channels
:
2262 if podcast_url
== podcast
.url
:
2263 for episode
in podcast
.get_all_episodes():
2264 if episode_url
== episode
.url
:
2269 def process_received_episode_actions(self
):
2270 """Process/merge episode actions from gpodder.net
2272 This function will merge all changes received from
2273 the server to the local database and update the
2274 status of the affected episodes as necessary.
2276 indicator
= ProgressIndicator(_('Merging episode actions'), \
2277 _('Episode actions from gpodder.net are merged.'), \
2278 False, self
.get_dialog_parent())
2280 while gtk
.events_pending():
2281 gtk
.main_iteration(False)
2283 self
.mygpo_client
.process_episode_actions(self
.find_episode
)
2285 indicator
.on_finished()
2288 def _update_cover(self
, channel
):
2289 if channel
is not None:
2290 self
.cover_downloader
.request_cover(channel
)
2292 def show_update_feeds_buttons(self
):
2293 # Make sure that the buttons for updating feeds
2294 # appear - this should happen after a feed update
2295 self
.hboxUpdateFeeds
.hide()
2296 self
.btnUpdateFeeds
.show()
2297 self
.itemUpdate
.set_sensitive(True)
2298 self
.itemUpdateChannel
.set_sensitive(True)
2300 def on_btnCancelFeedUpdate_clicked(self
, widget
):
2301 if not self
.feed_cache_update_cancelled
:
2302 self
.pbFeedUpdate
.set_text(_('Cancelling...'))
2303 self
.feed_cache_update_cancelled
= True
2304 self
.btnCancelFeedUpdate
.set_sensitive(False)
2306 self
.show_update_feeds_buttons()
2308 def update_feed_cache(self
, channels
=None,
2309 show_new_episodes_dialog
=True):
2310 # Fix URLs if mygpo has rewritten them
2311 self
.rewrite_urls_mygpo()
2313 if channels
is None:
2314 # Only update podcasts for which updates are enabled
2315 channels
= [c
for c
in self
.channels
if not c
.pause_subscription
]
2317 self
.itemUpdate
.set_sensitive(False)
2318 self
.itemUpdateChannel
.set_sensitive(False)
2320 self
.feed_cache_update_cancelled
= False
2321 self
.btnCancelFeedUpdate
.show()
2322 self
.btnCancelFeedUpdate
.set_sensitive(True)
2323 self
.btnCancelFeedUpdate
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_STOP
, gtk
.ICON_SIZE_BUTTON
))
2324 self
.hboxUpdateFeeds
.show_all()
2325 self
.btnUpdateFeeds
.hide()
2327 count
= len(channels
)
2328 text
= N_('Updating %(count)d feed...', 'Updating %(count)d feeds...', count
) % {'count':count
}
2330 self
.pbFeedUpdate
.set_text(text
)
2331 self
.pbFeedUpdate
.set_fraction(0)
2333 def update_feed_cache_proc():
2334 updated_channels
= []
2335 for updated
, channel
in enumerate(channels
):
2336 if self
.feed_cache_update_cancelled
:
2340 channel
.update(max_episodes
=self
.config
.max_episodes_per_feed
)
2341 self
._update
_cover
(channel
)
2342 except Exception, e
:
2343 d
= {'url': cgi
.escape(channel
.url
), 'message': cgi
.escape(str(e
))}
2345 message
= _('Error while updating %(url)s: %(message)s')
2347 message
= _('The feed at %(url)s could not be updated.')
2348 self
.notification(message
% d
, _('Error while updating feed'), widget
=self
.treeChannels
)
2349 logger
.error('Error: %s', str(e
), exc_info
=True)
2351 updated_channels
.append(channel
)
2353 def update_progress(channel
):
2354 self
.update_podcast_list_model([channel
.url
])
2356 # If the currently-viewed podcast is updated, reload episodes
2357 if self
.active_channel
is not None and \
2358 self
.active_channel
== channel
:
2359 logger
.debug('Updated channel is active, updating UI')
2360 self
.update_episode_list_model()
2362 d
= {'podcast': channel
.title
, 'position': updated
+1, 'total': count
}
2363 progression
= _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2365 self
.pbFeedUpdate
.set_text(progression
)
2366 self
.pbFeedUpdate
.set_fraction(float(updated
+1)/float(count
))
2368 util
.idle_add(update_progress
, channel
)
2370 def update_feed_cache_finish_callback():
2371 # Process received episode actions for all updated URLs
2372 self
.process_received_episode_actions()
2374 # If we are currently viewing "All episodes", update its episode list now
2375 if self
.active_channel
is not None and \
2376 getattr(self
.active_channel
, 'ALL_EPISODES_PROXY', False):
2377 self
.update_episode_list_model()
2379 if self
.feed_cache_update_cancelled
:
2380 # The user decided to abort the feed update
2381 self
.show_update_feeds_buttons()
2383 # Only search for new episodes in podcasts that have been
2384 # updated, not in other podcasts (for single-feed updates)
2385 episodes
= self
.get_new_episodes([c
for c
in updated_channels
])
2388 # Nothing new here - but inform the user
2389 self
.pbFeedUpdate
.set_fraction(1.0)
2390 self
.pbFeedUpdate
.set_text(_('No new episodes'))
2391 self
.feed_cache_update_cancelled
= True
2392 self
.btnCancelFeedUpdate
.show()
2393 self
.btnCancelFeedUpdate
.set_sensitive(True)
2394 self
.itemUpdate
.set_sensitive(True)
2395 self
.btnCancelFeedUpdate
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_APPLY
, gtk
.ICON_SIZE_BUTTON
))
2397 count
= len(episodes
)
2398 # New episodes are available
2399 self
.pbFeedUpdate
.set_fraction(1.0)
2401 if self
.config
.auto_download
== 'download':
2402 self
.download_episode_list(episodes
)
2403 title
= N_('Downloading %(count)d new episode.', 'Downloading %(count)d new episodes.', count
) % {'count':count
}
2404 self
.show_message(title
, _('New episodes available'), widget
=self
.labelDownloads
)
2405 elif self
.config
.auto_download
== 'queue':
2406 self
.download_episode_list_paused(episodes
)
2407 title
= N_('%(count)d new episode added to download list.', '%(count)d new episodes added to download list.', count
) % {'count':count
}
2408 self
.show_message(title
, _('New episodes available'), widget
=self
.labelDownloads
)
2410 if (show_new_episodes_dialog
and
2411 self
.config
.auto_download
== 'show'):
2412 self
.new_episodes_show(episodes
, notification
=True)
2413 else: # !show_new_episodes_dialog or auto_download == 'ignore'
2414 message
= N_('%(count)d new episode available', '%(count)d new episodes available', count
) % {'count':count
}
2415 self
.pbFeedUpdate
.set_text(message
)
2417 self
.show_update_feeds_buttons()
2419 util
.idle_add(update_feed_cache_finish_callback
)
2421 threading
.Thread(target
=update_feed_cache_proc
).start()
2423 def on_gPodder_delete_event(self
, widget
, *args
):
2424 """Called when the GUI wants to close the window
2425 Displays a confirmation dialog (and closes/hides gPodder)
2428 downloading
= self
.download_status_model
.are_downloads_in_progress()
2431 dialog
= gtk
.MessageDialog(self
.gPodder
, gtk
.DIALOG_MODAL
, gtk
.MESSAGE_QUESTION
, gtk
.BUTTONS_NONE
)
2432 dialog
.add_button(gtk
.STOCK_CANCEL
, gtk
.RESPONSE_CANCEL
)
2433 quit_button
= dialog
.add_button(gtk
.STOCK_QUIT
, gtk
.RESPONSE_CLOSE
)
2435 title
= _('Quit gPodder')
2436 message
= _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2438 dialog
.set_title(title
)
2439 dialog
.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title
, message
))
2441 quit_button
.grab_focus()
2442 result
= dialog
.run()
2445 if result
== gtk
.RESPONSE_CLOSE
:
2446 self
.close_gpodder()
2448 self
.close_gpodder()
2452 def quit_cb(self
, macapp
):
2453 """Called when OSX wants to quit the app (Cmd-Q or gPodder > Quit)
2455 # Event can't really be cancelled - don't even try
2456 self
.close_gpodder()
2459 def close_gpodder(self
):
2460 """ clean everything and exit properly
2464 # Notify all tasks to to carry out any clean-up actions
2465 self
.download_status_model
.tell_all_tasks_to_quit()
2467 while gtk
.events_pending():
2468 gtk
.main_iteration(False)
2470 self
.core
.shutdown()
2476 def get_expired_episodes(self
):
2477 # XXX: Move out of gtkui and into a generic module (gpodder.model)?
2479 # Only expire episodes if the age in days is positive
2480 if self
.config
.episode_old_age
< 1:
2483 for channel
in self
.channels
:
2484 for episode
in channel
.get_downloaded_episodes():
2485 # Never consider archived episodes as old
2489 # Never consider fresh episodes as old
2490 if episode
.age_in_days() < self
.config
.episode_old_age
:
2493 # Do not delete played episodes (except if configured)
2494 if not episode
.is_new
:
2495 if not self
.config
.auto_remove_played_episodes
:
2498 # Do not delete unfinished episodes (except if configured)
2499 if not episode
.is_finished():
2500 if not self
.config
.auto_remove_unfinished_episodes
:
2503 # Do not delete unplayed episodes (except if configured)
2505 if not self
.config
.auto_remove_unplayed_episodes
:
2510 def delete_episode_list(self
, episodes
, confirm
=True, skip_locked
=True):
2515 episodes
= [e
for e
in episodes
if not e
.archive
]
2518 title
= _('Episodes are locked')
2519 message
= _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2520 self
.notification(message
, title
, widget
=self
.treeAvailable
)
2523 count
= len(episodes
)
2524 title
= N_('Delete %(count)d episode?', 'Delete %(count)d episodes?', count
) % {'count':count
}
2525 message
= _('Deleting episodes removes downloaded files.')
2527 if confirm
and not self
.show_confirmation(message
, title
):
2530 progress
= ProgressIndicator(_('Deleting episodes'), \
2531 _('Please wait while episodes are deleted'), \
2532 parent
=self
.get_dialog_parent())
2534 def finish_deletion(episode_urls
, channel_urls
):
2535 progress
.on_finished()
2537 # Episodes have been deleted - persist the database
2540 self
.update_episode_list_icons(episode_urls
)
2541 self
.update_podcast_list_model(channel_urls
)
2542 self
.play_or_download()
2545 episode_urls
= set()
2546 channel_urls
= set()
2548 episodes_status_update
= []
2549 for idx
, episode
in enumerate(episodes
):
2550 progress
.on_progress(float(idx
)/float(len(episodes
)))
2551 if not episode
.archive
or not skip_locked
:
2552 progress
.on_message(episode
.title
)
2553 episode
.delete_from_disk()
2554 episode_urls
.add(episode
.url
)
2555 channel_urls
.add(episode
.channel
.url
)
2556 episodes_status_update
.append(episode
)
2558 # Tell the shownotes window that we have removed the episode
2559 if self
.episode_shownotes_window
is not None and \
2560 self
.episode_shownotes_window
.episode
is not None and \
2561 self
.episode_shownotes_window
.episode
.url
== episode
.url
:
2562 util
.idle_add(self
.episode_shownotes_window
._download
_status
_changed
, None)
2564 # Notify the web service about the status update + upload
2565 self
.mygpo_client
.on_delete(episodes_status_update
)
2566 self
.mygpo_client
.flush()
2568 util
.idle_add(finish_deletion
, episode_urls
, channel_urls
)
2570 threading
.Thread(target
=thread_proc
).start()
2574 def on_itemRemoveOldEpisodes_activate(self
, widget
):
2575 self
.show_delete_episodes_window()
2577 def show_delete_episodes_window(self
, channel
=None):
2578 """Offer deletion of episodes
2580 If channel is None, offer deletion of all episodes.
2581 Otherwise only offer deletion of episodes in the channel.
2584 ('markup_delete_episodes', None, None, _('Episode')),
2587 msg_older_than
= N_('Select older than %(count)d day', 'Select older than %(count)d days', self
.config
.episode_old_age
)
2588 selection_buttons
= {
2589 _('Select played'): lambda episode
: not episode
.is_new
,
2590 _('Select finished'): lambda episode
: episode
.is_finished(),
2591 msg_older_than
% {'count':self
.config
.episode_old_age
}: lambda episode
: episode
.age_in_days() > self
.config
.episode_old_age
,
2594 instructions
= _('Select the episodes you want to delete:')
2597 channels
= self
.channels
2599 channels
= [channel
]
2602 for channel
in channels
:
2603 for episode
in channel
.get_downloaded_episodes():
2604 # Disallow deletion of locked episodes that still exist
2605 if not episode
.archive
or not episode
.file_exists():
2606 episodes
.append(episode
)
2608 selected
= [not e
.is_new
or not e
.file_exists() for e
in episodes
]
2610 gPodderEpisodeSelector(self
.gPodder
, title
= _('Delete episodes'), instructions
= instructions
, \
2611 episodes
= episodes
, selected
= selected
, columns
= columns
, \
2612 stock_ok_button
= gtk
.STOCK_DELETE
, callback
= self
.delete_episode_list
, \
2613 selection_buttons
= selection_buttons
, _config
=self
.config
, \
2614 show_episode_shownotes
=self
.show_episode_shownotes
)
2616 def on_selected_episodes_status_changed(self
):
2617 # The order of the updates here is important! When "All episodes" is
2618 # selected, the update of the podcast list model depends on the episode
2619 # list selection to determine which podcasts are affected. Updating
2620 # the episode list could remove the selection if a filter is active.
2621 self
.update_podcast_list_model(selected
=True)
2622 self
.update_episode_list_icons(selected
=True)
2625 def mark_selected_episodes_new(self
):
2626 for episode
in self
.get_selected_episodes():
2628 self
.on_selected_episodes_status_changed()
2630 def mark_selected_episodes_old(self
):
2631 for episode
in self
.get_selected_episodes():
2633 self
.on_selected_episodes_status_changed()
2635 def on_item_toggle_played_activate( self
, widget
, toggle
= True, new_value
= False):
2636 for episode
in self
.get_selected_episodes():
2638 episode
.mark(is_played
=episode
.is_new
)
2640 episode
.mark(is_played
=new_value
)
2641 self
.on_selected_episodes_status_changed()
2643 def on_item_toggle_lock_activate(self
, widget
, toggle
=True, new_value
=False):
2644 for episode
in self
.get_selected_episodes():
2646 episode
.mark(is_locked
=not episode
.archive
)
2648 episode
.mark(is_locked
=new_value
)
2649 self
.on_selected_episodes_status_changed()
2651 def on_channel_toggle_lock_activate(self
, widget
, toggle
=True, new_value
=False):
2652 if self
.active_channel
is None:
2655 self
.active_channel
.auto_archive_episodes
= not self
.active_channel
.auto_archive_episodes
2656 self
.active_channel
.save()
2658 for episode
in self
.active_channel
.get_all_episodes():
2659 episode
.mark(is_locked
=self
.active_channel
.auto_archive_episodes
)
2661 self
.update_podcast_list_model(selected
=True)
2662 self
.update_episode_list_icons(all
=True)
2664 def on_itemUpdateChannel_activate(self
, widget
=None):
2665 if self
.active_channel
is None:
2666 title
= _('No podcast selected')
2667 message
= _('Please select a podcast in the podcasts list to update.')
2668 self
.show_message( message
, title
, widget
=self
.treeChannels
)
2671 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
2672 if getattr(self
.active_channel
, 'ALL_EPISODES_PROXY', False):
2673 self
.update_feed_cache()
2675 self
.update_feed_cache(channels
=[self
.active_channel
])
2677 def on_itemUpdate_activate(self
, widget
=None):
2678 # Check if we have outstanding subscribe/unsubscribe actions
2679 self
.on_add_remove_podcasts_mygpo()
2682 self
.update_feed_cache()
2684 def show_welcome_window():
2685 def on_show_example_podcasts(widget
):
2686 welcome_window
.main_window
.response(gtk
.RESPONSE_CANCEL
)
2687 self
.on_itemImportChannels_activate(None)
2689 def on_add_podcast_via_url(widget
):
2690 welcome_window
.main_window
.response(gtk
.RESPONSE_CANCEL
)
2691 self
.on_itemAddChannel_activate(None)
2693 def on_setup_my_gpodder(widget
):
2694 welcome_window
.main_window
.response(gtk
.RESPONSE_CANCEL
)
2695 self
.on_download_subscriptions_from_mygpo(None)
2697 welcome_window
= gPodderWelcome(self
.main_window
,
2698 center_on_widget
=self
.main_window
,
2699 on_show_example_podcasts
=on_show_example_podcasts
,
2700 on_add_podcast_via_url
=on_add_podcast_via_url
,
2701 on_setup_my_gpodder
=on_setup_my_gpodder
)
2703 welcome_window
.main_window
.run()
2704 welcome_window
.main_window
.destroy()
2706 util
.idle_add(show_welcome_window
)
2708 def download_episode_list_paused(self
, episodes
):
2709 self
.download_episode_list(episodes
, True)
2711 def download_episode_list(self
, episodes
, add_paused
=False, force_start
=False):
2712 enable_update
= False
2714 for episode
in episodes
:
2715 logger
.debug('Downloading episode: %s', episode
.title
)
2716 if not episode
.was_downloaded(and_exists
=True):
2718 for task
in self
.download_tasks_seen
:
2719 if episode
.url
== task
.url
and task
.status
not in (task
.DOWNLOADING
, task
.QUEUED
):
2720 self
.download_queue_manager
.add_task(task
, force_start
)
2721 enable_update
= True
2729 task
= download
.DownloadTask(episode
, self
.config
)
2730 except Exception, e
:
2731 d
= {'episode': episode
.title
, 'message': str(e
)}
2732 message
= _('Download error while downloading %(episode)s: %(message)s')
2733 self
.show_message(message
% d
, _('Download error'), important
=True)
2734 logger
.error('While downloading %s', episode
.title
, exc_info
=True)
2738 task
.status
= task
.PAUSED
2740 self
.mygpo_client
.on_download([task
.episode
])
2741 self
.download_queue_manager
.add_task(task
, force_start
)
2743 self
.download_status_model
.register_task(task
)
2744 enable_update
= True
2747 self
.enable_download_list_update()
2749 # Flush updated episode status
2750 self
.mygpo_client
.flush()
2752 def cancel_task_list(self
, tasks
):
2757 if task
.status
in (task
.QUEUED
, task
.DOWNLOADING
):
2758 task
.status
= task
.CANCELLED
2759 elif task
.status
== task
.PAUSED
:
2760 task
.status
= task
.CANCELLED
2761 # Call run, so the partial file gets deleted
2764 self
.update_episode_list_icons([task
.url
for task
in tasks
])
2765 self
.play_or_download()
2767 # Update the tab title and downloads list
2768 self
.update_downloads_list()
2770 def new_episodes_show(self
, episodes
, notification
=False, selected
=None):
2772 ('markup_new_episodes', None, None, _('Episode')),
2775 instructions
= _('Select the episodes you want to download:')
2777 if self
.new_episodes_window
is not None:
2778 self
.new_episodes_window
.main_window
.destroy()
2779 self
.new_episodes_window
= None
2781 def download_episodes_callback(episodes
):
2782 self
.new_episodes_window
= None
2783 self
.download_episode_list(episodes
)
2785 if selected
is None:
2786 # Select all by default
2787 selected
= [True]*len(episodes
)
2789 self
.new_episodes_window
= gPodderEpisodeSelector(self
.gPodder
, \
2790 title
=_('New episodes available'), \
2791 instructions
=instructions
, \
2792 episodes
=episodes
, \
2794 selected
=selected
, \
2795 stock_ok_button
= 'gpodder-download', \
2796 callback
=download_episodes_callback
, \
2797 remove_callback
=lambda e
: e
.mark_old(), \
2798 remove_action
=_('Mark as old'), \
2799 remove_finished
=self
.episode_new_status_changed
, \
2800 _config
=self
.config
, \
2801 show_notification
=False, \
2802 show_episode_shownotes
=self
.show_episode_shownotes
)
2804 def on_itemDownloadAllNew_activate(self
, widget
, *args
):
2805 if not self
.offer_new_episodes():
2806 self
.show_message(_('Please check for new episodes later.'), \
2807 _('No new episodes available'), widget
=self
.btnUpdateFeeds
)
2809 def get_new_episodes(self
, channels
=None):
2810 return [e
for c
in channels
or self
.channels
for e
in
2811 filter(lambda e
: e
.check_is_new(), c
.get_all_episodes())]
2813 def commit_changes_to_database(self
):
2814 """This will be called after the sync process is finished"""
2817 def on_itemShowAllEpisodes_activate(self
, widget
):
2818 self
.config
.podcast_list_view_all
= widget
.get_active()
2820 def on_itemShowToolbar_activate(self
, widget
):
2821 self
.config
.show_toolbar
= self
.itemShowToolbar
.get_active()
2823 def on_itemShowDescription_activate(self
, widget
):
2824 self
.config
.episode_list_descriptions
= self
.itemShowDescription
.get_active()
2826 def on_item_view_hide_boring_podcasts_toggled(self
, toggleaction
):
2827 self
.config
.podcast_list_hide_boring
= toggleaction
.get_active()
2828 if self
.config
.podcast_list_hide_boring
:
2829 self
.podcast_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
2831 self
.podcast_list_model
.set_view_mode(-1)
2833 def on_item_view_episodes_changed(self
, radioaction
, current
):
2834 if current
== self
.item_view_episodes_all
:
2835 self
.config
.episode_list_view_mode
= EpisodeListModel
.VIEW_ALL
2836 elif current
== self
.item_view_episodes_undeleted
:
2837 self
.config
.episode_list_view_mode
= EpisodeListModel
.VIEW_UNDELETED
2838 elif current
== self
.item_view_episodes_downloaded
:
2839 self
.config
.episode_list_view_mode
= EpisodeListModel
.VIEW_DOWNLOADED
2840 elif current
== self
.item_view_episodes_unplayed
:
2841 self
.config
.episode_list_view_mode
= EpisodeListModel
.VIEW_UNPLAYED
2843 self
.episode_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
2845 if self
.config
.podcast_list_hide_boring
:
2846 self
.podcast_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
2848 def on_itemPreferences_activate(self
, widget
, *args
):
2849 gPodderPreferences(self
.main_window
, \
2850 _config
=self
.config
, \
2851 user_apps_reader
=self
.user_apps_reader
, \
2852 parent_window
=self
.main_window
, \
2853 mygpo_client
=self
.mygpo_client
, \
2854 on_send_full_subscriptions
=self
.on_send_full_subscriptions
, \
2855 on_itemExportChannels_activate
=self
.on_itemExportChannels_activate
)
2857 def on_goto_mygpo(self
, widget
):
2858 self
.mygpo_client
.open_website()
2860 def on_download_subscriptions_from_mygpo(self
, action
=None):
2861 title
= _('Login to gpodder.net')
2862 message
= _('Please login to download your subscriptions.')
2864 def on_register_button_clicked():
2865 util
.open_website('http://gpodder.net/register/')
2867 success
, (username
, password
) = self
.show_login_dialog(title
, message
,
2868 self
.config
.mygpo
.username
, self
.config
.mygpo
.password
,
2869 register_callback
=on_register_button_clicked
)
2873 self
.config
.mygpo
.username
= username
2874 self
.config
.mygpo
.password
= password
2876 dir = gPodderPodcastDirectory(self
.gPodder
, _config
=self
.config
, \
2877 custom_title
=_('Subscriptions on gpodder.net'), \
2878 add_urls_callback
=self
.add_podcast_list
, \
2879 hide_url_entry
=True)
2881 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
2882 # we do not have to hardcode the URL here
2883 OPML_URL
= 'http://gpodder.net/subscriptions/%s.opml' % self
.config
.mygpo
.username
2884 url
= util
.url_add_authentication(OPML_URL
, \
2885 self
.config
.mygpo
.username
, \
2886 self
.config
.mygpo
.password
)
2887 dir.download_opml_file(url
)
2889 def on_itemAddChannel_activate(self
, widget
=None):
2890 gPodderAddPodcast(self
.gPodder
, \
2891 add_urls_callback
=self
.add_podcast_list
)
2893 def on_itemEditChannel_activate(self
, widget
, *args
):
2894 if self
.active_channel
is None:
2895 title
= _('No podcast selected')
2896 message
= _('Please select a podcast in the podcasts list to edit.')
2897 self
.show_message( message
, title
, widget
=self
.treeChannels
)
2900 gPodderChannel(self
.main_window
,
2901 channel
=self
.active_channel
,
2902 update_podcast_list_model
=self
.update_podcast_list_model
,
2903 cover_downloader
=self
.cover_downloader
,
2904 sections
=set(c
.section
for c
in self
.channels
),
2905 clear_cover_cache
=self
.podcast_list_model
.clear_cover_cache
)
2907 def on_itemMassUnsubscribe_activate(self
, item
=None):
2909 ('title', None, None, _('Podcast')),
2912 # We're abusing the Episode Selector for selecting Podcasts here,
2913 # but it works and looks good, so why not? -- thp
2914 gPodderEpisodeSelector(self
.main_window
, \
2915 title
=_('Remove podcasts'), \
2916 instructions
=_('Select the podcast you want to remove.'), \
2917 episodes
=self
.channels
, \
2919 size_attribute
=None, \
2920 stock_ok_button
=_('Remove'), \
2921 callback
=self
.remove_podcast_list
, \
2922 _config
=self
.config
)
2924 def remove_podcast_list(self
, channels
, confirm
=True):
2928 if len(channels
) == 1:
2929 title
= _('Removing podcast')
2930 info
= _('Please wait while the podcast is removed')
2931 message
= _('Do you really want to remove this podcast and its episodes?')
2933 title
= _('Removing podcasts')
2934 info
= _('Please wait while the podcasts are removed')
2935 message
= _('Do you really want to remove the selected podcasts and their episodes?')
2937 if confirm
and not self
.show_confirmation(message
, title
):
2940 progress
= ProgressIndicator(title
, info
, parent
=self
.get_dialog_parent())
2942 def finish_deletion(select_url
):
2943 # Upload subscription list changes to the web service
2944 self
.mygpo_client
.on_unsubscribe([c
.url
for c
in channels
])
2946 # Re-load the channels and select the desired new channel
2947 self
.update_podcast_list_model(select_url
=select_url
)
2948 progress
.on_finished()
2953 for idx
, channel
in enumerate(channels
):
2954 # Update the UI for correct status messages
2955 progress
.on_progress(float(idx
)/float(len(channels
)))
2956 progress
.on_message(channel
.title
)
2958 # Delete downloaded episodes
2959 channel
.remove_downloaded()
2961 # cancel any active downloads from this channel
2962 for episode
in channel
.get_all_episodes():
2963 if episode
.downloading
:
2964 episode
.download_task
.cancel()
2966 if len(channels
) == 1:
2967 # get the URL of the podcast we want to select next
2968 if channel
in self
.channels
:
2969 position
= self
.channels
.index(channel
)
2973 if position
== len(self
.channels
)-1:
2974 # this is the last podcast, so select the URL
2975 # of the item before this one (i.e. the "new last")
2976 select_url
= self
.channels
[position
-1].url
2978 # there is a podcast after the deleted one, so
2979 # we simply select the one that comes after it
2980 select_url
= self
.channels
[position
+1].url
2982 # Remove the channel and clean the database entries
2985 # Clean up downloads and download directories
2986 self
.clean_up_downloads()
2988 # The remaining stuff is to be done in the GTK main thread
2989 util
.idle_add(finish_deletion
, select_url
)
2991 threading
.Thread(target
=thread_proc
).start()
2993 def on_itemRemoveChannel_activate(self
, widget
, *args
):
2994 if self
.active_channel
is None:
2995 title
= _('No podcast selected')
2996 message
= _('Please select a podcast in the podcasts list to remove.')
2997 self
.show_message( message
, title
, widget
=self
.treeChannels
)
3000 self
.remove_podcast_list([self
.active_channel
])
3002 def get_opml_filter(self
):
3003 filter = gtk
.FileFilter()
3004 filter.add_pattern('*.opml')
3005 filter.add_pattern('*.xml')
3006 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3009 def on_item_import_from_file_activate(self
, widget
, filename
=None):
3010 if filename
is None:
3011 dlg
= gtk
.FileChooserDialog(title
=_('Import from OPML'),
3012 parent
=self
.main_window
,
3013 action
=gtk
.FILE_CHOOSER_ACTION_OPEN
)
3014 dlg
.add_button(gtk
.STOCK_CANCEL
, gtk
.RESPONSE_CANCEL
)
3015 dlg
.add_button(gtk
.STOCK_OPEN
, gtk
.RESPONSE_OK
)
3016 dlg
.set_filter(self
.get_opml_filter())
3017 response
= dlg
.run()
3019 if response
== gtk
.RESPONSE_OK
:
3020 filename
= dlg
.get_filename()
3023 if filename
is not None:
3024 dir = gPodderPodcastDirectory(self
.gPodder
, _config
=self
.config
, \
3025 custom_title
=_('Import podcasts from OPML file'), \
3026 add_urls_callback
=self
.add_podcast_list
, \
3027 hide_url_entry
=True)
3028 dir.download_opml_file(filename
)
3030 def on_itemExportChannels_activate(self
, widget
, *args
):
3031 if not self
.channels
:
3032 title
= _('Nothing to export')
3033 message
= _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3034 self
.show_message(message
, title
, widget
=self
.treeChannels
)
3037 dlg
= gtk
.FileChooserDialog(title
=_('Export to OPML'), parent
=self
.gPodder
, action
=gtk
.FILE_CHOOSER_ACTION_SAVE
)
3038 dlg
.add_button(gtk
.STOCK_CANCEL
, gtk
.RESPONSE_CANCEL
)
3039 dlg
.add_button(gtk
.STOCK_SAVE
, gtk
.RESPONSE_OK
)
3040 dlg
.set_filter(self
.get_opml_filter())
3041 response
= dlg
.run()
3042 if response
== gtk
.RESPONSE_OK
:
3043 filename
= dlg
.get_filename()
3045 exporter
= opml
.Exporter( filename
)
3046 if filename
is not None and exporter
.write(self
.channels
):
3047 count
= len(self
.channels
)
3048 title
= N_('%(count)d subscription exported', '%(count)d subscriptions exported', count
) % {'count':count
}
3049 self
.show_message(_('Your podcast list has been successfully exported.'), title
, widget
=self
.treeChannels
)
3051 self
.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important
=True)
3055 def on_itemImportChannels_activate(self
, widget
, *args
):
3056 dir = gPodderPodcastDirectory(self
.main_window
, _config
=self
.config
, \
3057 add_urls_callback
=self
.add_podcast_list
)
3058 util
.idle_add(dir.download_opml_file
, my
.EXAMPLES_OPML
)
3060 def on_homepage_activate(self
, widget
, *args
):
3061 util
.open_website(gpodder
.__url
__)
3063 def on_wiki_activate(self
, widget
, *args
):
3064 util
.open_website('http://gpodder.org/wiki/User_Manual')
3066 def on_check_for_updates_activate(self
, widget
):
3067 self
.check_for_updates(silent
=False)
3069 def check_for_updates(self
, silent
):
3070 """Check for updates and (optionally) show a message
3072 If silent=False, a message will be shown even if no updates are
3073 available (set silent=False when the check is manually triggered).
3075 up_to_date
, version
, released
, days
= util
.get_update_info()
3077 if up_to_date
and not silent
:
3078 title
= _('No updates available')
3079 message
= _('You have the latest version of gPodder.')
3080 self
.show_message(message
, title
, important
=True)
3083 title
= _('New version available')
3084 message
= '\n'.join([
3085 _('Installed version: %s') % gpodder
.__version
__,
3086 _('Newest version: %s') % version
,
3087 _('Release date: %s') % released
,
3089 _('Download the latest version from gpodder.org?'),
3092 if self
.show_confirmation(message
, title
):
3093 util
.open_website('http://gpodder.org/downloads')
3095 def on_bug_tracker_activate(self
, widget
, *args
):
3096 util
.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder&component=Application&version=%s' % gpodder
.__version
__)
3098 def on_item_support_activate(self
, widget
):
3099 util
.open_website('http://gpodder.org/donate')
3101 def on_itemAbout_activate(self
, widget
, *args
):
3102 dlg
= gtk
.Dialog(_('About gPodder'), self
.main_window
, \
3104 dlg
.add_button(gtk
.STOCK_CLOSE
, gtk
.RESPONSE_OK
).show()
3105 dlg
.set_resizable(False)
3107 bg
= gtk
.HBox(spacing
=10)
3108 bg
.pack_start(gtk
.image_new_from_file(gpodder
.icon_file
), expand
=False)
3112 label
.set_alignment(0, 1)
3113 label
.set_markup('<b><big>gPodder</big> %s</b>' % gpodder
.__version
__)
3114 vb
.pack_start(label
)
3116 label
.set_alignment(0, 0)
3117 label
.set_markup('<small><a href="%s">%s</a></small>' % \
3118 ((cgi
.escape(gpodder
.__url
__),)*2))
3119 vb
.pack_start(label
)
3122 out
= gtk
.VBox(spacing
=10)
3123 out
.set_border_width(12)
3124 out
.pack_start(bg
, expand
=False)
3125 out
.pack_start(gtk
.HSeparator())
3126 out
.pack_start(gtk
.Label(gpodder
.__copyright
__))
3128 button_box
= gtk
.HButtonBox()
3129 button
= gtk
.Button(_('Donate / Wishlist'))
3130 button
.connect('clicked', self
.on_item_support_activate
)
3131 button_box
.pack_start(button
)
3132 button
= gtk
.Button(_('Report a problem'))
3133 button
.connect('clicked', self
.on_bug_tracker_activate
)
3134 button_box
.pack_start(button
)
3135 out
.pack_start(button_box
, expand
=False)
3137 credits
= gtk
.TextView()
3138 credits
.set_left_margin(5)
3139 credits
.set_right_margin(5)
3140 credits
.set_pixels_above_lines(5)
3141 credits
.set_pixels_below_lines(5)
3142 credits
.set_editable(False)
3143 credits
.set_cursor_visible(False)
3144 sw
= gtk
.ScrolledWindow()
3145 sw
.set_shadow_type(gtk
.SHADOW_IN
)
3146 sw
.set_policy(gtk
.POLICY_NEVER
, gtk
.POLICY_AUTOMATIC
)
3148 credits
.set_size_request(-1, 160)
3149 out
.pack_start(sw
, expand
=True, fill
=True)
3151 dlg
.vbox
.pack_start(out
, expand
=False)
3152 dlg
.connect('response', lambda dlg
, response
: dlg
.destroy())
3156 if os
.path
.exists(gpodder
.credits_file
):
3157 credits_txt
= open(gpodder
.credits_file
).read().strip().split('\n')
3158 translator_credits
= _('translator-credits')
3159 if translator_credits
!= 'translator-credits':
3160 app_authors
= [_('Translation by:'), translator_credits
, '']
3164 app_authors
+= [_('Thanks to:')]
3165 app_authors
+= credits_txt
3167 buffer = gtk
.TextBuffer()
3168 buffer.set_text('\n'.join(app_authors
))
3169 credits
.set_buffer(buffer)
3173 credits
.grab_focus()
3176 def on_wNotebook_switch_page(self
, notebook
, page
, page_num
):
3178 self
.play_or_download()
3179 # The message area in the downloads tab should be hidden
3180 # when the user switches away from the downloads tab
3181 if self
.message_area
is not None:
3182 self
.message_area
.hide()
3183 self
.message_area
= None
3185 self
.toolDownload
.set_sensitive(False)
3186 self
.toolPlay
.set_sensitive(False)
3187 self
.toolCancel
.set_sensitive(False)
3189 def on_treeChannels_row_activated(self
, widget
, path
, *args
):
3190 # double-click action of the podcast list or enter
3191 self
.treeChannels
.set_cursor(path
)
3193 def on_treeChannels_cursor_changed(self
, widget
, *args
):
3194 ( model
, iter ) = self
.treeChannels
.get_selection().get_selected()
3196 if model
is not None and iter is not None:
3197 old_active_channel
= self
.active_channel
3198 self
.active_channel
= model
.get_value(iter, PodcastListModel
.C_CHANNEL
)
3200 if self
.active_channel
== old_active_channel
:
3203 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3204 if getattr(self
.active_channel
, 'ALL_EPISODES_PROXY', False):
3205 self
.itemEditChannel
.set_visible(False)
3206 self
.itemRemoveChannel
.set_visible(False)
3208 self
.itemEditChannel
.set_visible(True)
3209 self
.itemRemoveChannel
.set_visible(True)
3211 self
.active_channel
= None
3212 self
.itemEditChannel
.set_visible(False)
3213 self
.itemRemoveChannel
.set_visible(False)
3215 self
.update_episode_list_model()
3217 def on_btnEditChannel_clicked(self
, widget
, *args
):
3218 self
.on_itemEditChannel_activate( widget
, args
)
3220 def get_podcast_urls_from_selected_episodes(self
):
3221 """Get a set of podcast URLs based on the selected episodes"""
3222 return set(episode
.channel
.url
for episode
in \
3223 self
.get_selected_episodes())
3225 def get_selected_episodes(self
):
3226 """Get a list of selected episodes from treeAvailable"""
3227 selection
= self
.treeAvailable
.get_selection()
3228 model
, paths
= selection
.get_selected_rows()
3230 episodes
= [model
.get_value(model
.get_iter(path
), EpisodeListModel
.C_EPISODE
) for path
in paths
]
3233 def on_playback_selected_episodes(self
, widget
):
3234 self
.playback_episodes(self
.get_selected_episodes())
3236 def on_shownotes_selected_episodes(self
, widget
):
3237 episodes
= self
.get_selected_episodes()
3239 episode
= episodes
.pop(0)
3240 self
.show_episode_shownotes(episode
)
3242 self
.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget
=self
.treeAvailable
)
3244 def on_download_selected_episodes(self
, widget
):
3245 episodes
= self
.get_selected_episodes()
3246 self
.download_episode_list(episodes
)
3247 self
.update_episode_list_icons([episode
.url
for episode
in episodes
])
3248 self
.play_or_download()
3250 def on_treeAvailable_row_activated(self
, widget
, path
, view_column
):
3251 """Double-click/enter action handler for treeAvailable"""
3252 self
.on_shownotes_selected_episodes(widget
)
3254 def show_episode_shownotes(self
, episode
):
3255 if self
.episode_shownotes_window
is None:
3256 self
.episode_shownotes_window
= gPodderShownotes(self
.gPodder
, _config
=self
.config
, \
3257 _download_episode_list
=self
.download_episode_list
, \
3258 _playback_episodes
=self
.playback_episodes
, \
3259 _delete_episode_list
=self
.delete_episode_list
, \
3260 _episode_list_status_changed
=self
.episode_list_status_changed
, \
3261 _cancel_task_list
=self
.cancel_task_list
, \
3262 _streaming_possible
=self
.streaming_possible())
3263 self
.episode_shownotes_window
.show(episode
)
3264 if episode
.downloading
:
3265 self
.update_downloads_list()
3267 def restart_auto_update_timer(self
):
3268 if self
._auto
_update
_timer
_source
_id
is not None:
3269 logger
.debug('Removing existing auto update timer.')
3270 gobject
.source_remove(self
._auto
_update
_timer
_source
_id
)
3271 self
._auto
_update
_timer
_source
_id
= None
3273 if self
.config
.auto_update_feeds
and \
3274 self
.config
.auto_update_frequency
:
3275 interval
= 60*1000*self
.config
.auto_update_frequency
3276 logger
.debug('Setting up auto update timer with interval %d.',
3277 self
.config
.auto_update_frequency
)
3278 self
._auto
_update
_timer
_source
_id
= gobject
.timeout_add(\
3279 interval
, self
._on
_auto
_update
_timer
)
3281 def _on_auto_update_timer(self
):
3282 logger
.debug('Auto update timer fired.')
3283 self
.update_feed_cache()
3285 # Ask web service for sub changes (if enabled)
3286 self
.mygpo_client
.flush()
3290 def on_treeDownloads_row_activated(self
, widget
, *args
):
3291 # Use the standard way of working on the treeview
3292 selection
= self
.treeDownloads
.get_selection()
3293 (model
, paths
) = selection
.get_selected_rows()
3294 selected_tasks
= [(gtk
.TreeRowReference(model
, path
), model
.get_value(model
.get_iter(path
), 0)) for path
in paths
]
3296 for tree_row_reference
, task
in selected_tasks
:
3297 if task
.status
in (task
.DOWNLOADING
, task
.QUEUED
):
3298 task
.status
= task
.PAUSED
3299 elif task
.status
in (task
.CANCELLED
, task
.PAUSED
, task
.FAILED
):
3300 self
.download_queue_manager
.add_task(task
)
3301 self
.enable_download_list_update()
3302 elif task
.status
== task
.DONE
:
3303 model
.remove(model
.get_iter(tree_row_reference
.get_path()))
3305 self
.play_or_download()
3307 # Update the tab title and downloads list
3308 self
.update_downloads_list()
3310 def on_item_cancel_download_activate(self
, widget
):
3311 if self
.wNotebook
.get_current_page() == 0:
3312 selection
= self
.treeAvailable
.get_selection()
3313 (model
, paths
) = selection
.get_selected_rows()
3314 urls
= [model
.get_value(model
.get_iter(path
), \
3315 self
.episode_list_model
.C_URL
) for path
in paths
]
3316 selected_tasks
= [task
for task
in self
.download_tasks_seen \
3317 if task
.url
in urls
]
3319 selection
= self
.treeDownloads
.get_selection()
3320 (model
, paths
) = selection
.get_selected_rows()
3321 selected_tasks
= [model
.get_value(model
.get_iter(path
), \
3322 self
.download_status_model
.C_TASK
) for path
in paths
]
3323 self
.cancel_task_list(selected_tasks
)
3325 def on_btnCancelAll_clicked(self
, widget
, *args
):
3326 self
.cancel_task_list(self
.download_tasks_seen
)
3328 def on_btnDownloadedDelete_clicked(self
, widget
, *args
):
3329 episodes
= self
.get_selected_episodes()
3330 if len(episodes
) == 1:
3331 self
.delete_episode_list(episodes
, skip_locked
=False)
3333 self
.delete_episode_list(episodes
)
3335 def on_key_press(self
, widget
, event
):
3336 # Allow tab switching with Ctrl + PgUp/PgDown/Tab
3337 if event
.state
& gtk
.gdk
.CONTROL_MASK
:
3338 if event
.keyval
== gtk
.keysyms
.Page_Up
:
3339 self
.wNotebook
.prev_page()
3341 elif event
.keyval
== gtk
.keysyms
.Page_Down
:
3342 self
.wNotebook
.next_page()
3344 elif event
.keyval
== gtk
.keysyms
.Tab
:
3345 current_page
= self
.wNotebook
.get_current_page()
3347 if current_page
== self
.wNotebook
.get_n_pages()-1:
3348 self
.wNotebook
.set_current_page(0)
3350 self
.wNotebook
.next_page()
3355 def uniconify_main_window(self
):
3356 if self
.is_iconified():
3357 # We need to hide and then show the window in WMs like Metacity
3358 # or KWin4 to move the window to the active workspace
3359 # (see http://gpodder.org/bug/1125)
3362 self
.gPodder
.present()
3364 def iconify_main_window(self
):
3365 if not self
.is_iconified():
3366 self
.gPodder
.iconify()
3368 @dbus.service
.method(gpodder
.dbus_interface
)
3369 def show_gui_window(self
):
3370 parent
= self
.get_dialog_parent()
3373 @dbus.service
.method(gpodder
.dbus_interface
)
3374 def subscribe_to_url(self
, url
):
3375 gPodderAddPodcast(self
.gPodder
,
3376 add_urls_callback
=self
.add_podcast_list
,
3379 @dbus.service
.method(gpodder
.dbus_interface
)
3380 def mark_episode_played(self
, filename
):
3381 if filename
is None:
3384 for channel
in self
.channels
:
3385 for episode
in channel
.get_all_episodes():
3386 fn
= episode
.local_filename(create
=False, check_only
=True)
3388 episode
.mark(is_played
=True)
3390 self
.update_episode_list_icons([episode
.url
])
3391 self
.update_podcast_list_model([episode
.channel
.url
])
3396 def extensions_podcast_update_cb(self
, podcast
):
3397 logger
.debug('extensions_podcast_update_cb(%s)', podcast
)
3398 self
.update_feed_cache(channels
=[podcast
],
3399 show_new_episodes_dialog
=False)
3401 def extensions_episode_download_cb(self
, episode
):
3402 logger
.debug('extension_episode_download_cb(%s)', episode
)
3403 self
.download_episode_list(episodes
=[episode
])
3405 def main(options
=None):
3406 gobject
.threads_init()
3407 gobject
.set_application_name('gPodder')
3409 for i
in range(EpisodeListModel
.PROGRESS_STEPS
+ 1):
3410 pixbuf
= draw_cake_pixbuf(float(i
) /
3411 float(EpisodeListModel
.PROGRESS_STEPS
))
3412 icon_name
= 'gpodder-progress-%d' % i
3413 gtk
.icon_theme_add_builtin_icon(icon_name
, pixbuf
.get_width(), pixbuf
)
3415 gtk
.window_set_default_icon_name('gpodder')
3416 gtk
.about_dialog_set_url_hook(lambda dlg
, link
, data
: util
.open_website(link
), None)
3419 dbus_main_loop
= dbus
.glib
.DBusGMainLoop(set_as_default
=True)
3420 gpodder
.dbus_session_bus
= dbus
.SessionBus(dbus_main_loop
)
3422 bus_name
= dbus
.service
.BusName(gpodder
.dbus_bus_name
, bus
=gpodder
.dbus_session_bus
)
3423 except dbus
.exceptions
.DBusException
, dbe
:
3424 logger
.warn('Cannot get "on the bus".', exc_info
=True)
3425 dlg
= gtk
.MessageDialog(None, gtk
.DIALOG_MODAL
, gtk
.MESSAGE_ERROR
, \
3426 gtk
.BUTTONS_CLOSE
, _('Cannot start gPodder'))
3427 dlg
.format_secondary_markup(_('D-Bus error: %s') % (str(dbe
),))
3428 dlg
.set_title('gPodder')
3433 gp
= gPodder(bus_name
, core
.Core(UIConfig
, model_class
=Model
))
3436 if options
.subscribe
:
3437 util
.idle_add(gp
.subscribe_to_url
, options
.subscribe
)
3440 from gpodder
.gtkui
import macosx
3442 # Handle "subscribe to podcast" events from firefox
3443 macosx
.register_handlers(gp
)
3446 if macapp
is not None:
3447 macapp
.connect('NSApplicationBlockTermination', gp
.quit_cb
)