1
# Miro - an RSS based video player application
2
# Copyright (C) 2005-2010 Participatory Culture Foundation
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
18
# In addition, as a special exception, the copyright holders give
19
# permission to link the code of portions of this program with the OpenSSL
22
# You must obey the GNU General Public License in all respects for all of
23
# the code used other than OpenSSL. If you modify file(s) with this
24
# exception, you may extend this exception to your version of the file(s),
25
# but you are not obligated to do so. If you do not wish to do so, delete
26
# this exception statement from your version. If you delete this exception
27
# statement from all source files in the program, then also delete it here.
29
"""itemlist.py -- Handles TableModel objects that store items.
31
itemlist, itemlistcontroller and itemlistwidgets work together using the MVC
32
pattern. itemlist handles the Model, itemlistwidgets handles the View and
33
itemlistcontroller handles the Controller.
35
ItemList manages a TableModel that stores ItemInfo objects. It handles
36
filtering out items from the list (for example in the Downloading items list).
37
They also handle temporarily filtering out items based the user's search
45
from miro import search
47
from miro.frontends.widgets import imagepool
48
from miro.plat.utils import filenameToUnicode
49
from miro.plat.frontends.widgets import timer
50
from miro.plat.frontends.widgets import widgetset
52
def item_matches_search(item_info, search_text):
53
"""Check if an item matches search text."""
56
match_against = [item_info.name, item_info.description]
57
if item_info.video_path is not None:
58
match_against.append(filenameToUnicode(item_info.video_path))
59
return search.match(search_text, match_against)
61
class ItemSort(object):
62
"""Class that sorts items in an item list."""
64
def __init__(self, ascending):
65
self._reverse = not ascending
67
def is_ascending(self):
68
return not self._reverse
70
def sort_key(self, item):
71
"""Return a value that can be used to sort item.
73
Must be implemented by subclasses.
75
raise NotImplementedError()
77
def compare(self, item, other):
80
Returns -1 if item < other, 1 if other > item and 0 if item == other
84
return -cmp(self.sort_key(item), self.sort_key(other))
86
return cmp(self.sort_key(item), self.sort_key(other))
88
def sort_items(self, item_list):
89
"""Sort a list of items (in place)."""
90
item_list.sort(key=self.sort_key, reverse=self._reverse)
92
def sort_item_rows(self, rows):
93
rows.sort(key=lambda row: self.sort_key(row[0]),
94
reverse=self._reverse)
96
class DateSort(ItemSort):
98
def sort_key(self, item):
99
return item.release_date
101
class NameSort(ItemSort):
103
def _strip_accents(self, str):
104
nfkd_form = unicodedata.normalize('NFKD', unicode(str))
105
return u"".join([c for c in nfkd_form if not unicodedata.combining(c)])
106
def _trynum(self, str):
110
return self._strip_accents(str)
111
def sort_key(self, item):
113
return [ self._trynum(c) for c in re.split('([0-9]+\.?[0-9]*)', item.name) ]
116
class LengthSort(ItemSort):
118
def sort_key(self, item):
121
class SizeSort(ItemSort):
123
def sort_key(self, item):
126
class DescriptionSort(ItemSort):
128
def sort_key(self, item):
129
return item.description
131
class FeedNameSort(ItemSort):
133
def sort_key(self, item):
135
return item.feed_name.lower()
136
return item.feed_name
138
class StatusCircleSort(ItemSort):
140
# Weird sort, this one is for when the user clicks on the header above the
141
# status bumps. It's almost the same as StatusSort, but there isn't a
143
def sort_key(self, item):
144
if item.state == 'downloading':
145
return 1 # downloading
146
elif item.downloaded and not item.video_watched:
148
elif not item.item_viewed and not item.expiration_date:
153
class StatusSort(ItemSort):
155
def sort_key(self, item):
156
if item.state == 'downloading':
157
return (2, ) # downloading
158
elif item.downloaded and not item.video_watched:
159
return (3, ) # unwatched
160
elif item.expiration_date:
161
# the tuple here creates a subsort on expiration_date
162
return (4, item.expiration_date) # expiring
163
elif not item.item_viewed:
168
class ETASort(ItemSort):
170
def sort_key(self, item):
171
if item.state in ('downloading', 'paused'):
172
eta = item.download_info.eta
175
elif not self._reverse:
180
class DownloadRateSort(ItemSort):
182
def sort_key(self, item):
183
if item.state in ('downloading', 'paused'):
184
return item.download_info.rate
185
elif not self._reverse:
190
class ProgressSort(ItemSort):
192
def sort_key(self, item):
193
if item.state in ('downloading', 'paused'):
194
return float(item.download_info.downloaded_size) / item.size
195
elif not self._reverse:
201
DateSort.KEY: DateSort,
202
NameSort.KEY: NameSort,
203
LengthSort.KEY: LengthSort,
204
SizeSort.KEY: SizeSort,
205
DescriptionSort.KEY: DescriptionSort,
206
FeedNameSort.KEY: FeedNameSort,
207
StatusCircleSort.KEY: StatusCircleSort,
208
StatusSort.KEY: StatusSort,
209
ETASort.KEY: ETASort,
210
DownloadRateSort.KEY: DownloadRateSort,
211
ProgressSort.KEY: ProgressSort,
214
class ItemListGroup(object):
215
"""Manages a set of ItemLists.
217
ItemListGroup keep track of one or more ItemLists. When items are
218
added/changed/removed they take care of making sure each child list
221
ItemLists maintain an item sorting and a search filter that are shared by
225
def __init__(self, item_lists, sorter):
226
"""Construct in ItemLists.
228
item_lists is a list of ItemList objects that should be grouped
231
self.item_lists = item_lists
233
self.set_sort(DateSort(False))
235
self.set_sort(sorter)
236
self._throbber_timeouts = {}
237
self.html_stripper = util.HTMLStripper()
239
def _throbber_timeout(self, id):
240
for item_list in self.item_lists:
241
item_list.update_throbber(id)
242
self._schedule_throbber_timeout(id)
244
def _schedule_throbber_timeout(self, id):
245
timeout = timer.add(0.4, self._throbber_timeout, id)
246
self._throbber_timeouts[id] = timeout
248
def _setup_info(self, info):
249
"""Initialize a newly received ItemInfo."""
250
info.icon = imagepool.LazySurface(info.thumbnail, (154, 105))
251
info.description_text, info.description_links = \
252
self.html_stripper.strip(info.description)
253
download_info = info.download_info
254
if (download_info is not None and
255
not download_info.finished and
256
download_info.state != 'paused' and
257
download_info.downloaded_size > 0 and info.size == -1):
258
# We are downloading an item without a content-length. Take steps
259
# to update the progress throbbers.
260
if info.id not in self._throbber_timeouts:
261
self._schedule_throbber_timeout(info.id)
262
elif info.id in self._throbber_timeouts:
263
timer.cancel(self._throbber_timeouts.pop(info.id))
265
def add_items(self, item_list):
266
"""Add a list of new items to the item list.
268
Note: This method will sort item_list
270
self._sorter.sort_items(item_list)
271
for item_info in item_list:
272
self._setup_info(item_info)
273
for sublist in self.item_lists:
274
sublist.add_items(item_list, already_sorted=True)
276
def update_items(self, changed_items):
279
Note: This method will sort changed_items
281
self._sorter.sort_items(changed_items)
282
for item_info in changed_items:
283
self._setup_info(item_info)
284
for sublist in self.item_lists:
285
sublist.update_items(changed_items, already_sorted=True)
287
def remove_items(self, removed_ids):
288
"""Remove items from the list."""
289
for sublist in self.item_lists:
290
sublist.remove_items(removed_ids)
292
def set_sort(self, sorter):
293
"""Change the way items are sorted in the list (and filtered lists)
295
sorter must be a subclass of ItemSort.
297
self._sorter = sorter
298
for sublist in self.item_lists:
299
sublist.set_sort(sorter)
304
def set_search_text(self, search_text):
305
"""Update the search for each child list."""
306
for sublist in self.item_lists:
307
sublist.set_search_text(search_text)
309
class ItemList(object):
313
model -- TableModel for this item list. It contains these columns:
315
* show_details flag (boolean)
316
* counter used to change the progress throbber (integer)
318
new_only -- Are we only displaying the new items?
322
self.model = widgetset.TableModel('object', 'boolean', 'integer')
325
self._search_text = ''
326
self.new_only = False
327
self.unwatched_only = False
328
self.non_feed_only = False
329
self._hidden_items = {}
330
# maps ids -> items that should be in this list, but are filtered out
333
def set_sort(self, sorter):
334
self._sorter = sorter
341
"""Get the number of items in this list that are displayed."""
342
return len(self.model)
344
def get_hidden_count(self):
345
"""Get the number of items in this list that are hidden."""
346
return len(self._hidden_items)
348
def get_items(self, start_id=None):
349
"""Get a list of ItemInfo objects in this list"""
351
return [row[0] for row in self.model]
353
iter = self._iter_map[start_id]
355
while iter is not None:
356
retval.append(self.model[iter][0])
357
iter = self.model.next_iter(iter)
360
def _resort_items(self):
362
iter = self.model.first_iter()
363
while iter is not None:
364
rows.append(tuple(self.model[iter]))
365
iter = self.model.remove(iter)
366
self._sorter.sort_item_rows(rows)
368
self._iter_map[row[0].id] = self.model.append(*row)
370
def filter(self, item_info):
371
"""Can be overrided by subclasses to filter out items from the list.
375
def _should_show_item(self, item_info):
376
if not self.filter(item_info):
378
return (not (self.new_only and item_info.item_viewed) and
379
not (self.unwatched_only and item_info.video_watched) and
380
not (self.non_feed_only and (not item_info.is_external and item_info.feed_url != 'dtv:searchDownloads')) and
381
item_matches_search(item_info, self._search_text))
383
def set_show_details(self, item_id, value):
384
"""Change the show details value for an item"""
385
iter = self._iter_map[item_id]
386
self.model.update_value(iter, 1, value)
388
def find_show_details_rows(self):
389
"""Return a list of iters for rows with in show details mode."""
391
iter = self.model.first_iter()
392
while iter is not None:
393
if self.model[iter][1]:
395
iter = self.model.next_iter(iter)
398
def update_throbber(self, item_id):
400
iter = self._iter_map[item_id]
404
counter = self.model[iter][2]
405
self.model.update_value(iter, 2, counter + 1)
407
def _insert_sorted_items(self, item_list):
408
pos = self.model.first_iter()
409
for item_info in item_list:
410
while (pos is not None and
411
self._sorter.compare(self.model[pos][0], item_info) < 0):
412
pos = self.model.next_iter(pos)
413
iter = self.model.insert_before(pos, item_info, False, 0)
414
self._iter_map[item_info.id] = iter
416
def add_items(self, item_list, already_sorted=False):
418
for item in item_list:
419
if self._should_show_item(item):
422
self._hidden_items[item.id] = item
423
if not already_sorted:
424
self._sorter.sort_items(to_add)
425
self._insert_sorted_items(to_add)
427
def update_items(self, changed_items, already_sorted=False):
429
for info in changed_items:
430
should_show = self._should_show_item(info)
431
if info.id in self._iter_map:
432
# Item already displayed
434
self.remove_item(info.id)
435
self._hidden_items[info.id] = info
437
self.update_item(info)
439
# Item not already displayed
442
del self._hidden_items[info.id]
444
self._hidden_items[info.id] = info
445
if not already_sorted:
446
self._sorter.sort_items(to_add)
447
self._insert_sorted_items(to_add)
449
def remove_item(self, id):
451
iter = self._iter_map.pop(id)
453
pass # The item isn't in our current list, just skip it
455
self.model.remove(iter)
457
def update_item(self, info):
458
iter = self._iter_map[info.id]
459
self.model.update_value(iter, 0, info)
461
def remove_items(self, id_list):
465
def set_new_only(self, new_only):
466
"""Set if only new items are to be displayed (default False)."""
467
self.new_only = new_only
468
self._recalculate_hidden_items()
471
self.unwatched_only = False
472
self.non_feed_only = False
473
self._recalculate_hidden_items()
475
def toggle_unwatched_only(self):
476
self.unwatched_only = not self.unwatched_only
477
self._recalculate_hidden_items()
479
def toggle_non_feed(self):
480
self.non_feed_only = not self.non_feed_only
481
self._recalculate_hidden_items()
483
def set_search_text(self, search_text):
484
self._search_text = search_text
485
self._recalculate_hidden_items()
487
def _recalculate_hidden_items(self):
488
newly_matching = self._find_newly_matching_items()
489
removed = self._remove_non_matching_items()
490
self._sorter.sort_items(newly_matching)
491
self._insert_sorted_items(newly_matching)
493
self._hidden_items[item.id] = item
494
for item in newly_matching:
495
del self._hidden_items[item.id]
497
def move_items(self, insert_before, item_ids):
498
"""Move a group of items inside the list.
500
The items for item_ids will be positioned before insert_before.
501
insert_before should be an iterator, or None to position the items at
505
new_iters = _ItemReorderer().reorder(self.model, insert_before,
507
self._iter_map.update(new_iters)
509
def _find_newly_matching_items(self):
511
for item in self._hidden_items.values():
512
if self._should_show_item(item):
516
def _remove_non_matching_items(self):
518
iter = self.model.first_iter()
519
while iter is not None:
520
item = self.model[iter][0]
521
if not self._should_show_item(item):
522
iter = self.model.remove(iter)
523
del self._iter_map[item.id]
526
iter = self.model.next_iter(iter)
529
class IndividualDownloadItemList(ItemList):
530
"""ItemList that only displays single downloads items.
532
Used in the downloads tab."""
533
def filter(self, item_info):
534
return (item_info.is_external
535
and not (item_info.download_info
536
and item_info.download_info.state in ('uploading', 'uploading-paused')))
538
class ChannelDownloadItemList(ItemList):
539
"""ItemList that only displays channel downloads items.
541
Used in the downloads tab."""
542
def filter(self, item_info):
543
return (not item_info.is_external
544
and not (item_info.download_info
545
and item_info.download_info.state in ('uploading', 'uploading-paused')))
547
class SeedingItemList(ItemList):
548
"""ItemList that only displays seeding items.
550
Used in the downloads tab."""
551
def filter(self, item_info):
552
return (item_info.download_info
553
and item_info.download_info.state in ('uploading', 'uploading-paused'))
555
class DownloadingItemList(ItemList):
556
"""ItemList that only displays downloading items."""
557
def filter(self, item_info):
558
return (item_info.download_info
559
and not item_info.download_info.finished
560
and not item_info.download_info.state == 'failed')
562
class DownloadedItemList(ItemList):
563
"""ItemList that only displays downloaded items."""
564
def filter(self, item_info):
565
return (item_info.download_info and
566
item_info.download_info.finished)
568
class _ItemReorderer(object):
569
"""Handles re-ordering items inside an itemlist.
571
This object is just around for utility sake. It's only created to track
572
the state during the call to ItemList.move_items()
576
self.removed_rows = []
578
def calc_insert_id(self, model):
579
if self.insert_iter is not None:
580
self.insert_id = model[self.insert_iter][0].id
582
self.insert_id = None
584
def reorder(self, model, insert_iter, ids):
585
self.insert_iter = insert_iter
586
self.calc_insert_id(model)
587
self.remove_rows(model, ids)
588
return self.put_rows_back(model)
590
def remove_row(self, model, iter, row):
591
self.removed_rows.append(row)
592
if row[0].id == self.insert_id:
593
self.insert_iter = model.next_iter(self.insert_iter)
594
self.calc_insert_id(model)
595
return model.remove(iter)
597
def remove_rows(self, model, ids):
598
# iterating through the entire table seems inefficient, but we have to
599
# know the order of rows so we can insert them back in the right
601
iter = model.first_iter()
602
while iter is not None:
605
# need to make a copy of the row data, since we're removing it
607
iter = self.remove_row(model, iter, tuple(row))
609
iter = model.next_iter(iter)
611
def put_rows_back(self, model):
612
if self.insert_iter is None:
613
def put_back(moved_row):
614
return model.append(*moved_row)
616
def put_back(moved_row):
617
return model.insert_before(self.insert_iter, *moved_row)
619
for removed_row in self.removed_rows:
620
iter = put_back(removed_row)
621
retval[removed_row[0].id] = iter