flexget.plugins.filter_series
Covered: 765 lines
Missed: 3 lines
Skipped 253 lines
Percent: 99 %
   1
import logging
   2
from copy import copy
   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')
  20
class Series(Base):
  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')
  28
    def __repr__(self):
  29
        return '<Series(id=%s,name=%s)>' % (self.id, self.name)
  32
class Episode(Base):
  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')
  46
    @property
  47
    def age(self):
  48
        diff = datetime.now() - self.first_seen
  49
        age_days = diff.days
  50
        age_hours = diff.seconds / 60 / 60
  51
        age = ''
  52
        if age_days:
  53
            age += '%sd ' % age_days
  54
        age += '%sh' % age_hours
  55
        return age
  57
    @property
  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'
  63
        return False
  65
    def __init__(self):
  66
        self.first_seen = datetime.now()
  68
    def __repr__(self):
  69
        return '<Episode(id=%s,identifier=%s)>' % (self.id, self.identifier)
  72
class Release(Base):
  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))
  91
    def __repr__(self):
  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')
  97
def repair(manager):
  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."""
 100
    session = Session()
 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)
 104
    session.commit()
 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()
 116
        if not episode:
 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()
 128
        if not episode:
 130
            return False
 133
        return {'season': episode.season, 'episode': episode.number, 'name': name}
 135
    def auto_identified_by(self, session, name):
 136
        """
 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
 141
        """
 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))
 151
        if episodic >= 3 >= non_episodic:
 152
            return 'ep'
 153
        elif non_episodic >= 3 > episodic:
 154
            return 'id'
 155
        else:
 156
            return 'auto'
 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)
 169
            return False
 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()
 185
        if not downloaded:
 186
            log.debug('get_downloaded: no %s downloads recorded for %s' % (identifier, name))
 187
        return downloaded
 189
    def store(self, session, parser):
 190
        """Push series information into database. Returns added/existing release."""
 192
        series = session.query(Series).\
 193
            filter(func.lower(Series.name) == parser.name.lower()).\
 194
            filter(Series.id != None).first()
 195
        if not series:
 196
            log.debug('adding series %s into db' % parser.name)
 197
            series = Series()
 198
            series.name = parser.name
 199
            session.add(series)
 200
            log.debug('-> added %s' % series)
 203
        episode = session.query(Episode).filter(Episode.series_id == series.id).\
 204
            filter(Episode.identifier == parser.identifier).\
 205
            filter(Episode.series_id != None).first()
 206
        if not episode:
 207
            log.debug('adding episode %s into series %s' % (parser.identifier, parser.name))
 208
            episode = Episode()
 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)
 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()
 228
        if not release:
 229
            log.debug('adding release %s into episode' % parser)
 230
            release = Release()
 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)
 236
        return release
 239
def forget_series(name):
 240
    """Remove a whole series :name: from database."""
 241
    session = Session()
 242
    series = session.query(Series).filter(func.lower(Series.name) == name.lower()).first()
 243
    if series:
 244
        session.delete(series)
 245
        session.commit()
 246
        log.debug('Removed series %s from database.' % name)
 247
    else:
 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."""
 253
    session = Session()
 254
    series = session.query(Series).filter(func.lower(Series.name) == name.lower()).first()
 255
    if series:
 256
        episode = session.query(Episode).filter(Episode.identifier == identifier).\
 257
            filter(Episode.series_id == series.id).first()
 258
        if episode:
 259
            session.delete(episode)
 260
            session.commit()
 261
            log.debug('Episode %s from series %s removed from database.' % (identifier, name))
 262
        else:
 263
            raise ValueError('Unknown identifier %s for series %s' % (identifier, name.capitalize()))
 264
    else:
 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')
 278
        options.accept('regexp', key='name_regexp')
 279
        options.accept('regexp', key='ep_regexp')
 280
        options.accept('regexp', key='id_regexp')
 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):
 313
            config = {'simple': config,
 314
                      'settings': {}}
 315
        else:
 317
            if not 'settings' in config:
 318
                config['settings'] = {}
 320
        return config
 322
    def apply_group_options(self, config):
 323
        """Applies group settings to each item in series group and removes settings dict."""
 326
        config = self.make_grouped_config(config)
 327
        for group_name in config:
 328
            if group_name == 'settings':
 329
                continue
 330
            group_series = []
 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]:
 336
                series_settings = {}
 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)
 343
                if not isinstance(series, basestring):
 344
                    series = str(series)
 346
                if isinstance(series_settings, basestring):
 347
                    series_settings = {'path': 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']
 353
        return config
 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."""
 368
        unique_series = {}
 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
 374
                else:
 375
                    if kwargs.get('log_once'):
 376
                        log_once('Series %s is already configured in series plugin' % series, log)
 377
                    else:
 378
                        log.error('Series %s is configured multiple times in series plugin.' % series)
 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."""
 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):
 393
    """
 394
        Intelligent filter for tv-series.
 396
        http://flexget.com/wiki/FilterSeries
 397
    """
 399
    def __init__(self):
 400
        self.parser2entry = {}
 401
        self.backlog = None
 403
    def on_process_start(self, feed):
 404
        try:
 405
            self.backlog = get_plugin_by_name('backlog').instance
 406
        except DependencyError:
 407
            log.warning('Unable utilize backlog plugin, episodes may slip trough timeframe')
 409
    def validator(self):
 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')
 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')
 433
        build_list(simple)
 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')
 448
        build_list(group)
 450
        return root
 452
    def auto_exact(self, config):
 453
        """Automatically enable exact naming option for series that look like a problem"""
 456
        all_series = {}
 457
        for series_item in config:
 458
            series_name, series_config = series_item.items()[0]
 459
            all_series[series_name] = series_config
 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
 471
    @priority(125)
 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]
 478
            series_name = unicode(series_name)
 479
            log.debugall('series_name: %s series_config: %s' % (series_name, series_config))
 481
            import time
 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):
 489
        """Filter series"""
 493
        found_series = {}
 494
        guessed_series = {}
 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]
 506
            series_name = unicode(series_name)
 508
            db_series = feed.session.query(Series).filter(func.lower(Series.name) == series_name.lower()).first()
 509
            if db_series:
 510
                db_series.name = series_name
 511
            source = guessed_series if series_config.get('series_guessed') else found_series
 513
            if not source.get(series_name):
 514
                log.debugall('No entries found for %s this run.' % series_name)
 515
                continue
 516
            for id, eps in source[series_name].iteritems():
 517
                for parser in eps:
 519
                    release = self.store(feed.session, parser)
 520
                    entry = self.parser2entry[parser]
 521
                    entry['series_release'] = release
 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
 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))
 535
            import time
 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):
 544
        """
 545
            Search for :series_name: and populate all series_* fields in entries when successfully parsed
 546
        """
 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):
 552
                return [v]
 553
            return v
 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':
 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():
 579
                continue
 580
            else:
 581
                for field in ['title', 'description']:
 582
                    data = entry.get(field)
 584
                    if not isinstance(data, basestring) or not data:
 585
                        continue
 587
                    quality = None
 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']
 591
                    try:
 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)
 597
                    if parser.valid:
 598
                        break
 599
                else:
 600
                    continue
 602
            log.debug('%s detected as %s, field: %s' % (entry['title'], parser, parser.field))
 603
            entry['series_parser'] = copy(parser)
 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
 615
            else:
 616
                import time
 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():
 623
            if not eps:
 624
                continue
 625
            whitelist = []
 628
            eps.sort(reverse=True)
 630
            log.debug('start with episodes: %s' % [e.data for e in eps])
 633
            if 'watched' in config:
 634
                log.debug('-' * 20 + ' watched -->')
 635
                if self.process_watched(feed, config, eps):
 636
                    continue
 639
            log.debug('-' * 20 + ' process_propers -->')
 640
            removed, new_propers = self.process_propers(feed, config, eps)
 641
            whitelist.extend(new_propers)
 643
            for ep in removed:
 644
                log.debug('propers removed: %s' % ep)
 645
                eps.remove(ep)
 647
            if not eps:
 648
                continue
 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)
 660
                continue
 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)
 667
                eps.remove(ep)
 670
            if not eps:
 671
                continue
 673
            best = eps[0]
 674
            log.debug('continuing w. episodes: %s' % [e.data for e in eps])
 675
            log.debug('best episode is: %s' % best.data)
 678
            if best.season and best.episode:
 679
                if feed.manager.options.disable_advancement:
 680
                    log.debug('episode advancement disabled')
 681
                else:
 682
                    log.debug('-' * 20 + ' episode advancement -->')
 683
                    if self.process_episode_advancement(feed, eps, series):
 684
                        continue
 687
            if 'timeframe' in config:
 688
                log.debug('-' * 20 + ' timeframe -->')
 689
                self.process_timeframe(feed, config, eps, series_name)
 690
                continue
 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)
 697
                continue
 700
            reason = 'only choice'
 701
            if len(eps) > 1:
 702
                reason = 'choose best'
 703
            self.accept_series(feed, best, reason)
 705
    def process_propers(self, feed, config, eps):
 706
        """
 707
            Rejects downloaded propers, nukes episodes from which there exists proper.
 708
            Returns a list of removed episodes and a list of new propers.
 709
        """
 711
        proper_eps = [ep for ep in eps if ep.proper_or_repack]
 713
        if not proper_eps:
 714
            return [], []
 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:
 723
                if release.proper:
 724
                    return True
 727
        new_propers = []
 728
        removed = []
 729
        for ep in proper_eps:
 730
            if not proper_downloaded():
 731
                log.debug('found new proper %s' % ep)
 732
                new_propers.append(ep)
 733
            else:
 734
                feed.reject(self.parser2entry[ep], 'proper already downloaded')
 735
                removed.append(ep)
 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)
 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')
 748
                    removed.append(ep)
 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)
 759
            else:
 761
                amount, unit = config['propers'].split(' ')
 762
                log.debug('amount: %s unit: %s' % (repr(amount), repr(unit)))
 763
                params = {unit: int(amount)}
 764
                try:
 765
                    timeframe = timedelta(**params)
 766
                except TypeError:
 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):
 786
        """
 787
            Accepts all propers from qualities already downloaded.
 788
            :return: list of accepted
 789
        """
 790
        if not new_propers:
 791
            return []
 793
        downloaded_releases = self.get_downloaded(feed.session, eps[0].name, eps[0].identifier)
 794
        downloaded_qualities = [d.quality for d in downloaded_releases]
 796
        accepted = []
 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)
 804
        return accepted
 806
    def process_downloaded(self, feed, eps, whitelist):
 807
        """
 808
            Rejects all downloaded episodes (regardless of quality).
 809
            Doesn't reject reject anything in :whitelist:.
 810
        """
 812
        downloaded = set()
 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)
 817
            for ep in eps[:]:
 818
                if ep not in whitelist:
 820
                    downloaded.add(ep)
 821
        return downloaded
 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
 831
        else:
 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))
 836
        for ep in eps:
 837
            quality = ep.quality
 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')
 841
                break
 842
        else:
 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
 849
        best = eps[0]
 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))
 855
            for ep in eps:
 856
                entry = self.parser2entry[ep]
 857
                feed.reject(entry, 'watched')
 858
            return True
 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."""
 863
        current = eps[0]
 864
        latest = self.get_latest_download(feed.session, current.name)
 865
        log.debug('latest download: %s' % latest)
 866
        log.debug('current: %s' % current)
 868
        if latest:
 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')
 873
                for ep in eps:
 874
                    feed.reject(self.parser2entry[ep], 'too much in the past from latest downloaded episode S%02dE%02d' % (latest['season'], latest['episode']))
 875
                return True
 877
            if current.season > latest['season'] + 1:
 878
                log.debug('too new! rejecting all occurrences')
 879
                for ep in eps:
 880
                    feed.reject(self.parser2entry[ep], 'too much in the future from latest downloaded episode S%02dE%02d' % (latest['season'], latest['episode']))
 881
                return True
 883
    def process_timeframe(self, feed, config, eps, series_name):
 884
        """
 885
            The nasty timeframe logic, too complex even to explain (for now).
 886
            Returns True when there's no sense trying any other logic.
 887
        """
 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)')
 896
        best = eps[0]
 899
        amount, unit = config['timeframe'].split(' ')
 900
        log.debug('amount: %s unit: %s' % (repr(amount), repr(unit)))
 901
        params = {unit: int(amount)}
 902
        try:
 903
            timeframe = timedelta(**params)
 904
        except TypeError:
 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)
 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')
 918
                return True
 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]
 930
            if stop:
 931
                log.info('Stopped timeframe, accepting %s' % (entry['title']))
 932
            else:
 933
                log.info('Timeframe expired, accepting %s' % (entry['title']))
 934
            self.accept_series(feed, best, 'expired/stopped')
 935
            for ep in eps:
 936
                if ep == best:
 937
                    continue
 938
                entry = self.parser2entry[ep]
 939
                feed.reject(entry, 'wrong quality')
 940
            return True
 941
        else:
 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']))
 953
            log.debug('timeframe waiting %s episode %s, rejecting all occurrences' % (series_name, best.identifier))
 954
            for ep in eps:
 955
                feed.reject(self.parser2entry[ep], 'timeframe is waiting')
 957
            if self.backlog:
 958
                self.backlog.add_backlog(feed, entry)
 959
            return True
 962
    def process_qualities(self, feed, config, eps, whitelist=None):
 963
        """
 964
            Accepts all wanted qualities.
 965
            Accepts whitelisted episodes even if downloaded.
 966
        """
 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:
 976
                return True
 977
            for release in downloaded_releases:
 978
                if release.quality == quality:
 979
                    return True
 981
        wanted_qualities = [qualities.get(name) for name in config['qualities']]
 982
        log.debug('qualities: %s' % wanted_qualities)
 983
        for ep in eps:
 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)
 987
                continue
 988
            if is_quality_downloaded(ep.quality) and ep not in (whitelist or []):
 989
                feed.reject(self.parser2entry[ep], 'quality downloaded')
 990
            else:
 991
                feed.accept(self.parser2entry[ep], 'quality wanted')
 992
                accepted_qualities.append(ep.quality) # don't accept more of these
 995
    def accept_series(self, feed, parser, reason):
 996
        """Accept this series with a given reason"""
 997
        entry = self.parser2entry[parser]
 998
        if parser.field:
 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
1011
            else:
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.')