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
71 from gpodder
.gtkui
.interface
.common
import BuilderWidget
72 from gpodder
.gtkui
.interface
.common
import TreeViewHelper
73 from gpodder
.gtkui
.interface
.addpodcast
import gPodderAddPodcast
75 from gpodder
.gtkui
.download
import DownloadStatusModel
77 from gpodder
.gtkui
.desktop
.welcome
import gPodderWelcome
78 from gpodder
.gtkui
.desktop
.channel
import gPodderChannel
79 from gpodder
.gtkui
.desktop
.preferences
import gPodderPreferences
80 from gpodder
.gtkui
.desktop
.shownotes
import gPodderShownotes
81 from gpodder
.gtkui
.desktop
.episodeselector
import gPodderEpisodeSelector
82 from gpodder
.gtkui
.desktop
.podcastdirectory
import gPodderPodcastDirectory
83 from gpodder
.gtkui
.interface
.progress
import ProgressIndicator
85 from gpodder
.dbusproxy
import DBusPodcastsProxy
86 from gpodder
import extensions
88 class gPodder(BuilderWidget
, dbus
.service
.Object
):
89 # Delay until live search is started after typing stop
90 LIVE_SEARCH_DELAY
= 500
92 # Width (in pixels) of episode list icon
93 EPISODE_LIST_ICON_WIDTH
= 40
95 def __init__(self
, bus_name
, gpodder_core
):
96 dbus
.service
.Object
.__init
__(self
, object_path
=gpodder
.dbus_gui_object_path
, bus_name
=bus_name
)
97 self
.podcasts_proxy
= DBusPodcastsProxy(lambda: self
.channels
,
98 self
.on_itemUpdate_activate
,
99 self
.playback_episodes
,
100 self
.download_episode_list
,
101 self
.episode_object_by_uri
,
103 self
.core
= gpodder_core
104 self
.config
= self
.core
.config
105 self
.db
= self
.core
.db
106 self
.model
= self
.core
.model
107 BuilderWidget
.__init
__(self
, None)
110 self
.toolbar
.set_property('visible', self
.config
.show_toolbar
)
112 self
.bluetooth_available
= util
.bluetooth_available()
114 self
.config
.connect_gtk_window(self
.main_window
, 'main_window')
116 self
.config
.connect_gtk_paned('paned_position', self
.channelPaned
)
118 self
.main_window
.show()
120 self
.player_receiver
= player
.MediaPlayerDBusReceiver(self
.on_played
)
122 self
.gPodder
.connect('key-press-event', self
.on_key_press
)
124 self
.episode_columns_menu
= None
125 self
.config
.add_observer(self
.on_config_changed
)
127 self
.episode_shownotes_window
= None
128 self
.new_episodes_window
= None
131 from gpodder
.gtkui
import ubuntu
132 self
.ubuntu
= ubuntu
.LauncherEntry()
136 # Mac OS X-specific UI tweaks: Native main menu integration
137 # http://sourceforge.net/apps/trac/gtk-osx/wiki/Integrate
138 if getattr(gtk
.gdk
, 'WINDOWING', 'x11') == 'quartz':
140 import igemacintegration
as igemi
142 # Move the menu bar from the window to the Mac menu bar
144 igemi
.ige_mac_menu_set_menu_bar(self
.mainMenu
)
146 # Reparent some items to the "Application" menu
147 for widget
in ('/mainMenu/menuHelp/itemAbout',
148 '/mainMenu/menuPodcasts/itemPreferences'):
149 item
= self
.uimanager1
.get_widget(widget
)
150 group
= igemi
.ige_mac_menu_add_app_menu_group()
151 igemi
.ige_mac_menu_add_app_menu_item(group
, item
, None)
153 quit_widget
= '/mainMenu/menuPodcasts/itemQuit'
154 quit_item
= self
.uimanager1
.get_widget(quit_widget
)
155 igemi
.ige_mac_menu_set_quit_menu_item(quit_item
)
157 print >>sys
.stderr
, """
158 Warning: ige-mac-integration not found - no native menus.
161 self
.download_status_model
= DownloadStatusModel()
162 self
.download_queue_manager
= download
.DownloadQueueManager(self
.config
)
164 self
.itemShowAllEpisodes
.set_active(self
.config
.podcast_list_view_all
)
165 self
.itemShowToolbar
.set_active(self
.config
.show_toolbar
)
166 self
.itemShowDescription
.set_active(self
.config
.episode_list_descriptions
)
168 self
.config
.connect_gtk_spinbutton('max_downloads', self
.spinMaxDownloads
)
169 self
.config
.connect_gtk_togglebutton('max_downloads_enabled', self
.cbMaxDownloads
)
170 self
.config
.connect_gtk_spinbutton('limit_rate_value', self
.spinLimitDownloads
)
171 self
.config
.connect_gtk_togglebutton('limit_rate', self
.cbLimitDownloads
)
173 self
.config
.connect_gtk_togglebutton('podcast_list_sections', self
.item_podcast_sections
)
175 # When the amount of maximum downloads changes, notify the queue manager
176 changed_cb
= lambda spinbutton
: self
.download_queue_manager
.spawn_threads()
177 self
.spinMaxDownloads
.connect('value-changed', changed_cb
)
179 self
.default_title
= None
180 self
.set_title(_('gPodder'))
182 self
.cover_downloader
= CoverDownloader()
184 # Generate list models for podcasts and their episodes
185 self
.podcast_list_model
= PodcastListModel(self
.cover_downloader
)
187 self
.cover_downloader
.register('cover-available', self
.cover_download_finished
)
188 self
.cover_downloader
.register('cover-removed', self
.cover_file_removed
)
190 # Source IDs for timeouts for search-as-you-type
191 self
._podcast
_list
_search
_timeout
= None
192 self
._episode
_list
_search
_timeout
= None
194 # Init the treeviews that we use
195 self
.init_podcast_list_treeview()
196 self
.init_episode_list_treeview()
197 self
.init_download_list_treeview()
199 if self
.config
.podcast_list_hide_boring
:
200 self
.item_view_hide_boring_podcasts
.set_active(True)
202 self
.currently_updating
= False
204 self
.download_tasks_seen
= set()
205 self
.download_list_update_enabled
= False
206 self
.download_task_monitors
= set()
208 # Subscribed channels
209 self
.active_channel
= None
210 self
.channels
= self
.model
.get_podcasts()
212 gpodder
.user_extensions
.on_ui_initialized(self
.model
,
213 self
.extensions_podcast_update_cb
,
214 self
.extensions_episode_download_cb
)
216 # load list of user applications for audio playback
217 self
.user_apps_reader
= UserAppsReader(['audio', 'video'])
218 threading
.Thread(target
=self
.user_apps_reader
.read
).start()
220 # Set up the first instance of MygPoClient
221 self
.mygpo_client
= my
.MygPoClient(self
.config
)
223 # Now, update the feed cache, when everything's in place
224 self
.btnUpdateFeeds
.show()
225 self
.feed_cache_update_cancelled
= False
226 self
.update_podcast_list_model()
228 self
.message_area
= None
230 def find_partial_downloads():
231 # Look for partial file downloads
232 partial_files
= glob
.glob(os
.path
.join(gpodder
.downloads
, '*', '*.partial'))
233 count
= len(partial_files
)
234 resumable_episodes
= []
236 util
.idle_add(self
.wNotebook
.set_current_page
, 1)
237 indicator
= ProgressIndicator(_('Loading incomplete downloads'),
238 _('Some episodes have not finished downloading in a previous session.'),
239 False, self
.get_dialog_parent())
240 indicator
.on_message(N_('%(count)d partial file', '%(count)d partial files', count
) % {'count':count
})
242 candidates
= [f
[:-len('.partial')] for f
in partial_files
]
245 for c
in self
.channels
:
246 for e
in c
.get_all_episodes():
247 filename
= e
.local_filename(create
=False, check_only
=True)
248 if filename
in candidates
:
250 indicator
.on_message(e
.title
)
251 indicator
.on_progress(float(found
)/count
)
252 candidates
.remove(filename
)
253 partial_files
.remove(filename
+'.partial')
255 if os
.path
.exists(filename
):
256 # The file has already been downloaded;
257 # remove the leftover partial file
258 util
.delete_file(filename
+'.partial')
260 resumable_episodes
.append(e
)
268 for f
in partial_files
:
269 logger
.warn('Partial file without episode: %s', f
)
272 util
.idle_add(indicator
.on_finished
)
274 if len(resumable_episodes
):
275 def offer_resuming():
276 self
.download_episode_list_paused(resumable_episodes
)
277 resume_all
= gtk
.Button(_('Resume all'))
278 def on_resume_all(button
):
279 selection
= self
.treeDownloads
.get_selection()
280 selection
.select_all()
281 selected_tasks
, can_queue
, can_cancel
, can_pause
, can_remove
, can_force
= self
.downloads_list_get_selection()
282 selection
.unselect_all()
283 self
._for
_each
_task
_set
_status
(selected_tasks
, download
.DownloadTask
.QUEUED
)
284 self
.message_area
.hide()
285 resume_all
.connect('clicked', on_resume_all
)
287 self
.message_area
= SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all
,))
288 self
.vboxDownloadStatusWidgets
.pack_start(self
.message_area
, expand
=False)
289 self
.vboxDownloadStatusWidgets
.reorder_child(self
.message_area
, 0)
290 self
.message_area
.show_all()
291 self
.clean_up_downloads(delete_partial
=False)
292 util
.idle_add(offer_resuming
)
294 util
.idle_add(self
.wNotebook
.set_current_page
, 0)
296 util
.idle_add(self
.clean_up_downloads
, True)
297 threading
.Thread(target
=find_partial_downloads
).start()
299 # Start the auto-update procedure
300 self
._auto
_update
_timer
_source
_id
= None
301 if self
.config
.auto_update_feeds
:
302 self
.restart_auto_update_timer()
304 # Find expired (old) episodes and delete them
305 old_episodes
= list(self
.get_expired_episodes())
306 if len(old_episodes
) > 0:
307 self
.delete_episode_list(old_episodes
, confirm
=False)
308 updated_urls
= set(e
.channel
.url
for e
in old_episodes
)
309 self
.update_podcast_list_model(updated_urls
)
311 # Do the initial sync with the web service
312 util
.idle_add(self
.mygpo_client
.flush
, True)
314 # First-time users should be asked if they want to see the OPML
315 if not self
.channels
:
316 self
.on_itemUpdate_activate()
318 def episode_object_by_uri(self
, uri
):
319 """Get an episode object given a local or remote URI
321 This can be used to quickly access an episode object
322 when all we have is its download filename or episode
323 URL (e.g. from external D-Bus calls / signals, etc..)
325 if uri
.startswith('/'):
326 uri
= 'file://' + urllib
.quote(uri
)
328 prefix
= 'file://' + urllib
.quote(gpodder
.downloads
)
330 # By default, assume we can't pre-select any channel
331 # but can match episodes simply via the download URL
332 is_channel
= lambda c
: True
333 is_episode
= lambda e
: e
.url
== uri
335 if uri
.startswith(prefix
):
336 # File is on the local filesystem in the download folder
337 # Try to reduce search space by pre-selecting the channel
338 # based on the folder name of the local file
340 filename
= urllib
.unquote(uri
[len(prefix
):])
341 file_parts
= filter(None, filename
.split(os
.sep
))
343 if len(file_parts
) != 2:
346 foldername
, filename
= file_parts
348 is_channel
= lambda c
: c
.download_folder
== foldername
349 is_episode
= lambda e
: e
.download_filename
== filename
351 # Deep search through channels and episodes for a match
352 for channel
in filter(is_channel
, self
.channels
):
353 for episode
in filter(is_episode
, channel
.get_all_episodes()):
358 def on_played(self
, start
, end
, total
, file_uri
):
359 """Handle the "played" signal from a media player"""
360 if start
== 0 and end
== 0 and total
== 0:
361 # Ignore bogus play event
363 elif end
< start
+ 5:
364 # Ignore "less than five seconds" segments,
365 # as they can happen with seeking, etc...
368 logger
.debug('Received play action: %s (%d, %d, %d)', file_uri
, start
, end
, total
)
369 episode
= self
.episode_object_by_uri(file_uri
)
371 if episode
is not None:
372 file_type
= episode
.file_type()
376 episode
.total_time
= total
378 # Assume the episode's total time for the action
379 total
= episode
.total_time
381 assert (episode
.current_position_updated
is None or
382 now
>= episode
.current_position_updated
)
384 episode
.current_position
= end
385 episode
.current_position_updated
= now
386 episode
.mark(is_played
=True)
389 self
.update_episode_list_icons([episode
.url
])
390 self
.update_podcast_list_model([episode
.channel
.url
])
392 # Submit this action to the webservice
393 self
.mygpo_client
.on_playback_full(episode
, start
, end
, total
)
395 def on_add_remove_podcasts_mygpo(self
):
396 actions
= self
.mygpo_client
.get_received_actions()
400 existing_urls
= [c
.url
for c
in self
.channels
]
402 # Columns for the episode selector window - just one...
404 ('description', None, None, _('Action')),
407 # A list of actions that have to be chosen from
410 # Actions that are ignored (already carried out)
413 for action
in actions
:
414 if action
.is_add
and action
.url
not in existing_urls
:
415 changes
.append(my
.Change(action
))
416 elif action
.is_remove
and action
.url
in existing_urls
:
417 podcast_object
= None
418 for podcast
in self
.channels
:
419 if podcast
.url
== action
.url
:
420 podcast_object
= podcast
422 changes
.append(my
.Change(action
, podcast_object
))
424 ignored
.append(action
)
426 # Confirm all ignored changes
427 self
.mygpo_client
.confirm_received_actions(ignored
)
429 def execute_podcast_actions(selected
):
430 add_list
= [c
.action
.url
for c
in selected
if c
.action
.is_add
]
431 remove_list
= [c
.podcast
for c
in selected
if c
.action
.is_remove
]
433 # Apply the accepted changes locally
434 self
.add_podcast_list(add_list
)
435 self
.remove_podcast_list(remove_list
, confirm
=False)
437 # All selected items are now confirmed
438 self
.mygpo_client
.confirm_received_actions(c
.action
for c
in selected
)
440 # Revert the changes on the server
441 rejected
= [c
.action
for c
in changes
if c
not in selected
]
442 self
.mygpo_client
.reject_received_actions(rejected
)
445 # We're abusing the Episode Selector again ;) -- thp
446 gPodderEpisodeSelector(self
.main_window
, \
447 title
=_('Confirm changes from gpodder.net'), \
448 instructions
=_('Select the actions you want to carry out.'), \
451 size_attribute
=None, \
452 stock_ok_button
=gtk
.STOCK_APPLY
, \
453 callback
=execute_podcast_actions
, \
456 # There are some actions that need the user's attention
461 # We have no remaining actions - no selection happens
464 def rewrite_urls_mygpo(self
):
465 # Check if we have to rewrite URLs since the last add
466 rewritten_urls
= self
.mygpo_client
.get_rewritten_urls()
469 for rewritten_url
in rewritten_urls
:
470 if not rewritten_url
.new_url
:
473 for channel
in self
.channels
:
474 if channel
.url
== rewritten_url
.old_url
:
475 logger
.info('Updating URL of %s to %s', channel
,
476 rewritten_url
.new_url
)
477 channel
.url
= rewritten_url
.new_url
483 util
.idle_add(self
.update_episode_list_model
)
485 def on_send_full_subscriptions(self
):
486 # Send the full subscription list to the gpodder.net client
487 # (this will overwrite the subscription list on the server)
488 indicator
= ProgressIndicator(_('Uploading subscriptions'), \
489 _('Your subscriptions are being uploaded to the server.'), \
490 False, self
.get_dialog_parent())
493 self
.mygpo_client
.set_subscriptions([c
.url
for c
in self
.channels
])
494 util
.idle_add(self
.show_message
, _('List uploaded successfully.'))
499 message
= e
.__class
__.__name
__
500 self
.show_message(message
, \
501 _('Error while uploading'), \
503 util
.idle_add(show_error
, e
)
505 util
.idle_add(indicator
.on_finished
)
507 def on_podcast_selected(self
, treeview
, path
, column
):
509 model
= treeview
.get_model()
510 channel
= model
.get_value(model
.get_iter(path
), \
511 PodcastListModel
.C_CHANNEL
)
512 self
.active_channel
= channel
513 self
.update_episode_list_model()
514 self
.episodes_window
.channel
= self
.active_channel
515 self
.episodes_window
.show()
517 def on_button_subscribe_clicked(self
, button
):
518 self
.on_itemImportChannels_activate(button
)
520 def on_button_downloads_clicked(self
, widget
):
521 self
.downloads_window
.show()
523 def for_each_episode_set_task_status(self
, episodes
, status
):
524 episode_urls
= set(episode
.url
for episode
in episodes
)
525 model
= self
.treeDownloads
.get_model()
526 selected_tasks
= [(gtk
.TreeRowReference(model
, row
.path
), \
527 model
.get_value(row
.iter, \
528 DownloadStatusModel
.C_TASK
)) for row
in model \
529 if model
.get_value(row
.iter, DownloadStatusModel
.C_TASK
).url \
531 self
._for
_each
_task
_set
_status
(selected_tasks
, status
)
533 def on_treeview_button_pressed(self
, treeview
, event
):
534 if event
.window
!= treeview
.get_bin_window():
537 role
= getattr(treeview
, TreeViewHelper
.ROLE
)
538 if role
== TreeViewHelper
.ROLE_PODCASTS
:
539 return self
.currently_updating
540 elif (role
== TreeViewHelper
.ROLE_EPISODES
and event
.button
== 1):
541 # Toggle episode "new" status by clicking the icon (bug 1432)
542 result
= treeview
.get_path_at_pos(int(event
.x
), int(event
.y
))
543 if result
is not None:
544 path
, column
, x
, y
= result
545 # The user clicked the icon if she clicked in the first column
546 # and the x position is in the area where the icon resides
547 if (x
< self
.EPISODE_LIST_ICON_WIDTH
and
548 column
== treeview
.get_columns()[0]):
549 model
= treeview
.get_model()
550 cursor_episode
= model
.get_value(model
.get_iter(path
),
551 EpisodeListModel
.C_EPISODE
)
553 new_value
= cursor_episode
.is_new
554 selected_episodes
= self
.get_selected_episodes()
556 # Avoid changing anything if the clicked episode is not
557 # selected already - otherwise update all selected
558 if cursor_episode
in selected_episodes
:
559 for episode
in selected_episodes
:
560 episode
.mark(is_played
=new_value
)
562 self
.update_episode_list_icons(selected
=True)
563 self
.update_podcast_list_model(selected
=True)
566 return event
.button
== 3
568 def on_treeview_podcasts_button_released(self
, treeview
, event
):
569 if event
.window
!= treeview
.get_bin_window():
572 return self
.treeview_channels_show_context_menu(treeview
, event
)
574 def on_treeview_episodes_button_released(self
, treeview
, event
):
575 if event
.window
!= treeview
.get_bin_window():
578 return self
.treeview_available_show_context_menu(treeview
, event
)
580 def on_treeview_downloads_button_released(self
, treeview
, event
):
581 if event
.window
!= treeview
.get_bin_window():
584 return self
.treeview_downloads_show_context_menu(treeview
, event
)
586 def on_entry_search_podcasts_changed(self
, editable
):
587 if self
.hbox_search_podcasts
.get_property('visible'):
588 def set_search_term(self
, text
):
589 self
.podcast_list_model
.set_search_term(text
)
590 self
._podcast
_list
_search
_timeout
= None
593 if self
._podcast
_list
_search
_timeout
is not None:
594 gobject
.source_remove(self
._podcast
_list
_search
_timeout
)
595 self
._podcast
_list
_search
_timeout
= gobject
.timeout_add(\
596 self
.LIVE_SEARCH_DELAY
, \
597 set_search_term
, self
, editable
.get_chars(0, -1))
599 def on_entry_search_podcasts_key_press(self
, editable
, event
):
600 if event
.keyval
== gtk
.keysyms
.Escape
:
601 self
.hide_podcast_search()
604 def hide_podcast_search(self
, *args
):
605 if self
._podcast
_list
_search
_timeout
is not None:
606 gobject
.source_remove(self
._podcast
_list
_search
_timeout
)
607 self
._podcast
_list
_search
_timeout
= None
608 self
.hbox_search_podcasts
.hide()
609 self
.entry_search_podcasts
.set_text('')
610 self
.podcast_list_model
.set_search_term(None)
611 self
.treeChannels
.grab_focus()
613 def show_podcast_search(self
, input_char
):
614 self
.hbox_search_podcasts
.show()
615 self
.entry_search_podcasts
.insert_text(input_char
, -1)
616 self
.entry_search_podcasts
.grab_focus()
617 self
.entry_search_podcasts
.set_position(-1)
619 def init_podcast_list_treeview(self
):
620 # Set up podcast channel tree view widget
621 column
= gtk
.TreeViewColumn('')
622 iconcell
= gtk
.CellRendererPixbuf()
623 iconcell
.set_property('width', 45)
624 column
.pack_start(iconcell
, False)
625 column
.add_attribute(iconcell
, 'pixbuf', PodcastListModel
.C_COVER
)
626 column
.add_attribute(iconcell
, 'visible', PodcastListModel
.C_COVER_VISIBLE
)
628 namecell
= gtk
.CellRendererText()
629 namecell
.set_property('ellipsize', pango
.ELLIPSIZE_END
)
630 column
.pack_start(namecell
, True)
631 column
.add_attribute(namecell
, 'markup', PodcastListModel
.C_DESCRIPTION
)
633 iconcell
= gtk
.CellRendererPixbuf()
634 iconcell
.set_property('xalign', 1.0)
635 column
.pack_start(iconcell
, False)
636 column
.add_attribute(iconcell
, 'pixbuf', PodcastListModel
.C_PILL
)
637 column
.add_attribute(iconcell
, 'visible', PodcastListModel
.C_PILL_VISIBLE
)
639 self
.treeChannels
.append_column(column
)
641 self
.treeChannels
.set_model(self
.podcast_list_model
.get_filtered_model())
643 # When no podcast is selected, clear the episode list model
644 selection
= self
.treeChannels
.get_selection()
645 def select_function(selection
, model
, path
, path_currently_selected
):
646 url
= model
.get_value(model
.get_iter(path
), PodcastListModel
.C_URL
)
648 selection
.set_select_function(select_function
, full
=True)
650 # Set up type-ahead find for the podcast list
651 def on_key_press(treeview
, event
):
652 if event
.keyval
== gtk
.keysyms
.Right
:
653 self
.treeAvailable
.grab_focus()
654 elif event
.keyval
in (gtk
.keysyms
.Up
, gtk
.keysyms
.Down
):
655 # If section markers exist in the treeview, we want to
656 # "jump over" them when moving the cursor up and down
657 selection
= self
.treeChannels
.get_selection()
658 model
, it
= selection
.get_selected()
660 if event
.keyval
== gtk
.keysyms
.Up
:
665 path
= model
.get_path(it
)
667 path
= (path
[0]+step
,)
670 # Valid paths must have a value >= 0
674 it
= model
.get_iter(path
)
676 # Already at the end of the list
679 if model
.get_value(it
, PodcastListModel
.C_URL
) != '-':
682 self
.treeChannels
.set_cursor(path
)
683 elif event
.keyval
== gtk
.keysyms
.Escape
:
684 self
.hide_podcast_search()
685 elif event
.state
& gtk
.gdk
.CONTROL_MASK
:
686 # Don't handle type-ahead when control is pressed (so shortcuts
687 # with the Ctrl key still work, e.g. Ctrl+A, ...)
690 unicode_char_id
= gtk
.gdk
.keyval_to_unicode(event
.keyval
)
691 if unicode_char_id
== 0:
693 input_char
= unichr(unicode_char_id
)
694 self
.show_podcast_search(input_char
)
696 self
.treeChannels
.connect('key-press-event', on_key_press
)
698 self
.treeChannels
.connect('popup-menu', self
.treeview_channels_show_context_menu
)
700 # Enable separators to the podcast list to separate special podcasts
701 # from others (this is used for the "all episodes" view)
702 self
.treeChannels
.set_row_separator_func(PodcastListModel
.row_separator_func
)
704 TreeViewHelper
.set(self
.treeChannels
, TreeViewHelper
.ROLE_PODCASTS
)
706 def on_entry_search_episodes_changed(self
, editable
):
707 if self
.hbox_search_episodes
.get_property('visible'):
708 def set_search_term(self
, text
):
709 self
.episode_list_model
.set_search_term(text
)
710 self
._episode
_list
_search
_timeout
= None
713 if self
._episode
_list
_search
_timeout
is not None:
714 gobject
.source_remove(self
._episode
_list
_search
_timeout
)
715 self
._episode
_list
_search
_timeout
= gobject
.timeout_add(\
716 self
.LIVE_SEARCH_DELAY
, \
717 set_search_term
, self
, editable
.get_chars(0, -1))
719 def on_entry_search_episodes_key_press(self
, editable
, event
):
720 if event
.keyval
== gtk
.keysyms
.Escape
:
721 self
.hide_episode_search()
724 def hide_episode_search(self
, *args
):
725 if self
._episode
_list
_search
_timeout
is not None:
726 gobject
.source_remove(self
._episode
_list
_search
_timeout
)
727 self
._episode
_list
_search
_timeout
= None
728 self
.hbox_search_episodes
.hide()
729 self
.entry_search_episodes
.set_text('')
730 self
.episode_list_model
.set_search_term(None)
731 self
.treeAvailable
.grab_focus()
733 def show_episode_search(self
, input_char
):
734 self
.hbox_search_episodes
.show()
735 self
.entry_search_episodes
.insert_text(input_char
, -1)
736 self
.entry_search_episodes
.grab_focus()
737 self
.entry_search_episodes
.set_position(-1)
739 def set_episode_list_column(self
, index
, new_value
):
742 self
.config
.episode_list_columns |
= mask
744 self
.config
.episode_list_columns
&= ~mask
746 def update_episode_list_columns_visibility(self
):
747 columns
= TreeViewHelper
.get_columns(self
.treeAvailable
)
748 for index
, column
in enumerate(columns
):
749 visible
= bool(self
.config
.episode_list_columns
& (1 << index
))
750 column
.set_visible(visible
)
751 self
.treeAvailable
.columns_autosize()
753 if self
.episode_columns_menu
is not None:
754 children
= self
.episode_columns_menu
.get_children()
755 for index
, child
in enumerate(children
):
756 active
= bool(self
.config
.episode_list_columns
& (1 << index
))
757 child
.set_active(active
)
759 def on_episode_list_header_clicked(self
, button
, event
):
760 if event
.button
!= 3:
763 if self
.episode_columns_menu
is not None:
764 self
.episode_columns_menu
.popup(None, None, None, event
.button
, \
769 def init_episode_list_treeview(self
):
770 # For loading the list model
771 self
.episode_list_model
= EpisodeListModel(self
.on_episode_list_filter_changed
)
773 if self
.config
.episode_list_view_mode
== EpisodeListModel
.VIEW_UNDELETED
:
774 self
.item_view_episodes_undeleted
.set_active(True)
775 elif self
.config
.episode_list_view_mode
== EpisodeListModel
.VIEW_DOWNLOADED
:
776 self
.item_view_episodes_downloaded
.set_active(True)
777 elif self
.config
.episode_list_view_mode
== EpisodeListModel
.VIEW_UNPLAYED
:
778 self
.item_view_episodes_unplayed
.set_active(True)
780 self
.item_view_episodes_all
.set_active(True)
782 self
.episode_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
784 self
.treeAvailable
.set_model(self
.episode_list_model
.get_filtered_model())
786 TreeViewHelper
.set(self
.treeAvailable
, TreeViewHelper
.ROLE_EPISODES
)
788 iconcell
= gtk
.CellRendererPixbuf()
789 iconcell
.set_property('stock-size', gtk
.ICON_SIZE_BUTTON
)
790 iconcell
.set_fixed_size(self
.EPISODE_LIST_ICON_WIDTH
, -1)
792 namecell
= gtk
.CellRendererText()
793 namecell
.set_property('ellipsize', pango
.ELLIPSIZE_END
)
794 namecolumn
= gtk
.TreeViewColumn(_('Episode'))
795 namecolumn
.pack_start(iconcell
, False)
796 namecolumn
.add_attribute(iconcell
, 'icon-name', EpisodeListModel
.C_STATUS_ICON
)
797 namecolumn
.pack_start(namecell
, True)
798 namecolumn
.add_attribute(namecell
, 'markup', EpisodeListModel
.C_DESCRIPTION
)
799 namecolumn
.set_sort_column_id(EpisodeListModel
.C_DESCRIPTION
)
800 namecolumn
.set_sizing(gtk
.TREE_VIEW_COLUMN_AUTOSIZE
)
801 namecolumn
.set_resizable(True)
802 namecolumn
.set_expand(True)
804 lockcell
= gtk
.CellRendererPixbuf()
805 lockcell
.set_fixed_size(40, -1)
806 lockcell
.set_property('stock-size', gtk
.ICON_SIZE_MENU
)
807 lockcell
.set_property('icon-name', 'emblem-readonly')
808 namecolumn
.pack_start(lockcell
, False)
809 namecolumn
.add_attribute(lockcell
, 'visible', EpisodeListModel
.C_LOCKED
)
811 sizecell
= gtk
.CellRendererText()
812 sizecell
.set_property('xalign', 1)
813 sizecolumn
= gtk
.TreeViewColumn(_('Size'), sizecell
, text
=EpisodeListModel
.C_FILESIZE_TEXT
)
814 sizecolumn
.set_sort_column_id(EpisodeListModel
.C_FILESIZE
)
816 timecell
= gtk
.CellRendererText()
817 timecell
.set_property('xalign', 1)
818 timecolumn
= gtk
.TreeViewColumn(_('Duration'), timecell
, text
=EpisodeListModel
.C_TIME
)
819 timecolumn
.set_sort_column_id(EpisodeListModel
.C_TOTAL_TIME
)
821 releasecell
= gtk
.CellRendererText()
822 releasecolumn
= gtk
.TreeViewColumn(_('Released'), releasecell
, text
=EpisodeListModel
.C_PUBLISHED_TEXT
)
823 releasecolumn
.set_sort_column_id(EpisodeListModel
.C_PUBLISHED
)
825 namecolumn
.set_reorderable(True)
826 self
.treeAvailable
.append_column(namecolumn
)
828 for itemcolumn
in (sizecolumn
, timecolumn
, releasecolumn
):
829 itemcolumn
.set_reorderable(True)
830 self
.treeAvailable
.append_column(itemcolumn
)
831 TreeViewHelper
.register_column(self
.treeAvailable
, itemcolumn
)
833 # Add context menu to all tree view column headers
834 for column
in self
.treeAvailable
.get_columns():
835 label
= gtk
.Label(column
.get_title())
837 column
.set_widget(label
)
839 w
= column
.get_widget()
840 while w
is not None and not isinstance(w
, gtk
.Button
):
843 w
.connect('button-release-event', self
.on_episode_list_header_clicked
)
845 # Create a new menu for the visible episode list columns
846 for child
in self
.mainMenu
.get_children():
847 if child
.get_name() == 'menuView':
848 submenu
= child
.get_submenu()
849 item
= gtk
.MenuItem(_('Visible columns'))
850 submenu
.append(gtk
.SeparatorMenuItem())
854 self
.episode_columns_menu
= gtk
.Menu()
855 item
.set_submenu(self
.episode_columns_menu
)
858 # For each column that can be shown/hidden, add a menu item
859 columns
= TreeViewHelper
.get_columns(self
.treeAvailable
)
860 for index
, column
in enumerate(columns
):
861 item
= gtk
.CheckMenuItem(column
.get_title())
862 self
.episode_columns_menu
.append(item
)
863 def on_item_toggled(item
, index
):
864 self
.set_episode_list_column(index
, item
.get_active())
865 item
.connect('toggled', on_item_toggled
, index
)
866 self
.episode_columns_menu
.show_all()
868 # Update the visibility of the columns and the check menu items
869 self
.update_episode_list_columns_visibility()
871 # Set up type-ahead find for the episode list
872 def on_key_press(treeview
, event
):
873 if event
.keyval
== gtk
.keysyms
.Left
:
874 self
.treeChannels
.grab_focus()
875 elif event
.keyval
== gtk
.keysyms
.Escape
:
876 self
.hide_episode_search()
877 elif event
.state
& gtk
.gdk
.CONTROL_MASK
:
878 # Don't handle type-ahead when control is pressed (so shortcuts
879 # with the Ctrl key still work, e.g. Ctrl+A, ...)
882 unicode_char_id
= gtk
.gdk
.keyval_to_unicode(event
.keyval
)
883 if unicode_char_id
== 0:
885 input_char
= unichr(unicode_char_id
)
886 self
.show_episode_search(input_char
)
888 self
.treeAvailable
.connect('key-press-event', on_key_press
)
890 self
.treeAvailable
.connect('popup-menu', self
.treeview_available_show_context_menu
)
892 self
.treeAvailable
.enable_model_drag_source(gtk
.gdk
.BUTTON1_MASK
, \
893 (('text/uri-list', 0, 0),), gtk
.gdk
.ACTION_COPY
)
894 def drag_data_get(tree
, context
, selection_data
, info
, timestamp
):
895 uris
= ['file://'+e
.local_filename(create
=False) \
896 for e
in self
.get_selected_episodes() \
897 if e
.was_downloaded(and_exists
=True)]
898 uris
.append('') # for the trailing '\r\n'
899 selection_data
.set(selection_data
.target
, 8, '\r\n'.join(uris
))
900 self
.treeAvailable
.connect('drag-data-get', drag_data_get
)
902 selection
= self
.treeAvailable
.get_selection()
903 selection
.set_mode(gtk
.SELECTION_MULTIPLE
)
904 # Update the sensitivity of the toolbar buttons on the Desktop
905 selection
.connect('changed', lambda s
: self
.play_or_download())
907 def init_download_list_treeview(self
):
908 # enable multiple selection support
909 self
.treeDownloads
.get_selection().set_mode(gtk
.SELECTION_MULTIPLE
)
910 self
.treeDownloads
.set_search_equal_func(TreeViewHelper
.make_search_equal_func(DownloadStatusModel
))
912 # columns and renderers for "download progress" tab
913 # First column: [ICON] Episodename
914 column
= gtk
.TreeViewColumn(_('Episode'))
916 cell
= gtk
.CellRendererPixbuf()
917 cell
.set_property('stock-size', gtk
.ICON_SIZE_BUTTON
)
918 column
.pack_start(cell
, expand
=False)
919 column
.add_attribute(cell
, 'icon-name', \
920 DownloadStatusModel
.C_ICON_NAME
)
922 cell
= gtk
.CellRendererText()
923 cell
.set_property('ellipsize', pango
.ELLIPSIZE_END
)
924 column
.pack_start(cell
, expand
=True)
925 column
.add_attribute(cell
, 'markup', DownloadStatusModel
.C_NAME
)
926 column
.set_sizing(gtk
.TREE_VIEW_COLUMN_AUTOSIZE
)
927 column
.set_expand(True)
928 self
.treeDownloads
.append_column(column
)
930 # Second column: Progress
931 cell
= gtk
.CellRendererProgress()
932 cell
.set_property('yalign', .5)
933 cell
.set_property('ypad', 6)
934 column
= gtk
.TreeViewColumn(_('Progress'), cell
,
935 value
=DownloadStatusModel
.C_PROGRESS
, \
936 text
=DownloadStatusModel
.C_PROGRESS_TEXT
)
937 column
.set_sizing(gtk
.TREE_VIEW_COLUMN_AUTOSIZE
)
938 column
.set_expand(False)
939 self
.treeDownloads
.append_column(column
)
940 column
.set_property('min-width', 150)
941 column
.set_property('max-width', 150)
943 self
.treeDownloads
.set_model(self
.download_status_model
)
944 TreeViewHelper
.set(self
.treeDownloads
, TreeViewHelper
.ROLE_DOWNLOADS
)
946 self
.treeDownloads
.connect('popup-menu', self
.treeview_downloads_show_context_menu
)
948 def on_treeview_expose_event(self
, treeview
, event
):
949 if event
.window
== treeview
.get_bin_window():
950 model
= treeview
.get_model()
951 if (model
is not None and model
.get_iter_first() is not None):
954 role
= getattr(treeview
, TreeViewHelper
.ROLE
, None)
958 ctx
= event
.window
.cairo_create()
959 ctx
.rectangle(event
.area
.x
, event
.area
.y
,
960 event
.area
.width
, event
.area
.height
)
963 x
, y
, width
, height
, depth
= event
.window
.get_geometry()
966 if role
== TreeViewHelper
.ROLE_EPISODES
:
967 if self
.currently_updating
:
968 text
= _('Loading episodes')
969 elif self
.config
.episode_list_view_mode
!= \
970 EpisodeListModel
.VIEW_ALL
:
971 text
= _('No episodes in current view')
973 text
= _('No episodes available')
974 elif role
== TreeViewHelper
.ROLE_PODCASTS
:
975 if self
.config
.episode_list_view_mode
!= \
976 EpisodeListModel
.VIEW_ALL
and \
977 self
.config
.podcast_list_hide_boring
and \
978 len(self
.channels
) > 0:
979 text
= _('No podcasts in this view')
981 text
= _('No subscriptions')
982 elif role
== TreeViewHelper
.ROLE_DOWNLOADS
:
983 text
= _('No active downloads')
985 raise Exception('on_treeview_expose_event: unknown role')
988 draw_text_box_centered(ctx
, treeview
, width
, height
, text
, font_desc
, progress
)
992 def enable_download_list_update(self
):
993 if not self
.download_list_update_enabled
:
994 self
.update_downloads_list()
995 gobject
.timeout_add(1500, self
.update_downloads_list
)
996 self
.download_list_update_enabled
= True
998 def cleanup_downloads(self
):
999 model
= self
.download_status_model
1001 all_tasks
= [(gtk
.TreeRowReference(model
, row
.path
), row
[0]) for row
in model
]
1002 changed_episode_urls
= set()
1003 for row_reference
, task
in all_tasks
:
1004 if task
.status
in (task
.DONE
, task
.CANCELLED
):
1005 model
.remove(model
.get_iter(row_reference
.get_path()))
1007 # We don't "see" this task anymore - remove it;
1008 # this is needed, so update_episode_list_icons()
1009 # below gets the correct list of "seen" tasks
1010 self
.download_tasks_seen
.remove(task
)
1011 except KeyError, key_error
:
1013 changed_episode_urls
.add(task
.url
)
1014 # Tell the task that it has been removed (so it can clean up)
1015 task
.removed_from_list()
1017 # Tell the podcasts tab to update icons for our removed podcasts
1018 self
.update_episode_list_icons(changed_episode_urls
)
1020 # Tell the shownotes window that we have removed the episode
1021 if self
.episode_shownotes_window
is not None and \
1022 self
.episode_shownotes_window
.episode
is not None and \
1023 self
.episode_shownotes_window
.episode
.url
in changed_episode_urls
:
1024 self
.episode_shownotes_window
._download
_status
_changed
(None)
1026 # Update the downloads list one more time
1027 self
.update_downloads_list(can_call_cleanup
=False)
1029 def on_tool_downloads_toggled(self
, toolbutton
):
1030 if toolbutton
.get_active():
1031 self
.wNotebook
.set_current_page(1)
1033 self
.wNotebook
.set_current_page(0)
1035 def add_download_task_monitor(self
, monitor
):
1036 self
.download_task_monitors
.add(monitor
)
1037 model
= self
.download_status_model
1041 task
= row
[self
.download_status_model
.C_TASK
]
1042 monitor
.task_updated(task
)
1044 def remove_download_task_monitor(self
, monitor
):
1045 self
.download_task_monitors
.remove(monitor
)
1047 def set_download_progress(self
, progress
):
1048 if self
.ubuntu
is not None:
1049 self
.ubuntu
.set_progress(progress
)
1051 def set_new_episodes_count(self
, count
):
1052 if self
.ubuntu
is not None:
1053 self
.ubuntu
.set_count(count
)
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 treeview_channels_show_context_menu(self
, treeview
, event
=None):
1499 model
, paths
= self
.treeview_handle_context_menu_click(treeview
, event
)
1503 # Check for valid channel id, if there's no id then
1504 # assume that it is a proxy channel or equivalent
1505 # and cannot be operated with right click
1506 if self
.active_channel
.id is None:
1509 if event
is None or event
.button
== 3:
1512 item
= gtk
.ImageMenuItem( _('Update podcast'))
1513 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_REFRESH
, gtk
.ICON_SIZE_MENU
))
1514 item
.connect('activate', self
.on_itemUpdateChannel_activate
)
1517 menu
.append(gtk
.SeparatorMenuItem())
1519 item
= gtk
.MenuItem(_('Mark episodes as old'))
1520 item
.connect('activate', self
.on_mark_episodes_as_old
)
1523 item
= gtk
.CheckMenuItem(_('Archive'))
1524 item
.set_active(self
.active_channel
.auto_archive_episodes
)
1525 item
.connect('activate', self
.on_channel_toggle_lock_activate
)
1528 item
= gtk
.ImageMenuItem(_('Remove podcast'))
1529 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_DELETE
, gtk
.ICON_SIZE_MENU
))
1530 item
.connect( 'activate', self
.on_itemRemoveChannel_activate
)
1533 menu
.append( gtk
.SeparatorMenuItem())
1535 item
= gtk
.ImageMenuItem(_('Podcast details'))
1536 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_INFO
, gtk
.ICON_SIZE_MENU
))
1537 item
.connect('activate', self
.on_itemEditChannel_activate
)
1541 # Disable tooltips while we are showing the menu, so
1542 # the tooltip will not appear over the menu
1543 self
.treeview_allow_tooltips(self
.treeChannels
, False)
1544 menu
.connect('deactivate', lambda menushell
: self
.treeview_allow_tooltips(self
.treeChannels
, True))
1547 func
= TreeViewHelper
.make_popup_position_func(treeview
)
1548 menu
.popup(None, None, func
, 3, 0)
1550 menu
.popup(None, None, None, event
.button
, event
.time
)
1554 def cover_file_removed(self
, channel_url
):
1556 The Cover Downloader calls this when a previously-
1557 available cover has been removed from the disk. We
1558 have to update our model to reflect this change.
1560 self
.podcast_list_model
.delete_cover_by_url(channel_url
)
1562 def cover_download_finished(self
, channel
, pixbuf
):
1564 The Cover Downloader calls this when it has finished
1565 downloading (or registering, if already downloaded)
1566 a new channel cover, which is ready for displaying.
1568 self
.podcast_list_model
.add_cover_by_channel(channel
, pixbuf
)
1570 def save_episodes_as_file(self
, episodes
):
1571 for episode
in episodes
:
1572 self
.save_episode_as_file(episode
)
1574 def save_episode_as_file(self
, episode
):
1575 PRIVATE_FOLDER_ATTRIBUTE
= '_save_episodes_as_file_folder'
1576 if episode
.was_downloaded(and_exists
=True):
1577 folder
= getattr(self
, PRIVATE_FOLDER_ATTRIBUTE
, None)
1578 copy_from
= episode
.local_filename(create
=False)
1579 assert copy_from
is not None
1580 copy_to
= util
.sanitize_filename(episode
.sync_filename())
1581 (result
, folder
) = self
.show_copy_dialog(src_filename
=copy_from
, dst_filename
=copy_to
, dst_directory
=folder
)
1582 setattr(self
, PRIVATE_FOLDER_ATTRIBUTE
, folder
)
1584 def copy_episodes_bluetooth(self
, episodes
):
1585 episodes_to_copy
= [e
for e
in episodes
if e
.was_downloaded(and_exists
=True)]
1587 def convert_and_send_thread(episode
):
1588 for episode
in episodes
:
1589 filename
= episode
.local_filename(create
=False)
1590 assert filename
is not None
1591 destfile
= os
.path
.join(tempfile
.gettempdir(), \
1592 util
.sanitize_filename(episode
.sync_filename()))
1593 (base
, ext
) = os
.path
.splitext(filename
)
1594 if not destfile
.endswith(ext
):
1598 shutil
.copyfile(filename
, destfile
)
1599 util
.bluetooth_send_file(destfile
)
1601 logger
.error('Cannot copy "%s" to "%s".', filename
, destfile
)
1602 self
.notification(_('Error converting file.'), _('Bluetooth file transfer'), important
=True)
1604 util
.delete_file(destfile
)
1606 threading
.Thread(target
=convert_and_send_thread
, args
=[episodes_to_copy
]).start()
1608 def treeview_available_show_context_menu(self
, treeview
, event
=None):
1609 model
, paths
= self
.treeview_handle_context_menu_click(treeview
, event
)
1611 if not hasattr(treeview
, 'is_rubber_banding_active'):
1614 return not treeview
.is_rubber_banding_active()
1616 if event
is None or event
.button
== 3:
1617 episodes
= self
.get_selected_episodes()
1618 any_locked
= any(e
.archive
for e
in episodes
)
1619 any_new
= any(e
.is_new
for e
in episodes
)
1620 downloaded
= all(e
.was_downloaded(and_exists
=True) for e
in episodes
)
1621 downloading
= any(e
.downloading
for e
in episodes
)
1625 (can_play
, can_download
, can_cancel
, can_delete
, open_instead_of_play
) = self
.play_or_download()
1627 if open_instead_of_play
:
1628 item
= gtk
.ImageMenuItem(gtk
.STOCK_OPEN
)
1630 item
= gtk
.ImageMenuItem(gtk
.STOCK_MEDIA_PLAY
)
1633 item
= gtk
.ImageMenuItem(_('Preview'))
1635 item
= gtk
.ImageMenuItem(_('Stream'))
1636 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_MEDIA_PLAY
, gtk
.ICON_SIZE_MENU
))
1638 item
.set_sensitive(can_play
)
1639 item
.connect('activate', self
.on_playback_selected_episodes
)
1643 item
= gtk
.ImageMenuItem(_('Download'))
1644 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_GO_DOWN
, gtk
.ICON_SIZE_MENU
))
1645 item
.set_sensitive(can_download
)
1646 item
.connect('activate', self
.on_download_selected_episodes
)
1649 item
= gtk
.ImageMenuItem(gtk
.STOCK_CANCEL
)
1650 item
.connect('activate', self
.on_item_cancel_download_activate
)
1653 item
= gtk
.ImageMenuItem(gtk
.STOCK_DELETE
)
1654 item
.set_sensitive(can_delete
)
1655 item
.connect('activate', self
.on_btnDownloadedDelete_clicked
)
1658 result
= gpodder
.user_extensions
.on_episodes_context_menu(episodes
)
1660 menu
.append(gtk
.SeparatorMenuItem())
1661 for label
, callback
in result
:
1662 item
= gtk
.MenuItem(label
)
1663 item
.connect('activate', lambda item
, callback
:
1664 callback(episodes
), callback
)
1667 # Ok, this probably makes sense to only display for downloaded files
1669 menu
.append(gtk
.SeparatorMenuItem())
1670 share_item
= gtk
.MenuItem(_('Send to'))
1671 menu
.append(share_item
)
1672 share_menu
= gtk
.Menu()
1674 item
= gtk
.ImageMenuItem(_('Local folder'))
1675 item
.set_image(gtk
.image_new_from_stock(gtk
.STOCK_DIRECTORY
, gtk
.ICON_SIZE_MENU
))
1676 item
.connect('button-press-event', lambda w
, ee
: self
.save_episodes_as_file(episodes
))
1677 share_menu
.append(item
)
1678 if self
.bluetooth_available
:
1679 item
= gtk
.ImageMenuItem(_('Bluetooth device'))
1680 item
.set_image(gtk
.image_new_from_icon_name('bluetooth', gtk
.ICON_SIZE_MENU
))
1681 item
.connect('button-press-event', lambda w
, ee
: self
.copy_episodes_bluetooth(episodes
))
1682 share_menu
.append(item
)
1684 share_item
.set_submenu(share_menu
)
1686 menu
.append(gtk
.SeparatorMenuItem())
1688 item
= gtk
.CheckMenuItem(_('New'))
1689 item
.set_active(any_new
)
1691 item
.connect('activate', lambda w
: self
.mark_selected_episodes_old())
1693 item
.connect('activate', lambda w
: self
.mark_selected_episodes_new())
1697 item
= gtk
.CheckMenuItem(_('Archive'))
1698 item
.set_active(any_locked
)
1699 item
.connect('activate', lambda w
: self
.on_item_toggle_lock_activate( w
, False, not any_locked
))
1702 menu
.append(gtk
.SeparatorMenuItem())
1703 # Single item, add episode information menu item
1704 item
= gtk
.ImageMenuItem(_('Episode details'))
1705 item
.set_image(gtk
.image_new_from_stock( gtk
.STOCK_INFO
, gtk
.ICON_SIZE_MENU
))
1706 item
.connect('activate', lambda w
: self
.show_episode_shownotes(episodes
[0]))
1710 # Disable tooltips while we are showing the menu, so
1711 # the tooltip will not appear over the menu
1712 self
.treeview_allow_tooltips(self
.treeAvailable
, False)
1713 menu
.connect('deactivate', lambda menushell
: self
.treeview_allow_tooltips(self
.treeAvailable
, True))
1715 func
= TreeViewHelper
.make_popup_position_func(treeview
)
1716 menu
.popup(None, None, func
, 3, 0)
1718 menu
.popup(None, None, None, event
.button
, event
.time
)
1722 def set_title(self
, new_title
):
1723 self
.default_title
= new_title
1724 self
.gPodder
.set_title(new_title
)
1726 def update_episode_list_icons(self
, urls
=None, selected
=False, all
=False):
1728 Updates the status icons in the episode list.
1730 If urls is given, it should be a list of URLs
1731 of episodes that should be updated.
1733 If urls is None, set ONE OF selected, all to
1734 True (the former updates just the selected
1735 episodes and the latter updates all episodes).
1737 descriptions
= self
.config
.episode_list_descriptions
1739 if urls
is not None:
1740 # We have a list of URLs to walk through
1741 self
.episode_list_model
.update_by_urls(urls
, descriptions
)
1742 elif selected
and not all
:
1743 # We should update all selected episodes
1744 selection
= self
.treeAvailable
.get_selection()
1745 model
, paths
= selection
.get_selected_rows()
1746 for path
in reversed(paths
):
1747 iter = model
.get_iter(path
)
1748 self
.episode_list_model
.update_by_filter_iter(iter, descriptions
)
1749 elif all
and not selected
:
1750 # We update all (even the filter-hidden) episodes
1751 self
.episode_list_model
.update_all(descriptions
)
1753 # Wrong/invalid call - have to specify at least one parameter
1754 raise ValueError('Invalid call to update_episode_list_icons')
1756 def episode_list_status_changed(self
, episodes
):
1757 self
.update_episode_list_icons(set(e
.url
for e
in episodes
))
1758 self
.update_podcast_list_model(set(e
.channel
.url
for e
in episodes
))
1761 def clean_up_downloads(self
, delete_partial
=False):
1762 # Clean up temporary files left behind by old gPodder versions
1763 temporary_files
= glob
.glob('%s/*/.tmp-*' % gpodder
.downloads
)
1766 temporary_files
+= glob
.glob('%s/*/*.partial' % gpodder
.downloads
)
1768 for tempfile
in temporary_files
:
1769 util
.delete_file(tempfile
)
1772 def streaming_possible(self
):
1773 # User has to have a media player set on the Desktop, or else we
1774 # would probably open the browser when giving a URL to xdg-open..
1775 return (self
.config
.player
and self
.config
.player
!= 'default')
1777 def playback_episodes_for_real(self
, episodes
):
1778 groups
= collections
.defaultdict(list)
1779 for episode
in episodes
:
1780 file_type
= episode
.file_type()
1781 if file_type
== 'video' and self
.config
.videoplayer
and \
1782 self
.config
.videoplayer
!= 'default':
1783 player
= self
.config
.videoplayer
1784 elif file_type
== 'audio' and self
.config
.player
and \
1785 self
.config
.player
!= 'default':
1786 player
= self
.config
.player
1790 # Mark episode as played in the database
1791 episode
.playback_mark()
1792 self
.mygpo_client
.on_playback([episode
])
1794 fmt_id
= self
.config
.youtube_preferred_fmt_id
1795 allow_partial
= (player
!= 'default')
1796 filename
= episode
.get_playback_url(fmt_id
, allow_partial
)
1798 # Determine the playback resume position - if the file
1799 # was played 100%, we simply start from the beginning
1800 resume_position
= episode
.current_position
1801 if resume_position
== episode
.total_time
:
1804 # If Panucci is configured, use D-Bus on Maemo to call it
1805 if player
== 'panucci':
1807 PANUCCI_NAME
= 'org.panucci.panucciInterface'
1808 PANUCCI_PATH
= '/panucciInterface'
1809 PANUCCI_INTF
= 'org.panucci.panucciInterface'
1810 o
= gpodder
.dbus_session_bus
.get_object(PANUCCI_NAME
, PANUCCI_PATH
)
1811 i
= dbus
.Interface(o
, PANUCCI_INTF
)
1813 def on_reply(*args
):
1816 def error_handler(filename
, err
):
1817 logger
.error('Exception in D-Bus call: %s', str(err
))
1819 # Fallback: use the command line client
1820 for command
in util
.format_desktop_command('panucci', \
1822 logger
.info('Executing: %s', repr(command
))
1823 subprocess
.Popen(command
)
1825 on_error
= lambda err
: error_handler(filename
, err
)
1827 # This method only exists in Panucci > 0.9 ('new Panucci')
1828 i
.playback_from(filename
, resume_position
, \
1829 reply_handler
=on_reply
, error_handler
=on_error
)
1831 continue # This file was handled by the D-Bus call
1832 except Exception, e
:
1833 logger
.error('Calling Panucci using D-Bus', exc_info
=True)
1835 groups
[player
].append(filename
)
1837 # Open episodes with system default player
1838 if 'default' in groups
:
1839 # Special-casing for a single episode when the object is a PDF
1840 # file - this is needed on Maemo 5, so we only use gui_open()
1841 # for single PDF files, but still use the built-in media player
1842 # with an M3U file for single audio/video files. (The Maemo 5
1843 # media player behaves differently when opening a single-file
1844 # M3U playlist compared to opening the single file directly.)
1845 if len(groups
['default']) == 1:
1846 fn
= groups
['default'][0]
1847 # The list of extensions is taken from gui_open in util.py
1848 # where all special-cases of Maemo apps are listed
1849 for extension
in ('.pdf', '.jpg', '.jpeg', '.png'):
1850 if fn
.lower().endswith(extension
):
1852 groups
['default'] = []
1855 for filename
in groups
['default']:
1856 logger
.debug('Opening with system default: %s', filename
)
1857 util
.gui_open(filename
)
1858 del groups
['default']
1860 # For each type now, go and create play commands
1861 for group
in groups
:
1862 for command
in util
.format_desktop_command(group
, groups
[group
], resume_position
):
1863 logger
.debug('Executing: %s', repr(command
))
1864 subprocess
.Popen(command
)
1866 # Persist episode status changes to the database
1869 # Flush updated episode status
1870 self
.mygpo_client
.flush()
1872 def playback_episodes(self
, episodes
):
1873 # We need to create a list, because we run through it more than once
1874 episodes
= list(Model
.sort_episodes_by_pubdate(e
for e
in episodes
if \
1875 e
.was_downloaded(and_exists
=True) or self
.streaming_possible()))
1878 self
.playback_episodes_for_real(episodes
)
1879 except Exception, e
:
1880 logger
.error('Error in playback!', exc_info
=True)
1881 self
.show_message(_('Please check your media player settings in the preferences dialog.'), \
1882 _('Error opening player'), widget
=self
.toolPreferences
)
1884 channel_urls
= set()
1885 episode_urls
= set()
1886 for episode
in episodes
:
1887 channel_urls
.add(episode
.channel
.url
)
1888 episode_urls
.add(episode
.url
)
1889 self
.update_episode_list_icons(episode_urls
)
1890 self
.update_podcast_list_model(channel_urls
)
1892 def play_or_download(self
):
1893 if self
.wNotebook
.get_current_page() > 0:
1894 self
.toolCancel
.set_sensitive(True)
1897 if self
.currently_updating
:
1898 return (False, False, False, False, False, False)
1900 ( can_play
, can_download
, can_cancel
, can_delete
) = (False,)*4
1901 ( is_played
, is_locked
) = (False,)*2
1903 open_instead_of_play
= False
1905 selection
= self
.treeAvailable
.get_selection()
1906 if selection
.count_selected_rows() > 0:
1907 (model
, paths
) = selection
.get_selected_rows()
1911 episode
= model
.get_value(model
.get_iter(path
), EpisodeListModel
.C_EPISODE
)
1912 except TypeError, te
:
1913 logger
.error('Invalid episode at path %s', str(path
))
1916 if episode
.file_type() not in ('audio', 'video'):
1917 open_instead_of_play
= True
1919 if episode
.was_downloaded():
1920 can_play
= episode
.was_downloaded(and_exists
=True)
1921 is_played
= not episode
.is_new
1922 is_locked
= episode
.archive
1926 if episode
.downloading
:
1931 can_download
= can_download
and not can_cancel
1932 can_play
= self
.streaming_possible() or (can_play
and not can_cancel
and not can_download
)
1933 can_delete
= not can_cancel
1935 if open_instead_of_play
:
1936 self
.toolPlay
.set_stock_id(gtk
.STOCK_OPEN
)
1938 self
.toolPlay
.set_stock_id(gtk
.STOCK_MEDIA_PLAY
)
1939 self
.toolPlay
.set_sensitive( can_play
)
1940 self
.toolDownload
.set_sensitive( can_download
)
1941 self
.toolCancel
.set_sensitive( can_cancel
)
1943 self
.item_cancel_download
.set_sensitive(can_cancel
)
1944 self
.itemDownloadSelected
.set_sensitive(can_download
)
1945 self
.itemOpenSelected
.set_sensitive(can_play
)
1946 self
.itemPlaySelected
.set_sensitive(can_play
)
1947 self
.itemDeleteSelected
.set_sensitive(can_delete
)
1948 self
.item_toggle_played
.set_sensitive(can_play
)
1949 self
.item_toggle_lock
.set_sensitive(can_play
)
1950 self
.itemOpenSelected
.set_visible(open_instead_of_play
)
1951 self
.itemPlaySelected
.set_visible(not open_instead_of_play
)
1953 return (can_play
, can_download
, can_cancel
, can_delete
, open_instead_of_play
)
1955 def on_cbMaxDownloads_toggled(self
, widget
, *args
):
1956 self
.spinMaxDownloads
.set_sensitive(self
.cbMaxDownloads
.get_active())
1958 def on_cbLimitDownloads_toggled(self
, widget
, *args
):
1959 self
.spinLimitDownloads
.set_sensitive(self
.cbLimitDownloads
.get_active())
1961 def episode_new_status_changed(self
, urls
):
1962 self
.update_podcast_list_model()
1963 self
.update_episode_list_icons(urls
)
1965 def update_podcast_list_model(self
, urls
=None, selected
=False, select_url
=None,
1966 sections_changed
=False):
1967 """Update the podcast list treeview model
1969 If urls is given, it should list the URLs of each
1970 podcast that has to be updated in the list.
1972 If selected is True, only update the model contents
1973 for the currently-selected podcast - nothing more.
1975 The caller can optionally specify "select_url",
1976 which is the URL of the podcast that is to be
1977 selected in the list after the update is complete.
1978 This only works if the podcast list has to be
1979 reloaded; i.e. something has been added or removed
1980 since the last update of the podcast list).
1982 _
, _
, new
, _
, _
= self
.db
.get_podcast_statistics()
1983 self
.set_new_episodes_count(new
)
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 and not os
.path
.exists(channel
.cover_file
) and channel
.image
:
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 close_gpodder(self
):
2453 """ clean everything and exit properly
2457 # Notify all tasks to to carry out any clean-up actions
2458 self
.download_status_model
.tell_all_tasks_to_quit()
2460 while gtk
.events_pending():
2461 gtk
.main_iteration(False)
2463 self
.core
.shutdown()
2468 def get_expired_episodes(self
):
2469 # XXX: Move out of gtkui and into a generic module (gpodder.model)?
2471 # Only expire episodes if the age in days is positive
2472 if self
.config
.episode_old_age
< 1:
2475 for channel
in self
.channels
:
2476 for episode
in channel
.get_downloaded_episodes():
2477 # Never consider archived episodes as old
2481 # Never consider fresh episodes as old
2482 if episode
.age_in_days() < self
.config
.episode_old_age
:
2485 # Do not delete played episodes (except if configured)
2486 if not episode
.is_new
:
2487 if not self
.config
.auto_remove_played_episodes
:
2490 # Do not delete unfinished episodes (except if configured)
2491 if not episode
.is_finished():
2492 if not self
.config
.auto_remove_unfinished_episodes
:
2495 # Do not delete unplayed episodes (except if configured)
2497 if not self
.config
.auto_remove_unplayed_episodes
:
2502 def delete_episode_list(self
, episodes
, confirm
=True, skip_locked
=True):
2507 episodes
= [e
for e
in episodes
if not e
.archive
]
2510 title
= _('Episodes are locked')
2511 message
= _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2512 self
.notification(message
, title
, widget
=self
.treeAvailable
)
2515 count
= len(episodes
)
2516 title
= N_('Delete %(count)d episode?', 'Delete %(count)d episodes?', count
) % {'count':count
}
2517 message
= _('Deleting episodes removes downloaded files.')
2519 if confirm
and not self
.show_confirmation(message
, title
):
2522 progress
= ProgressIndicator(_('Deleting episodes'), \
2523 _('Please wait while episodes are deleted'), \
2524 parent
=self
.get_dialog_parent())
2526 def finish_deletion(episode_urls
, channel_urls
):
2527 progress
.on_finished()
2529 # Episodes have been deleted - persist the database
2532 self
.update_episode_list_icons(episode_urls
)
2533 self
.update_podcast_list_model(channel_urls
)
2534 self
.play_or_download()
2537 episode_urls
= set()
2538 channel_urls
= set()
2540 episodes_status_update
= []
2541 for idx
, episode
in enumerate(episodes
):
2542 progress
.on_progress(float(idx
)/float(len(episodes
)))
2543 if not episode
.archive
or not skip_locked
:
2544 progress
.on_message(episode
.title
)
2545 episode
.delete_from_disk()
2546 episode_urls
.add(episode
.url
)
2547 channel_urls
.add(episode
.channel
.url
)
2548 episodes_status_update
.append(episode
)
2550 # Tell the shownotes window that we have removed the episode
2551 if self
.episode_shownotes_window
is not None and \
2552 self
.episode_shownotes_window
.episode
is not None and \
2553 self
.episode_shownotes_window
.episode
.url
== episode
.url
:
2554 util
.idle_add(self
.episode_shownotes_window
._download
_status
_changed
, None)
2556 # Notify the web service about the status update + upload
2557 self
.mygpo_client
.on_delete(episodes_status_update
)
2558 self
.mygpo_client
.flush()
2560 util
.idle_add(finish_deletion
, episode_urls
, channel_urls
)
2562 threading
.Thread(target
=thread_proc
).start()
2566 def on_itemRemoveOldEpisodes_activate(self
, widget
):
2567 self
.show_delete_episodes_window()
2569 def show_delete_episodes_window(self
, channel
=None):
2570 """Offer deletion of episodes
2572 If channel is None, offer deletion of all episodes.
2573 Otherwise only offer deletion of episodes in the channel.
2576 ('markup_delete_episodes', None, None, _('Episode')),
2579 msg_older_than
= N_('Select older than %(count)d day', 'Select older than %(count)d days', self
.config
.episode_old_age
)
2580 selection_buttons
= {
2581 _('Select played'): lambda episode
: not episode
.is_new
,
2582 _('Select finished'): lambda episode
: episode
.is_finished(),
2583 msg_older_than
% {'count':self
.config
.episode_old_age
}: lambda episode
: episode
.age_in_days() > self
.config
.episode_old_age
,
2586 instructions
= _('Select the episodes you want to delete:')
2589 channels
= self
.channels
2591 channels
= [channel
]
2594 for channel
in channels
:
2595 for episode
in channel
.get_downloaded_episodes():
2596 # Disallow deletion of locked episodes that still exist
2597 if not episode
.archive
or not episode
.file_exists():
2598 episodes
.append(episode
)
2600 selected
= [not e
.is_new
or not e
.file_exists() for e
in episodes
]
2602 gPodderEpisodeSelector(self
.gPodder
, title
= _('Delete episodes'), instructions
= instructions
, \
2603 episodes
= episodes
, selected
= selected
, columns
= columns
, \
2604 stock_ok_button
= gtk
.STOCK_DELETE
, callback
= self
.delete_episode_list
, \
2605 selection_buttons
= selection_buttons
, _config
=self
.config
, \
2606 show_episode_shownotes
=self
.show_episode_shownotes
)
2608 def on_selected_episodes_status_changed(self
):
2609 # The order of the updates here is important! When "All episodes" is
2610 # selected, the update of the podcast list model depends on the episode
2611 # list selection to determine which podcasts are affected. Updating
2612 # the episode list could remove the selection if a filter is active.
2613 self
.update_podcast_list_model(selected
=True)
2614 self
.update_episode_list_icons(selected
=True)
2617 def mark_selected_episodes_new(self
):
2618 for episode
in self
.get_selected_episodes():
2620 self
.on_selected_episodes_status_changed()
2622 def mark_selected_episodes_old(self
):
2623 for episode
in self
.get_selected_episodes():
2625 self
.on_selected_episodes_status_changed()
2627 def on_item_toggle_played_activate( self
, widget
, toggle
= True, new_value
= False):
2628 for episode
in self
.get_selected_episodes():
2630 episode
.mark(is_played
=episode
.is_new
)
2632 episode
.mark(is_played
=new_value
)
2633 self
.on_selected_episodes_status_changed()
2635 def on_item_toggle_lock_activate(self
, widget
, toggle
=True, new_value
=False):
2636 for episode
in self
.get_selected_episodes():
2638 episode
.mark(is_locked
=not episode
.archive
)
2640 episode
.mark(is_locked
=new_value
)
2641 self
.on_selected_episodes_status_changed()
2643 def on_channel_toggle_lock_activate(self
, widget
, toggle
=True, new_value
=False):
2644 if self
.active_channel
is None:
2647 self
.active_channel
.auto_archive_episodes
= not self
.active_channel
.auto_archive_episodes
2648 self
.active_channel
.save()
2650 for episode
in self
.active_channel
.get_all_episodes():
2651 episode
.mark(is_locked
=self
.active_channel
.auto_archive_episodes
)
2653 self
.update_podcast_list_model(selected
=True)
2654 self
.update_episode_list_icons(all
=True)
2656 def on_itemUpdateChannel_activate(self
, widget
=None):
2657 if self
.active_channel
is None:
2658 title
= _('No podcast selected')
2659 message
= _('Please select a podcast in the podcasts list to update.')
2660 self
.show_message( message
, title
, widget
=self
.treeChannels
)
2663 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
2664 if getattr(self
.active_channel
, 'ALL_EPISODES_PROXY', False):
2665 self
.update_feed_cache()
2667 self
.update_feed_cache(channels
=[self
.active_channel
])
2669 def on_itemUpdate_activate(self
, widget
=None):
2670 # Check if we have outstanding subscribe/unsubscribe actions
2671 self
.on_add_remove_podcasts_mygpo()
2674 self
.update_feed_cache()
2676 welcome_window
= gPodderWelcome(self
.main_window
,
2677 center_on_widget
=self
.main_window
)
2679 result
= welcome_window
.main_window
.run()
2681 welcome_window
.main_window
.destroy()
2682 if result
== gPodderWelcome
.RESPONSE_OPML
:
2683 self
.on_itemImportChannels_activate(None)
2684 elif result
== gPodderWelcome
.RESPONSE_MYGPO
:
2685 self
.on_download_subscriptions_from_mygpo(None)
2687 def download_episode_list_paused(self
, episodes
):
2688 self
.download_episode_list(episodes
, True)
2690 def download_episode_list(self
, episodes
, add_paused
=False, force_start
=False):
2691 enable_update
= False
2693 for episode
in episodes
:
2694 logger
.debug('Downloading episode: %s', episode
.title
)
2695 if not episode
.was_downloaded(and_exists
=True):
2697 for task
in self
.download_tasks_seen
:
2698 if episode
.url
== task
.url
and task
.status
not in (task
.DOWNLOADING
, task
.QUEUED
):
2699 self
.download_queue_manager
.add_task(task
, force_start
)
2700 enable_update
= True
2708 task
= download
.DownloadTask(episode
, self
.config
)
2709 except Exception, e
:
2710 d
= {'episode': episode
.title
, 'message': str(e
)}
2711 message
= _('Download error while downloading %(episode)s: %(message)s')
2712 self
.show_message(message
% d
, _('Download error'), important
=True)
2713 logger
.error('While downloading %s', episode
.title
, exc_info
=True)
2717 task
.status
= task
.PAUSED
2719 self
.mygpo_client
.on_download([task
.episode
])
2720 self
.download_queue_manager
.add_task(task
, force_start
)
2722 self
.download_status_model
.register_task(task
)
2723 enable_update
= True
2726 self
.enable_download_list_update()
2728 # Flush updated episode status
2729 self
.mygpo_client
.flush()
2731 def cancel_task_list(self
, tasks
):
2736 if task
.status
in (task
.QUEUED
, task
.DOWNLOADING
):
2737 task
.status
= task
.CANCELLED
2738 elif task
.status
== task
.PAUSED
:
2739 task
.status
= task
.CANCELLED
2740 # Call run, so the partial file gets deleted
2743 self
.update_episode_list_icons([task
.url
for task
in tasks
])
2744 self
.play_or_download()
2746 # Update the tab title and downloads list
2747 self
.update_downloads_list()
2749 def new_episodes_show(self
, episodes
, notification
=False, selected
=None):
2751 ('markup_new_episodes', None, None, _('Episode')),
2754 instructions
= _('Select the episodes you want to download:')
2756 if self
.new_episodes_window
is not None:
2757 self
.new_episodes_window
.main_window
.destroy()
2758 self
.new_episodes_window
= None
2760 def download_episodes_callback(episodes
):
2761 self
.new_episodes_window
= None
2762 self
.download_episode_list(episodes
)
2764 if selected
is None:
2765 # Select all by default
2766 selected
= [True]*len(episodes
)
2768 self
.new_episodes_window
= gPodderEpisodeSelector(self
.gPodder
, \
2769 title
=_('New episodes available'), \
2770 instructions
=instructions
, \
2771 episodes
=episodes
, \
2773 selected
=selected
, \
2774 stock_ok_button
= 'gpodder-download', \
2775 callback
=download_episodes_callback
, \
2776 remove_callback
=lambda e
: e
.mark_old(), \
2777 remove_action
=_('Mark as old'), \
2778 remove_finished
=self
.episode_new_status_changed
, \
2779 _config
=self
.config
, \
2780 show_notification
=False, \
2781 show_episode_shownotes
=self
.show_episode_shownotes
)
2783 def on_itemDownloadAllNew_activate(self
, widget
, *args
):
2784 if not self
.offer_new_episodes():
2785 self
.show_message(_('Please check for new episodes later.'), \
2786 _('No new episodes available'), widget
=self
.btnUpdateFeeds
)
2788 def get_new_episodes(self
, channels
=None):
2789 return [e
for c
in channels
or self
.channels
for e
in
2790 filter(lambda e
: e
.check_is_new(), c
.get_all_episodes())]
2792 def commit_changes_to_database(self
):
2793 """This will be called after the sync process is finished"""
2796 def on_itemShowAllEpisodes_activate(self
, widget
):
2797 self
.config
.podcast_list_view_all
= widget
.get_active()
2799 def on_itemShowToolbar_activate(self
, widget
):
2800 self
.config
.show_toolbar
= self
.itemShowToolbar
.get_active()
2802 def on_itemShowDescription_activate(self
, widget
):
2803 self
.config
.episode_list_descriptions
= self
.itemShowDescription
.get_active()
2805 def on_item_view_hide_boring_podcasts_toggled(self
, toggleaction
):
2806 self
.config
.podcast_list_hide_boring
= toggleaction
.get_active()
2807 if self
.config
.podcast_list_hide_boring
:
2808 self
.podcast_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
2810 self
.podcast_list_model
.set_view_mode(-1)
2812 def on_item_view_episodes_changed(self
, radioaction
, current
):
2813 if current
== self
.item_view_episodes_all
:
2814 self
.config
.episode_list_view_mode
= EpisodeListModel
.VIEW_ALL
2815 elif current
== self
.item_view_episodes_undeleted
:
2816 self
.config
.episode_list_view_mode
= EpisodeListModel
.VIEW_UNDELETED
2817 elif current
== self
.item_view_episodes_downloaded
:
2818 self
.config
.episode_list_view_mode
= EpisodeListModel
.VIEW_DOWNLOADED
2819 elif current
== self
.item_view_episodes_unplayed
:
2820 self
.config
.episode_list_view_mode
= EpisodeListModel
.VIEW_UNPLAYED
2822 self
.episode_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
2824 if self
.config
.podcast_list_hide_boring
:
2825 self
.podcast_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
2827 def on_itemPreferences_activate(self
, widget
, *args
):
2828 gPodderPreferences(self
.main_window
, \
2829 _config
=self
.config
, \
2830 user_apps_reader
=self
.user_apps_reader
, \
2831 parent_window
=self
.main_window
, \
2832 mygpo_client
=self
.mygpo_client
, \
2833 on_send_full_subscriptions
=self
.on_send_full_subscriptions
, \
2834 on_itemExportChannels_activate
=self
.on_itemExportChannels_activate
)
2836 def on_goto_mygpo(self
, widget
):
2837 self
.mygpo_client
.open_website()
2839 def on_download_subscriptions_from_mygpo(self
, action
=None):
2840 title
= _('Login to gpodder.net')
2841 message
= _('Please login to download your subscriptions.')
2842 success
, (username
, password
) = self
.show_login_dialog(title
, message
, \
2843 self
.config
.mygpo
.username
, self
.config
.mygpo
.password
)
2847 self
.config
.mygpo
.username
= username
2848 self
.config
.mygpo
.password
= password
2850 dir = gPodderPodcastDirectory(self
.gPodder
, _config
=self
.config
, \
2851 custom_title
=_('Subscriptions on gpodder.net'), \
2852 add_urls_callback
=self
.add_podcast_list
, \
2853 hide_url_entry
=True)
2855 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
2856 # we do not have to hardcode the URL here
2857 OPML_URL
= 'http://gpodder.net/subscriptions/%s.opml' % self
.config
.mygpo
.username
2858 url
= util
.url_add_authentication(OPML_URL
, \
2859 self
.config
.mygpo
.username
, \
2860 self
.config
.mygpo
.password
)
2861 dir.download_opml_file(url
)
2863 def on_itemAddChannel_activate(self
, widget
=None):
2864 gPodderAddPodcast(self
.gPodder
, \
2865 add_urls_callback
=self
.add_podcast_list
)
2867 def on_itemEditChannel_activate(self
, widget
, *args
):
2868 if self
.active_channel
is None:
2869 title
= _('No podcast selected')
2870 message
= _('Please select a podcast in the podcasts list to edit.')
2871 self
.show_message( message
, title
, widget
=self
.treeChannels
)
2874 gPodderChannel(self
.main_window
, \
2875 channel
=self
.active_channel
, \
2876 update_podcast_list_model
=self
.update_podcast_list_model
, \
2877 cover_downloader
=self
.cover_downloader
, \
2878 sections
=set(c
.section
for c
in self
.channels
))
2880 def on_itemMassUnsubscribe_activate(self
, item
=None):
2882 ('title', None, None, _('Podcast')),
2885 # We're abusing the Episode Selector for selecting Podcasts here,
2886 # but it works and looks good, so why not? -- thp
2887 gPodderEpisodeSelector(self
.main_window
, \
2888 title
=_('Remove podcasts'), \
2889 instructions
=_('Select the podcast you want to remove.'), \
2890 episodes
=self
.channels
, \
2892 size_attribute
=None, \
2893 stock_ok_button
=_('Remove'), \
2894 callback
=self
.remove_podcast_list
, \
2895 _config
=self
.config
)
2897 def remove_podcast_list(self
, channels
, confirm
=True):
2901 if len(channels
) == 1:
2902 title
= _('Removing podcast')
2903 info
= _('Please wait while the podcast is removed')
2904 message
= _('Do you really want to remove this podcast and its episodes?')
2906 title
= _('Removing podcasts')
2907 info
= _('Please wait while the podcasts are removed')
2908 message
= _('Do you really want to remove the selected podcasts and their episodes?')
2910 if confirm
and not self
.show_confirmation(message
, title
):
2913 progress
= ProgressIndicator(title
, info
, parent
=self
.get_dialog_parent())
2915 def finish_deletion(select_url
):
2916 # Upload subscription list changes to the web service
2917 self
.mygpo_client
.on_unsubscribe([c
.url
for c
in channels
])
2919 # Re-load the channels and select the desired new channel
2920 self
.update_podcast_list_model(select_url
=select_url
)
2921 progress
.on_finished()
2926 for idx
, channel
in enumerate(channels
):
2927 # Update the UI for correct status messages
2928 progress
.on_progress(float(idx
)/float(len(channels
)))
2929 progress
.on_message(channel
.title
)
2931 # Delete downloaded episodes
2932 channel
.remove_downloaded()
2934 # cancel any active downloads from this channel
2935 for episode
in channel
.get_all_episodes():
2936 if episode
.downloading
:
2937 episode
.download_task
.cancel()
2939 if len(channels
) == 1:
2940 # get the URL of the podcast we want to select next
2941 if channel
in self
.channels
:
2942 position
= self
.channels
.index(channel
)
2946 if position
== len(self
.channels
)-1:
2947 # this is the last podcast, so select the URL
2948 # of the item before this one (i.e. the "new last")
2949 select_url
= self
.channels
[position
-1].url
2951 # there is a podcast after the deleted one, so
2952 # we simply select the one that comes after it
2953 select_url
= self
.channels
[position
+1].url
2955 # Remove the channel and clean the database entries
2958 # Clean up downloads and download directories
2959 self
.clean_up_downloads()
2961 # The remaining stuff is to be done in the GTK main thread
2962 util
.idle_add(finish_deletion
, select_url
)
2964 threading
.Thread(target
=thread_proc
).start()
2966 def on_itemRemoveChannel_activate(self
, widget
, *args
):
2967 if self
.active_channel
is None:
2968 title
= _('No podcast selected')
2969 message
= _('Please select a podcast in the podcasts list to remove.')
2970 self
.show_message( message
, title
, widget
=self
.treeChannels
)
2973 self
.remove_podcast_list([self
.active_channel
])
2975 def get_opml_filter(self
):
2976 filter = gtk
.FileFilter()
2977 filter.add_pattern('*.opml')
2978 filter.add_pattern('*.xml')
2979 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
2982 def on_item_import_from_file_activate(self
, widget
, filename
=None):
2983 if filename
is None:
2984 dlg
= gtk
.FileChooserDialog(title
=_('Import from OPML'),
2985 parent
=self
.main_window
,
2986 action
=gtk
.FILE_CHOOSER_ACTION_OPEN
)
2987 dlg
.add_button(gtk
.STOCK_CANCEL
, gtk
.RESPONSE_CANCEL
)
2988 dlg
.add_button(gtk
.STOCK_OPEN
, gtk
.RESPONSE_OK
)
2989 dlg
.set_filter(self
.get_opml_filter())
2990 response
= dlg
.run()
2992 if response
== gtk
.RESPONSE_OK
:
2993 filename
= dlg
.get_filename()
2996 if filename
is not None:
2997 dir = gPodderPodcastDirectory(self
.gPodder
, _config
=self
.config
, \
2998 custom_title
=_('Import podcasts from OPML file'), \
2999 add_urls_callback
=self
.add_podcast_list
, \
3000 hide_url_entry
=True)
3001 dir.download_opml_file(filename
)
3003 def on_itemExportChannels_activate(self
, widget
, *args
):
3004 if not self
.channels
:
3005 title
= _('Nothing to export')
3006 message
= _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3007 self
.show_message(message
, title
, widget
=self
.treeChannels
)
3010 dlg
= gtk
.FileChooserDialog(title
=_('Export to OPML'), parent
=self
.gPodder
, action
=gtk
.FILE_CHOOSER_ACTION_SAVE
)
3011 dlg
.add_button(gtk
.STOCK_CANCEL
, gtk
.RESPONSE_CANCEL
)
3012 dlg
.add_button(gtk
.STOCK_SAVE
, gtk
.RESPONSE_OK
)
3013 dlg
.set_filter(self
.get_opml_filter())
3014 response
= dlg
.run()
3015 if response
== gtk
.RESPONSE_OK
:
3016 filename
= dlg
.get_filename()
3018 exporter
= opml
.Exporter( filename
)
3019 if filename
is not None and exporter
.write(self
.channels
):
3020 count
= len(self
.channels
)
3021 title
= N_('%(count)d subscription exported', '%(count)d subscriptions exported', count
) % {'count':count
}
3022 self
.show_message(_('Your podcast list has been successfully exported.'), title
, widget
=self
.treeChannels
)
3024 self
.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important
=True)
3028 def on_itemImportChannels_activate(self
, widget
, *args
):
3029 dir = gPodderPodcastDirectory(self
.main_window
, _config
=self
.config
, \
3030 add_urls_callback
=self
.add_podcast_list
)
3031 util
.idle_add(dir.download_opml_file
, my
.EXAMPLES_OPML
)
3033 def on_homepage_activate(self
, widget
, *args
):
3034 util
.open_website(gpodder
.__url
__)
3036 def on_wiki_activate(self
, widget
, *args
):
3037 util
.open_website('http://gpodder.org/wiki/User_Manual')
3039 def on_bug_tracker_activate(self
, widget
, *args
):
3040 util
.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder&component=Application&version=%s' % gpodder
.__version
__)
3042 def on_item_support_activate(self
, widget
):
3043 util
.open_website('http://gpodder.org/donate')
3045 def on_itemAbout_activate(self
, widget
, *args
):
3046 dlg
= gtk
.Dialog(_('About gPodder'), self
.main_window
, \
3048 dlg
.add_button(gtk
.STOCK_CLOSE
, gtk
.RESPONSE_OK
).show()
3049 dlg
.set_resizable(False)
3051 bg
= gtk
.HBox(spacing
=10)
3052 bg
.pack_start(gtk
.image_new_from_file(gpodder
.icon_file
), expand
=False)
3056 label
.set_alignment(0, 1)
3057 label
.set_markup('<b><big>gPodder</big> %s</b>' % gpodder
.__version
__)
3058 vb
.pack_start(label
)
3060 label
.set_alignment(0, 0)
3061 label
.set_markup('<small><a href="%s">%s</a></small>' % \
3062 ((cgi
.escape(gpodder
.__url
__),)*2))
3063 vb
.pack_start(label
)
3066 out
= gtk
.VBox(spacing
=10)
3067 out
.set_border_width(12)
3068 out
.pack_start(bg
, expand
=False)
3069 out
.pack_start(gtk
.HSeparator())
3070 out
.pack_start(gtk
.Label(gpodder
.__copyright
__))
3072 button_box
= gtk
.HButtonBox()
3073 button
= gtk
.Button(_('Donate / Wishlist'))
3074 button
.connect('clicked', self
.on_item_support_activate
)
3075 button_box
.pack_start(button
)
3076 button
= gtk
.Button(_('Report a problem'))
3077 button
.connect('clicked', self
.on_bug_tracker_activate
)
3078 button_box
.pack_start(button
)
3079 out
.pack_start(button_box
, expand
=False)
3081 credits
= gtk
.TextView()
3082 credits
.set_left_margin(5)
3083 credits
.set_right_margin(5)
3084 credits
.set_pixels_above_lines(5)
3085 credits
.set_pixels_below_lines(5)
3086 credits
.set_editable(False)
3087 credits
.set_cursor_visible(False)
3088 sw
= gtk
.ScrolledWindow()
3089 sw
.set_shadow_type(gtk
.SHADOW_IN
)
3090 sw
.set_policy(gtk
.POLICY_NEVER
, gtk
.POLICY_AUTOMATIC
)
3092 credits
.set_size_request(-1, 160)
3093 out
.pack_start(sw
, expand
=True, fill
=True)
3095 dlg
.vbox
.pack_start(out
, expand
=False)
3096 dlg
.connect('response', lambda dlg
, response
: dlg
.destroy())
3100 if os
.path
.exists(gpodder
.credits_file
):
3101 credits_txt
= open(gpodder
.credits_file
).read().strip().split('\n')
3102 translator_credits
= _('translator-credits')
3103 if translator_credits
!= 'translator-credits':
3104 app_authors
= [_('Translation by:'), translator_credits
, '']
3108 app_authors
+= [_('Thanks to:')]
3109 app_authors
+= credits_txt
3111 buffer = gtk
.TextBuffer()
3112 buffer.set_text('\n'.join(app_authors
))
3113 credits
.set_buffer(buffer)
3117 credits
.grab_focus()
3120 def on_wNotebook_switch_page(self
, notebook
, page
, page_num
):
3122 self
.play_or_download()
3123 # The message area in the downloads tab should be hidden
3124 # when the user switches away from the downloads tab
3125 if self
.message_area
is not None:
3126 self
.message_area
.hide()
3127 self
.message_area
= None
3129 self
.toolDownload
.set_sensitive(False)
3130 self
.toolPlay
.set_sensitive(False)
3131 self
.toolCancel
.set_sensitive(False)
3133 def on_treeChannels_row_activated(self
, widget
, path
, *args
):
3134 # double-click action of the podcast list or enter
3135 self
.treeChannels
.set_cursor(path
)
3137 def on_treeChannels_cursor_changed(self
, widget
, *args
):
3138 ( model
, iter ) = self
.treeChannels
.get_selection().get_selected()
3140 if model
is not None and iter is not None:
3141 old_active_channel
= self
.active_channel
3142 self
.active_channel
= model
.get_value(iter, PodcastListModel
.C_CHANNEL
)
3144 if self
.active_channel
== old_active_channel
:
3147 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3148 if getattr(self
.active_channel
, 'ALL_EPISODES_PROXY', False):
3149 self
.itemEditChannel
.set_visible(False)
3150 self
.itemRemoveChannel
.set_visible(False)
3152 self
.itemEditChannel
.set_visible(True)
3153 self
.itemRemoveChannel
.set_visible(True)
3155 self
.active_channel
= None
3156 self
.itemEditChannel
.set_visible(False)
3157 self
.itemRemoveChannel
.set_visible(False)
3159 self
.update_episode_list_model()
3161 def on_btnEditChannel_clicked(self
, widget
, *args
):
3162 self
.on_itemEditChannel_activate( widget
, args
)
3164 def get_podcast_urls_from_selected_episodes(self
):
3165 """Get a set of podcast URLs based on the selected episodes"""
3166 return set(episode
.channel
.url
for episode
in \
3167 self
.get_selected_episodes())
3169 def get_selected_episodes(self
):
3170 """Get a list of selected episodes from treeAvailable"""
3171 selection
= self
.treeAvailable
.get_selection()
3172 model
, paths
= selection
.get_selected_rows()
3174 episodes
= [model
.get_value(model
.get_iter(path
), EpisodeListModel
.C_EPISODE
) for path
in paths
]
3177 def on_playback_selected_episodes(self
, widget
):
3178 self
.playback_episodes(self
.get_selected_episodes())
3180 def on_shownotes_selected_episodes(self
, widget
):
3181 episodes
= self
.get_selected_episodes()
3183 episode
= episodes
.pop(0)
3184 self
.show_episode_shownotes(episode
)
3186 self
.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget
=self
.treeAvailable
)
3188 def on_download_selected_episodes(self
, widget
):
3189 episodes
= self
.get_selected_episodes()
3190 self
.download_episode_list(episodes
)
3191 self
.update_episode_list_icons([episode
.url
for episode
in episodes
])
3192 self
.play_or_download()
3194 def on_treeAvailable_row_activated(self
, widget
, path
, view_column
):
3195 """Double-click/enter action handler for treeAvailable"""
3196 self
.on_shownotes_selected_episodes(widget
)
3198 def show_episode_shownotes(self
, episode
):
3199 if self
.episode_shownotes_window
is None:
3200 self
.episode_shownotes_window
= gPodderShownotes(self
.gPodder
, _config
=self
.config
, \
3201 _download_episode_list
=self
.download_episode_list
, \
3202 _playback_episodes
=self
.playback_episodes
, \
3203 _delete_episode_list
=self
.delete_episode_list
, \
3204 _episode_list_status_changed
=self
.episode_list_status_changed
, \
3205 _cancel_task_list
=self
.cancel_task_list
, \
3206 _streaming_possible
=self
.streaming_possible())
3207 self
.episode_shownotes_window
.show(episode
)
3208 if episode
.downloading
:
3209 self
.update_downloads_list()
3211 def restart_auto_update_timer(self
):
3212 if self
._auto
_update
_timer
_source
_id
is not None:
3213 logger
.debug('Removing existing auto update timer.')
3214 gobject
.source_remove(self
._auto
_update
_timer
_source
_id
)
3215 self
._auto
_update
_timer
_source
_id
= None
3217 if self
.config
.auto_update_feeds
and \
3218 self
.config
.auto_update_frequency
:
3219 interval
= 60*1000*self
.config
.auto_update_frequency
3220 logger
.debug('Setting up auto update timer with interval %d.',
3221 self
.config
.auto_update_frequency
)
3222 self
._auto
_update
_timer
_source
_id
= gobject
.timeout_add(\
3223 interval
, self
._on
_auto
_update
_timer
)
3225 def _on_auto_update_timer(self
):
3226 logger
.debug('Auto update timer fired.')
3227 self
.update_feed_cache()
3229 # Ask web service for sub changes (if enabled)
3230 self
.mygpo_client
.flush()
3234 def on_treeDownloads_row_activated(self
, widget
, *args
):
3235 # Use the standard way of working on the treeview
3236 selection
= self
.treeDownloads
.get_selection()
3237 (model
, paths
) = selection
.get_selected_rows()
3238 selected_tasks
= [(gtk
.TreeRowReference(model
, path
), model
.get_value(model
.get_iter(path
), 0)) for path
in paths
]
3240 for tree_row_reference
, task
in selected_tasks
:
3241 if task
.status
in (task
.DOWNLOADING
, task
.QUEUED
):
3242 task
.status
= task
.PAUSED
3243 elif task
.status
in (task
.CANCELLED
, task
.PAUSED
, task
.FAILED
):
3244 self
.download_queue_manager
.add_task(task
)
3245 self
.enable_download_list_update()
3246 elif task
.status
== task
.DONE
:
3247 model
.remove(model
.get_iter(tree_row_reference
.get_path()))
3249 self
.play_or_download()
3251 # Update the tab title and downloads list
3252 self
.update_downloads_list()
3254 def on_item_cancel_download_activate(self
, widget
):
3255 if self
.wNotebook
.get_current_page() == 0:
3256 selection
= self
.treeAvailable
.get_selection()
3257 (model
, paths
) = selection
.get_selected_rows()
3258 urls
= [model
.get_value(model
.get_iter(path
), \
3259 self
.episode_list_model
.C_URL
) for path
in paths
]
3260 selected_tasks
= [task
for task
in self
.download_tasks_seen \
3261 if task
.url
in urls
]
3263 selection
= self
.treeDownloads
.get_selection()
3264 (model
, paths
) = selection
.get_selected_rows()
3265 selected_tasks
= [model
.get_value(model
.get_iter(path
), \
3266 self
.download_status_model
.C_TASK
) for path
in paths
]
3267 self
.cancel_task_list(selected_tasks
)
3269 def on_btnCancelAll_clicked(self
, widget
, *args
):
3270 self
.cancel_task_list(self
.download_tasks_seen
)
3272 def on_btnDownloadedDelete_clicked(self
, widget
, *args
):
3273 episodes
= self
.get_selected_episodes()
3274 if len(episodes
) == 1:
3275 self
.delete_episode_list(episodes
, skip_locked
=False)
3277 self
.delete_episode_list(episodes
)
3279 def on_key_press(self
, widget
, event
):
3280 # Allow tab switching with Ctrl + PgUp/PgDown
3281 if event
.state
& gtk
.gdk
.CONTROL_MASK
:
3282 if event
.keyval
== gtk
.keysyms
.Page_Up
:
3283 self
.wNotebook
.prev_page()
3285 elif event
.keyval
== gtk
.keysyms
.Page_Down
:
3286 self
.wNotebook
.next_page()
3291 def uniconify_main_window(self
):
3292 if self
.is_iconified():
3293 # We need to hide and then show the window in WMs like Metacity
3294 # or KWin4 to move the window to the active workspace
3295 # (see http://gpodder.org/bug/1125)
3298 self
.gPodder
.present()
3300 def iconify_main_window(self
):
3301 if not self
.is_iconified():
3302 self
.gPodder
.iconify()
3304 @dbus.service
.method(gpodder
.dbus_interface
)
3305 def show_gui_window(self
):
3306 parent
= self
.get_dialog_parent()
3309 @dbus.service
.method(gpodder
.dbus_interface
)
3310 def subscribe_to_url(self
, url
):
3311 gPodderAddPodcast(self
.gPodder
,
3312 add_urls_callback
=self
.add_podcast_list
,
3315 @dbus.service
.method(gpodder
.dbus_interface
)
3316 def mark_episode_played(self
, filename
):
3317 if filename
is None:
3320 for channel
in self
.channels
:
3321 for episode
in channel
.get_all_episodes():
3322 fn
= episode
.local_filename(create
=False, check_only
=True)
3324 episode
.mark(is_played
=True)
3326 self
.update_episode_list_icons([episode
.url
])
3327 self
.update_podcast_list_model([episode
.channel
.url
])
3332 def extensions_podcast_update_cb(self
, podcast
):
3333 logger
.debug('extensions_podcast_update_cb(%s)', podcast
)
3334 self
.update_feed_cache(channels
=[podcast
],
3335 show_new_episodes_dialog
=False)
3337 def extensions_episode_download_cb(self
, episode
):
3338 logger
.debug('extension_episode_download_cb(%s)', episode
)
3339 self
.download_episode_list(episodes
=[episode
])
3341 def main(options
=None):
3342 gobject
.threads_init()
3343 gobject
.set_application_name('gPodder')
3345 for i
in range(EpisodeListModel
.PROGRESS_STEPS
+ 1):
3346 pixbuf
= draw_cake_pixbuf(float(i
) /
3347 float(EpisodeListModel
.PROGRESS_STEPS
))
3348 icon_name
= 'gpodder-progress-%d' % i
3349 gtk
.icon_theme_add_builtin_icon(icon_name
, pixbuf
.get_width(), pixbuf
)
3351 gtk
.window_set_default_icon_name('gpodder')
3352 gtk
.about_dialog_set_url_hook(lambda dlg
, link
, data
: util
.open_website(link
), None)
3355 dbus_main_loop
= dbus
.glib
.DBusGMainLoop(set_as_default
=True)
3356 gpodder
.dbus_session_bus
= dbus
.SessionBus(dbus_main_loop
)
3358 bus_name
= dbus
.service
.BusName(gpodder
.dbus_bus_name
, bus
=gpodder
.dbus_session_bus
)
3359 except dbus
.exceptions
.DBusException
, dbe
:
3360 logger
.warn('Cannot get "on the bus".', exc_info
=True)
3361 dlg
= gtk
.MessageDialog(None, gtk
.DIALOG_MODAL
, gtk
.MESSAGE_ERROR
, \
3362 gtk
.BUTTONS_CLOSE
, _('Cannot start gPodder'))
3363 dlg
.format_secondary_markup(_('D-Bus error: %s') % (str(dbe
),))
3364 dlg
.set_title('gPodder')
3369 gp
= gPodder(bus_name
, core
.Core(UIConfig
, model_class
=Model
))
3372 if options
.subscribe
:
3373 util
.idle_add(gp
.subscribe_to_url
, options
.subscribe
)
3376 # handle "subscribe to podcast" events from firefox
3377 if platform
.system() == 'Darwin':
3378 from gpodder
.gtkui
import macosx
3379 macosx
.register_handlers(gp
)
3380 # end mac OS X stuff