3
from datetime import datetime, timedelta
4
from flexget.utils.log import log_once
5
from sqlalchemy import Column, Integer, String, Unicode, DateTime, Boolean, desc, func
6
from sqlalchemy.schema import ForeignKey
7
from sqlalchemy.orm import relation, join, synonym
8
from flexget.event import event
9
from flexget.utils.titles import SeriesParser, ParseWarning
10
from flexget.utils import qualities
11
from flexget.utils.tools import merge_dict_from_to
12
from flexget.manager import Base, Session
13
from flexget.plugin import register_plugin, register_parser_option, get_plugin_by_name, get_plugin_keywords, \
14
PluginWarning, PluginError, DependencyError, priority
17
log = logging.getLogger('series')
22
__tablename__ = 'series'
24
id = Column(Integer, primary_key=True)
25
name = Column(Unicode)
26
episodes = relation('Episode', backref='series', cascade='all, delete, delete-orphan')
29
return '<Series(id=%s,name=%s)>' % (self.id, self.name)
34
__tablename__ = 'series_episodes'
36
id = Column(Integer, primary_key=True)
37
identifier = Column(String)
38
first_seen = Column(DateTime)
40
season = Column(Integer)
41
number = Column(Integer)
43
series_id = Column(Integer, ForeignKey('series.id'), nullable=False)
44
releases = relation('Release', backref='episode', cascade='all, delete, delete-orphan')
48
diff = datetime.now() - self.first_seen
50
age_hours = diff.seconds / 60 / 60
53
age += '%sd ' % age_days
54
age += '%sh' % age_hours
58
def is_premiere(self):
59
if self.season == 1 and self.number == 1:
60
return 'Series Premiere'
61
elif self.number == 1:
62
return 'Season Premiere'
66
self.first_seen = datetime.now()
69
return '<Episode(id=%s,identifier=%s)>' % (self.id, self.identifier)
74
__tablename__ = 'episode_releases'
76
id = Column(Integer, primary_key=True)
77
episode_id = Column(Integer, ForeignKey('series_episodes.id'), nullable=False)
78
_quality = Column('quality', String)
79
downloaded = Column(Boolean, default=False)
80
proper = Column(Boolean, default=False)
81
title = Column(Unicode)
83
def get_quality(self):
84
return qualities.get(self._quality)
86
def set_quality(self, value):
87
self._quality = value.name
89
quality = synonym('_quality', descriptor=property(get_quality, set_quality))
92
return '<Release(id=%s,quality=%s,downloaded=%s,proper=%s,title=%s)>' % \
93
(self.id, self.quality, self.downloaded, self.proper, self.title)
96
@event('manager.startup')
98
"""Perform database repairing at startup. For some reason at least I have some releases in
99
database which don't belong to any episode."""
101
for release in session.query(Release).filter(Release.episode == None).all():
102
log.info('Purging orphan release %s from database' % release.title)
103
session.delete(release)
107
class SeriesPlugin(object):
109
"""Database helpers"""
111
def get_first_seen(self, session, parser):
112
"""Return datetime when this episode of series was first seen"""
113
episode = session.query(Episode).select_from(join(Episode, Series)).\
114
filter(func.lower(Series.name) == parser.name.lower()).\
115
filter(Episode.identifier == parser.identifier).first()
117
log.debugall('%s not seen, return current time' % parser)
118
return datetime.now()
119
return episode.first_seen
121
def get_latest_info(self, session, name):
122
"""Return latest known identifier in dict (season, episode, name) for series name"""
123
episode = session.query(Episode).select_from(join(Episode, Series)).\
124
filter(Episode.season != None).\
125
filter(func.lower(Series.name) == name.lower()).\
126
order_by(desc(Episode.season)).\
127
order_by(desc(Episode.number)).first()
129
# log.debugall('get_latest_info: no info available for %s' % name)
131
# log.debugall('get_latest_info, series: %s season: %s episode: %s' % \
132
# (name, episode.season, episode.number))
133
return {'season': episode.season, 'episode': episode.number, 'name': name}
135
def auto_identified_by(self, session, name):
137
Determine if series :name: should be considered identified by episode or id format
139
Returns 'ep' or 'id' if 3 of the first 5 were parsed as such. Returns 'ep' in the event of a tie.
140
Returns 'auto' if there is not enough history to determine the format yet
142
total = session.query(Release).join(Episode).join(Series).\
143
filter(func.lower(Series.name) == name.lower()).count()
144
episodic = session.query(Release).join(Episode).join(Series).\
145
filter(func.lower(Series.name) == name.lower()).\
146
filter(Episode.season != None).\
147
filter(Episode.number != None).count()
148
non_episodic = total - episodic
149
log.debug('series %s auto ep/id check: %s/%s' % (name, episodic, non_episodic))
150
# Best of 5, episodic wins in a tie
151
if episodic >= 3 >= non_episodic:
153
elif non_episodic >= 3 > episodic:
158
def get_latest_download(self, session, name):
159
"""Return latest downloaded episode (season, episode, name) for series :name:"""
160
latest_download = session.query(Episode).join(Release, Series).\
161
filter(func.lower(Series.name) == name.lower()).\
162
filter(Release.downloaded == True).\
163
filter(Episode.season != None).\
164
filter(Episode.number != None).\
165
order_by(desc(Episode.season), desc(Episode.number)).first()
167
if not latest_download:
168
log.debug('get_latest_download returning false, no downloaded episodes found for: %s' % name)
171
return {'season': latest_download.season, 'episode': latest_download.number, 'name': name}
173
def get_releases(self, session, name, identifier):
174
"""Return all releases for series by identifier."""
175
return session.query(Release).join(Episode, Series).\
176
filter(func.lower(Series.name) == name.lower()).\
177
filter(Episode.identifier == identifier).all()
179
def get_downloaded(self, session, name, identifier):
180
"""Return list of downloaded releases for this episode"""
181
downloaded = session.query(Release).join(Episode, Series).\
182
filter(func.lower(Series.name) == name.lower()).\
183
filter(Episode.identifier == identifier).\
184
filter(Release.downloaded == True).all()
186
log.debug('get_downloaded: no %s downloads recorded for %s' % (identifier, name))
189
def store(self, session, parser):
190
"""Push series information into database. Returns added/existing release."""
191
# if series does not exist in database, add new
192
series = session.query(Series).\
193
filter(func.lower(Series.name) == parser.name.lower()).\
194
filter(Series.id != None).first()
196
log.debug('adding series %s into db' % parser.name)
198
series.name = parser.name
200
log.debug('-> added %s' % series)
202
# if episode does not exist in series, add new
203
episode = session.query(Episode).filter(Episode.series_id == series.id).\
204
filter(Episode.identifier == parser.identifier).\
205
filter(Episode.series_id != None).first()
207
log.debug('adding episode %s into series %s' % (parser.identifier, parser.name))
209
episode.identifier = parser.identifier
211
if parser.season and parser.episode:
212
episode.season = parser.season
213
episode.number = parser.episode
214
series.episodes.append(episode) # pylint:disable=E1103
215
log.debug('-> added %s' % episode)
217
# if release does not exists in episodes, add new
221
# filter(Release.episode_id != None) fixes weird bug where release had/has been added
222
# to database but doesn't have episode_id, this causes all kinds of havoc with the plugin.
223
# perhaps a bug in sqlalchemy?
224
release = session.query(Release).filter(Release.episode_id == episode.id).\
225
filter(Release.quality == parser.quality.name).\
226
filter(Release.proper == parser.proper_or_repack).\
227
filter(Release.episode_id != None).first()
229
log.debug('adding release %s into episode' % parser)
231
release.quality = parser.quality
232
release.proper = parser.proper_or_repack
233
release.title = parser.data
234
episode.releases.append(release) # pylint:disable=E1103
235
log.debug('-> added %s' % release)
239
def forget_series(name):
240
"""Remove a whole series :name: from database."""
242
series = session.query(Series).filter(func.lower(Series.name) == name.lower()).first()
244
session.delete(series)
246
log.debug('Removed series %s from database.' % name)
248
raise ValueError('Unknown series %s' % name)
251
def forget_series_episode(name, identifier):
252
"""Remove all episodes by :identifier: from series :name: from database."""
254
series = session.query(Series).filter(func.lower(Series.name) == name.lower()).first()
256
episode = session.query(Episode).filter(Episode.identifier == identifier).\
257
filter(Episode.series_id == series.id).first()
259
session.delete(episode)
261
log.debug('Episode %s from series %s removed from database.' % (identifier, name))
263
raise ValueError('Unknown identifier %s for series %s' % (identifier, name.capitalize()))
265
raise ValueError('Unknown series %s' % name)
268
class FilterSeriesBase(object):
269
"""Class that contains helper methods for both filter_series as well as plugins that configure it,
270
such as thetvdb_favorites, all_series and series_premiere."""
272
def build_options_validator(self, options):
273
quals = [q.name for q in qualities.all()]
274
options.accept('text', key='path')
276
options.accept('dict', key='set').accept_any_key('any')
277
# regexes can be given in as a single string ..
278
options.accept('regexp', key='name_regexp')
279
options.accept('regexp', key='ep_regexp')
280
options.accept('regexp', key='id_regexp')
281
# .. or as list containing strings
282
options.accept('list', key='name_regexp').accept('regexp')
283
options.accept('list', key='ep_regexp').accept('regexp')
284
options.accept('list', key='id_regexp').accept('regexp')
286
options.accept('choice', key='quality').accept_choices(quals, ignore_case=True)
287
options.accept('list', key='qualities').accept('choice').accept_choices(quals, ignore_case=True)
288
options.accept('choice', key='min_quality').accept_choices(quals, ignore_case=True)
289
options.accept('choice', key='max_quality').accept_choices(quals, ignore_case=True)
291
options.accept('boolean', key='propers')
292
message = "should be in format 'x (minutes|hours|days|weeks)' e.g. '5 days'"
293
time_regexp = r'\d+ (minutes|hours|days|weeks)'
294
options.accept('regexp_match', key='propers', message=message + ' or yes/no').accept(time_regexp)
296
options.accept('choice', key='identified_by').accept_choices(['ep', 'id', 'auto'])
298
options.accept('regexp_match', key='timeframe', message=message).accept(time_regexp)
300
options.accept('boolean', key='exact')
302
watched = options.accept('dict', key='watched')
303
watched.accept('integer', key='season')
304
watched.accept('integer', key='episode')
306
options.accept('text', key='from_group')
307
options.accept('list', key='from_group').accept('text')
309
def make_grouped_config(self, config):
310
"""Turns a simple series list into grouped format with a settings dict"""
311
if not isinstance(config, dict):
312
# convert simplest configuration internally grouped format
313
config = {'simple': config,
316
# already in grouped format, just get settings from there
317
if not 'settings' in config:
318
config['settings'] = {}
322
def apply_group_options(self, config):
323
"""Applies group settings to each item in series group and removes settings dict."""
325
# Make sure config is in grouped format first
326
config = self.make_grouped_config(config)
327
for group_name in config:
328
if group_name == 'settings':
331
# if group name is known quality, convenience create settings with that quality
332
if isinstance(group_name, basestring) and group_name.lower() in qualities.registry:
333
config['settings'].setdefault(group_name, {}).setdefault('quality', group_name)
334
for series in config[group_name]:
335
# convert into dict-form if necessary
337
group_settings = config['settings'].get(group_name, {})
338
if isinstance(series, dict):
339
series, series_settings = series.items()[0]
340
if series_settings is None:
341
raise Exception('Series %s has unexpected \':\'' % series)
342
# make sure series name is a string to accommodate for "24"
343
if not isinstance(series, basestring):
345
# if series have given path instead of dict, convert it into a dict
346
if isinstance(series_settings, basestring):
347
series_settings = {'path': series_settings}
348
# merge group settings into this series settings
349
merge_dict_from_to(group_settings, series_settings)
350
group_series.append({series: series_settings})
351
config[group_name] = group_series
352
del config['settings']
355
def prepare_config(self, config):
356
"""Generate a list of unique series from configuration.
357
This way we don't need to handle two different configuration formats in the logic.
358
Applies group settings with advanced form."""
360
config = self.apply_group_options(config)
361
return self.combine_series_lists(*config.values())
363
def combine_series_lists(self, *series_lists, **kwargs):
364
"""Combines the series from multiple lists, making sure there are no doubles.
366
If keyword argument log_once is set to True, an error message will be printed if a series
367
is listed more than once, otherwise log_once will be used."""
369
for series_list in series_lists:
370
for series in series_list:
371
series, series_settings = series.items()[0]
372
if series not in unique_series:
373
unique_series[series] = series_settings
375
if kwargs.get('log_once'):
376
log_once('Series %s is already configured in series plugin' % series, log)
378
log.error('Series %s is configured multiple times in series plugin.' % series)
379
# Turn our all_series dict back into a list
380
return [{series: settings} for (series, settings) in unique_series.iteritems()]
382
def merge_config(self, feed, config):
383
"""Merges another series config dict in with the current one."""
385
# Make sure we start with both configs as a list of complex series
386
native_series = self.prepare_config(feed.config.get('series', {}))
387
merging_series = self.prepare_config(config)
388
feed.config['series'] = self.combine_series_lists(native_series, merging_series, log_once=True)
389
return feed.config['series']
392
class FilterSeries(SeriesPlugin, FilterSeriesBase):
394
Intelligent filter for tv-series.
396
http://flexget.com/wiki/FilterSeries
400
self.parser2entry = {}
403
def on_process_start(self, feed):
405
self.backlog = get_plugin_by_name('backlog').instance
406
except DependencyError:
407
log.warning('Unable utilize backlog plugin, episodes may slip trough timeframe')
410
from flexget import validator
412
def build_list(series):
413
"""Build series list to series."""
414
series.accept('text')
415
series.accept('number')
416
bundle = series.accept('dict')
417
# prevent invalid indentation level
418
bundle.reject_keys(['set', 'path', 'timeframe', 'name_regexp',
419
'ep_regexp', 'id_regexp', 'watched', 'quality', 'min_quality',
420
'max_quality', 'qualities', 'exact', 'from_group'],
421
'Option \'$key\' has invalid indentation level. It needs 2 more spaces.')
422
bundle.accept_any_key('path')
423
options = bundle.accept_any_key('dict')
424
self.build_options_validator(options)
426
root = validator.factory()
432
simple = root.accept('list')
441
advanced = root.accept('dict')
442
settings = advanced.accept('dict', key='settings')
443
settings.reject_keys(get_plugin_keywords())
444
settings_group = settings.accept_any_key('dict')
445
self.build_options_validator(settings_group)
447
group = advanced.accept_any_key('list')
452
def auto_exact(self, config):
453
"""Automatically enable exact naming option for series that look like a problem"""
455
# generate list of all series in one dict
457
for series_item in config:
458
series_name, series_config = series_item.items()[0]
459
all_series[series_name] = series_config
461
# scan for problematic names, enable exact mode for them
462
for series_name, series_config in all_series.iteritems():
463
for name in all_series.keys():
464
if (name.lower().startswith(series_name.lower())) and \
465
(name.lower() != series_name.lower()):
466
if not 'exact' in series_config:
467
log.info('Auto enabling exact matching for series %s (reason %s)' % (series_name, name))
468
series_config['exact'] = True
470
# Run after metainfo_quality and before metainfo_series
472
def on_feed_metainfo(self, feed):
473
config = self.prepare_config(feed.config.get('series', {}))
474
self.auto_exact(config)
475
for series_item in config:
476
series_name, series_config = series_item.items()[0]
477
# yaml loads ascii only as str
478
series_name = unicode(series_name)
479
log.debugall('series_name: %s series_config: %s' % (series_name, series_config))
482
start_time = time.clock()
484
self.parse_series(feed, series_name, series_config)
485
took = time.clock() - start_time
486
log.debugall('parsing %s took %s' % (series_name, took))
488
def on_feed_filter(self, feed):
490
# Parsing was done in metainfo phase, create the dicts to pass to process_series from the feed entries
491
# key: series (episode) identifier ie. S01E02
492
# value: seriesparser
495
for entry in feed.entries:
496
if entry.get('series_name') and entry.get('series_id') and entry.get('series_parser'):
497
self.parser2entry[entry['series_parser']] = entry
498
target = guessed_series if entry.get('series_guessed') else found_series
499
target.setdefault(entry['series_name'], {}).setdefault(entry['series_id'], []).append(entry['series_parser'])
501
config = self.prepare_config(feed.config.get('series', {}))
503
for series_item in config:
504
series_name, series_config = series_item.items()[0]
505
# yaml loads ascii only as str
506
series_name = unicode(series_name)
507
# Update database with capitalization from config
508
db_series = feed.session.query(Series).filter(func.lower(Series.name) == series_name.lower()).first()
510
db_series.name = series_name
511
source = guessed_series if series_config.get('series_guessed') else found_series
512
# If we didn't find any episodes for this series, continue
513
if not source.get(series_name):
514
log.debugall('No entries found for %s this run.' % series_name)
516
for id, eps in source[series_name].iteritems():
518
# store found episodes into database and save reference for later use
519
release = self.store(feed.session, parser)
520
entry = self.parser2entry[parser]
521
entry['series_release'] = release
523
# set custom download path
524
if 'path' in series_config:
525
log.debug('setting %s custom path to %s' % (entry['title'], series_config.get('path')))
526
entry['path'] = series_config.get('path') % entry
528
# accept info from set: and place into the entry
529
if 'set' in series_config:
530
set = get_plugin_by_name('set')
531
set.instance.modify(entry, series_config.get('set'))
533
log.debugall('series_name: %s series_config: %s' % (series_name, series_config))
536
start_time = time.clock()
538
self.process_series(feed, source[series_name], series_name, series_config)
540
took = time.clock() - start_time
541
log.debugall('processing %s took %s' % (series_name, took))
543
def parse_series(self, feed, series_name, config):
545
Search for :series_name: and populate all series_* fields in entries when successfully parsed
548
def get_as_array(config, key):
549
"""Return configuration key as array, even if given as a single string"""
550
v = config.get(key, [])
551
if isinstance(v, basestring):
557
identified_by = config.get('identified_by', 'auto')
558
if identified_by not in ['ep', 'id', 'auto']:
559
raise PluginError('Unknown identified_by value %s for the series %s' % (identified_by, series_name))
561
if identified_by == 'auto':
562
# determine if series is known to be in season, episode format or identified by id
563
identified_by = self.auto_identified_by(feed.session, series_name)
564
if identified_by != 'auto':
565
log.debug('identified_by set to \'%s\' based on series history' % identified_by)
567
parser = SeriesParser(name=series_name,
568
identified_by=identified_by,
569
name_regexps=get_as_array(config, 'name_regexp'),
570
ep_regexps=get_as_array(config, 'ep_regexp'),
571
id_regexps=get_as_array(config, 'id_regexp'),
572
strict_name=config.get('exact', False),
573
allow_groups=get_as_array(config, 'from_group'))
575
for entry in feed.entries:
576
if entry.get('series_parser') and entry['series_parser'].valid and not entry.get('series_guessed') and \
577
entry['series_parser'].name.lower() != series_name.lower():
578
# This was detected as another series, we can skip it.
581
for field in ['title', 'description']:
582
data = entry.get(field)
583
# skip invalid fields
584
if not isinstance(data, basestring) or not data:
586
# in case quality will not be found from title, set it from entry['quality'] if available
588
if entry.get('quality', qualities.UNKNOWN) > qualities.UNKNOWN:
589
log.debugall('Setting quality %s from entry field to parser' % entry['quality'])
590
quality = entry['quality']
592
parser.parse(data, field=field, quality=quality)
593
except ParseWarning, pw:
594
from flexget.utils.log import log_once
595
log_once(pw.value, logger=log)
602
log.debug('%s detected as %s, field: %s' % (entry['title'], parser, parser.field))
603
entry['series_parser'] = copy(parser)
604
# add series, season and episode to entry
605
entry['series_name'] = series_name
606
entry['series_guessed'] = config.get('series_guessed', False)
607
if 'quality' in entry and entry['quality'] != parser.quality:
608
log.warning('Found different quality for %s. Was %s, overriding with %s.' % \
609
(entry['title'], entry['quality'], parser.quality))
610
entry['quality'] = parser.quality
611
entry['proper'] = parser.proper
612
if parser.season and parser.episode:
613
entry['series_season'] = parser.season
614
entry['series_episode'] = parser.episode
617
entry['series_season'] = time.gmtime().tm_year
618
entry['series_id'] = parser.identifier
620
def process_series(self, feed, series, series_name, config):
621
"""Accept or Reject episode from available releases, or postpone choosing."""
622
for eps in series.itervalues():
627
# sort episodes in order of quality
628
eps.sort(reverse=True)
630
log.debug('start with episodes: %s' % [e.data for e in eps])
632
# reject episodes that have been marked as watched in config file
633
if 'watched' in config:
634
log.debug('-' * 20 + ' watched -->')
635
if self.process_watched(feed, config, eps):
639
log.debug('-' * 20 + ' process_propers -->')
640
removed, new_propers = self.process_propers(feed, config, eps)
641
whitelist.extend(new_propers)
644
log.debug('propers removed: %s' % ep)
650
log.debug('-' * 20 + ' accept_propers -->')
651
accepted = self.accept_propers(feed, eps, whitelist)
652
whitelist.extend(accepted)
654
log.debug('current episodes: %s' % [e.data for e in eps])
657
if 'qualities' in config:
658
log.debug('-' * 20 + ' process_qualities -->')
659
self.process_qualities(feed, config, eps, whitelist)
663
log.debug('-' * 20 + ' downloaded -->')
664
for ep in self.process_downloaded(feed, eps, whitelist):
665
feed.reject(self.parser2entry[ep], 'already downloaded episode with id \'%s\'' % str(ep.identifier))
666
log.debug('downloaded removed: %s' % ep)
669
# no releases left, continue to next episode
674
log.debug('continuing w. episodes: %s' % [e.data for e in eps])
675
log.debug('best episode is: %s' % best.data)
677
# episode advancement. used only with season based series
678
if best.season and best.episode:
679
if feed.manager.options.disable_advancement:
680
log.debug('episode advancement disabled')
682
log.debug('-' * 20 + ' episode advancement -->')
683
if self.process_episode_advancement(feed, eps, series):
687
if 'timeframe' in config:
688
log.debug('-' * 20 + ' timeframe -->')
689
self.process_timeframe(feed, config, eps, series_name)
692
# quality, min_quality, max_quality and NO timeframe
693
if ('timeframe' not in config and 'qualities' not in config) and \
694
('quality' in config or 'min_quality' in config or 'max_quality' in config):
695
log.debug('-' * 20 + ' process quality -->')
696
self.process_quality(feed, config, eps)
699
# no special configuration, just choose the best
700
reason = 'only choice'
702
reason = 'choose best'
703
self.accept_series(feed, best, reason)
705
def process_propers(self, feed, config, eps):
707
Rejects downloaded propers, nukes episodes from which there exists proper.
708
Returns a list of removed episodes and a list of new propers.
711
proper_eps = [ep for ep in eps if ep.proper_or_repack]
712
# Return if there are no propers for this episode
716
downloaded_releases = self.get_downloaded(feed.session, eps[0].name, eps[0].identifier)
717
downloaded_qualities = [d.quality for d in downloaded_releases]
719
log.debug('downloaded qualities: %s' % downloaded_qualities)
721
def proper_downloaded():
722
for release in downloaded_releases:
729
for ep in proper_eps:
730
if not proper_downloaded():
731
log.debug('found new proper %s' % ep)
732
new_propers.append(ep)
734
feed.reject(self.parser2entry[ep], 'proper already downloaded')
737
if downloaded_qualities:
738
for proper in new_propers[:]:
739
if proper.quality not in downloaded_qualities:
740
log.debug('proper %s quality mismatch' % proper)
741
new_propers.remove(proper)
743
# nuke qualities which there is proper available
744
for proper in new_propers:
745
for ep in set(eps) - set(removed) - set(new_propers):
746
if ep.quality == proper.quality:
747
feed.reject(self.parser2entry[ep], 'nuked')
750
# nuke propers after timeframe
751
if 'propers' in config:
752
if isinstance(config['propers'], bool):
753
if not config['propers']:
755
for proper in new_propers[:]:
756
feed.reject(self.parser2entry[proper], 'no propers')
757
removed.append(proper)
758
new_propers.remove(proper)
760
# propers with timeframe
761
amount, unit = config['propers'].split(' ')
762
log.debug('amount: %s unit: %s' % (repr(amount), repr(unit)))
763
params = {unit: int(amount)}
765
timeframe = timedelta(**params)
767
raise PluginWarning('Invalid time format', log)
769
first_seen = self.get_first_seen(feed.session, eps[0])
770
expires = first_seen + timeframe
771
log.debug('propers timeframe: %s' % timeframe)
772
log.debug('first_seen: %s' % first_seen)
773
log.debug('propers ignore after: %s' % str(expires))
775
if datetime.now() > expires:
776
log.debug('propers timeframe expired')
777
for proper in new_propers[:]:
778
feed.reject(self.parser2entry[proper], 'propers timeframe expired')
779
removed.append(proper)
780
new_propers.remove(proper)
782
log.debug('new_propers: %s' % [e.data for e in new_propers])
783
return removed, new_propers
785
def accept_propers(self, feed, eps, new_propers):
787
Accepts all propers from qualities already downloaded.
788
:return: list of accepted
793
downloaded_releases = self.get_downloaded(feed.session, eps[0].name, eps[0].identifier)
794
downloaded_qualities = [d.quality for d in downloaded_releases]
798
for proper in new_propers:
799
if proper.quality in downloaded_qualities:
800
log.debug('we\'ve downloaded quality %s, accepting proper from it' % proper.quality)
801
feed.accept(self.parser2entry[proper], 'proper')
802
accepted.append(proper)
806
def process_downloaded(self, feed, eps, whitelist):
808
Rejects all downloaded episodes (regardless of quality).
809
Doesn't reject reject anything in :whitelist:.
813
downloaded_releases = self.get_downloaded(feed.session, eps[0].name, eps[0].identifier)
814
log.debug('downloaded: %s' % [e.title for e in downloaded_releases])
815
if downloaded_releases and eps:
816
log.debug('identifier %s is downloaded' % eps[0].identifier)
818
if ep not in whitelist:
819
# same episode can appear multiple times, so use a set to avoid duplicates
823
def process_quality(self, feed, config, eps):
824
"""Accepts episodes that meet configured qualities"""
826
min = qualities.UNKNOWN
827
max = qualities.max()
828
if 'quality' in config:
829
quality = qualities.get(config['quality'])
830
min, max = quality, quality
832
min = qualities.get(config.get('min_quality', ''), min)
833
max = qualities.get(config.get('max_quality', ''), max)
834
log.debug('min: %s max: %s' % (min, max))
835
# see if any of the eps match accepted qualities
838
log.debug('ep: %s min: %s max: %s quality: %r' % (ep.data, min.value, max.value, quality))
839
if quality <= max and quality >= min:
840
self.accept_series(feed, ep, 'meets quality')
843
log.debug('no quality meets requirements')
845
def process_watched(self, feed, config, eps):
846
"""Rejects all episodes older than defined in watched, returns True when this happens."""
848
from sys import maxint
850
wconfig = config.get('watched')
851
season = wconfig.get('season', -1)
852
episode = wconfig.get('episode', maxint)
853
if best.season < season or (best.season == season and best.episode <= episode):
854
log.debug('%s episode %s is already watched, rejecting all occurrences' % (best.name, best.identifier))
856
entry = self.parser2entry[ep]
857
feed.reject(entry, 'watched')
860
def process_episode_advancement(self, feed, eps, series):
861
"""Rejects all episodes that are too old or new (advancement), return True when this happens."""
864
latest = self.get_latest_download(feed.session, current.name)
865
log.debug('latest download: %s' % latest)
866
log.debug('current: %s' % current)
869
# allow few episodes "backwards" in case of missed eps
870
grace = len(series) + 2
871
if (current.season < latest['season']) or (current.season == latest['season'] and current.episode < (latest['episode'] - grace)):
872
log.debug('too old! rejecting all occurrences')
874
feed.reject(self.parser2entry[ep], 'too much in the past from latest downloaded episode S%02dE%02d' % (latest['season'], latest['episode']))
877
if current.season > latest['season'] + 1:
878
log.debug('too new! rejecting all occurrences')
880
feed.reject(self.parser2entry[ep], 'too much in the future from latest downloaded episode S%02dE%02d' % (latest['season'], latest['episode']))
883
def process_timeframe(self, feed, config, eps, series_name):
885
The nasty timeframe logic, too complex even to explain (for now).
886
Returns True when there's no sense trying any other logic.
889
if 'max_quality' in config:
890
log.warning('Timeframe does not support max_quality (yet)')
891
if 'min_quality' in config:
892
log.warning('Timeframe does not support min_quality (yet)')
893
if 'qualities' in config:
894
log.warning('Timeframe does not support qualities (yet)')
899
amount, unit = config['timeframe'].split(' ')
900
log.debug('amount: %s unit: %s' % (repr(amount), repr(unit)))
901
params = {unit: int(amount)}
903
timeframe = timedelta(**params)
905
raise PluginWarning('Invalid time format', log)
907
quality_name = config.get('quality', '720p')
908
if quality_name not in qualities.registry:
909
log.error('Parameter quality has unknown value: %s' % quality_name)
910
quality = qualities.get(quality_name)
912
# scan for quality, starting from worst quality (reverse) (old logic, see note below)
913
for ep in reversed(eps):
914
if quality == ep.quality:
915
entry = self.parser2entry[ep]
916
log.debug('Timeframe accepting. %s meets quality %s' % (entry['title'], quality))
917
self.accept_series(feed, ep, 'quality met, timeframe unnecessary')
920
# expire timeframe, accept anything
921
first_seen = self.get_first_seen(feed.session, best)
922
expires = first_seen + timeframe
923
log.debug('timeframe: %s' % timeframe)
924
log.debug('first_seen: %s' % first_seen)
925
log.debug('timeframe expires: %s' % str(expires))
927
stop = feed.manager.options.stop_waiting.lower() == series_name.lower()
928
if expires <= datetime.now() or stop:
929
entry = self.parser2entry[best]
931
log.info('Stopped timeframe, accepting %s' % (entry['title']))
933
log.info('Timeframe expired, accepting %s' % (entry['title']))
934
self.accept_series(feed, best, 'expired/stopped')
938
entry = self.parser2entry[ep]
939
feed.reject(entry, 'wrong quality')
942
# verbose waiting, add to backlog
943
diff = expires - datetime.now()
945
hours, remainder = divmod(diff.seconds, 3600)
946
minutes, seconds = divmod(remainder, 60)
948
entry = self.parser2entry[best]
949
log.info('Timeframe waiting %s for %sh:%smin, currently best is %s' % \
950
(series_name, hours, minutes, entry['title']))
952
# reject all episodes that are in timeframe
953
log.debug('timeframe waiting %s episode %s, rejecting all occurrences' % (series_name, best.identifier))
955
feed.reject(self.parser2entry[ep], 'timeframe is waiting')
956
# add best entry to backlog (backlog is able to handle duplicate adds)
958
self.backlog.add_backlog(feed, entry)
961
# TODO: whitelist deprecated ?
962
def process_qualities(self, feed, config, eps, whitelist=None):
964
Accepts all wanted qualities.
965
Accepts whitelisted episodes even if downloaded.
968
# get list of downloaded releases
969
downloaded_releases = self.get_downloaded(feed.session, eps[0].name, eps[0].identifier)
970
log.debug('downloaded_releases: %s' % downloaded_releases)
972
accepted_qualities = []
974
def is_quality_downloaded(quality):
975
if quality in accepted_qualities:
977
for release in downloaded_releases:
978
if release.quality == quality:
981
wanted_qualities = [qualities.get(name) for name in config['qualities']]
982
log.debug('qualities: %s' % wanted_qualities)
984
log.debug('ep: %s quality: %s' % (ep.data, ep.quality))
985
if ep.quality not in wanted_qualities:
986
log.debug('%s is unwanted quality' % ep.quality)
988
if is_quality_downloaded(ep.quality) and ep not in (whitelist or []):
989
feed.reject(self.parser2entry[ep], 'quality downloaded')
991
feed.accept(self.parser2entry[ep], 'quality wanted')
992
accepted_qualities.append(ep.quality) # don't accept more of these
994
# TODO: get rid of, see how feed.reject is called, consistency!
995
def accept_series(self, feed, parser, reason):
996
"""Accept this series with a given reason"""
997
entry = self.parser2entry[parser]
999
if entry[parser.field] != parser.data:
1000
log.critical('BUG? accepted title is different from parser.data %s != %s, field=%s, series=%s' % \
1001
(entry[parser.field], parser.data, parser.field, parser.name))
1002
feed.accept(entry, reason)
1004
def on_feed_exit(self, feed):
1005
"""Learn succeeded episodes"""
1006
log.debug('on_feed_exit')
1007
for entry in feed.accepted:
1008
if 'series_release' in entry:
1009
log.debug('marking %s as downloaded' % entry['series_release'])
1010
entry['series_release'].downloaded = True
1012
log.debug('%s is not a series' % entry['title'])
1016
register_plugin(FilterSeries, 'series')
1017
register_parser_option('--stop-waiting', action='store', dest='stop_waiting', default='',
1018
metavar='NAME', help='Stop timeframe for a given series.')
1019
register_parser_option('--disable-advancement', action='store_true', dest='disable_advancement', default=False,
1020
help='Disable episode advancement for this run.')