Source code for flexget.components.series.series

import itertools
import sys
import time
from collections import defaultdict
from copy import copy
from datetime import datetime

import pendulum
from loguru import logger
from sqlalchemy import not_
from sqlalchemy.orm import joinedload, object_session

from flexget import options, plugin
from flexget.config_schema import one_or_more
from flexget.event import event
from flexget.manager import Session
from flexget.utils import qualities
from flexget.utils.log import log_once
from flexget.utils.tools import chunked, get_config_as_array, merge_dict_from_to, parse_timedelta

from . import db
from .utils import normalize_series_name

try:
    # NOTE: Importing other plugins is discouraged!
    from flexget.components.parsing.parsers import parser_common as plugin_parser_common
except ImportError:
    raise plugin.DependencyError(issued_by=__name__, missing='parsers')

logger = logger.bind(name='series')

try:
    preferred_clock = time.process_time
except AttributeError:
    preferred_clock = time.clock


[docs] @event('manager.lock_acquired') def repair(manager): # Perform database repairing and upgrading at startup. if not manager.persist.get('series_repaired', False): session = Session() try: # For some reason at least I have some releases in database which don't belong to any episode. for release in ( session.query(db.EpisodeRelease).filter(db.EpisodeRelease.episode.is_(None)).all() ): logger.info('Purging orphan release {} from database', release.title) session.delete(release) session.commit() finally: session.close() manager.persist['series_repaired'] = True # Run clean_series the first time we get a database lock, since we won't have had one the first time the config # got loaded. clean_series(manager)
[docs] @event('manager.config_updated') def clean_series(manager): # Unmark series from tasks which have been deleted. if not manager.has_lock: return with Session() as session: removed_tasks = session.query(db.SeriesTask) if manager.tasks: removed_tasks = removed_tasks.filter(not_(db.SeriesTask.name.in_(manager.tasks))) deleted = removed_tasks.delete(synchronize_session=False) if deleted: session.commit()
[docs] def populate_entry_fields(entry, parser, config): """Populate all series_fields for given entry based on parser. :param parser: A valid result from a series parser used to populate the fields. :config dict: If supplied, will use 'path' and 'set' options to populate specified fields. """ entry['series_parser'] = copy(parser) # add series, season and episode to entry entry['series_name'] = parser.name if 'quality' in entry and entry['quality'] != parser.quality: logger.verbose( 'Found different quality for `{}`. Was `{}`, overriding with `{}`.', entry['title'], entry['quality'], parser.quality, ) entry['quality'] = parser.quality entry['proper'] = parser.proper entry['proper_count'] = parser.proper_count entry['release_group'] = parser.group entry['season_pack'] = parser.season_pack if parser.id_type == 'ep': entry['series_season'] = parser.season if not parser.season_pack: entry['series_episode'] = parser.episode elif parser.id_type == 'date': entry['series_date'] = parser.id entry['series_season'] = parser.id.year else: entry['series_season'] = time.gmtime().tm_year entry['series_episodes'] = parser.episodes entry['series_id'] = parser.pack_identifier entry['series_id_type'] = parser.id_type entry['series_identified_by'] = parser.identified_by entry['series_exact'] = parser.strict_name # If a config is passed in, also look for 'path' and 'set' options to set more fields if config: # set custom download path if 'path' in config: logger.debug( 'setting custom path for `{}` to `{}`', entry['title'], config.get('path') ) # Just add this to the 'set' dictionary, so that string replacement is done cleanly config.setdefault('set', {}).update(path=config['path']) # accept info from set: and place into the entry if 'set' in config: plugin.get('set', 'series').modify(entry, config.get('set'))
[docs] class FilterSeriesBase: """Class that contains helper methods for both filter.series as well as plugins that configure it, such as all_series, series_premiere and configure_series.""" @property def settings_schema(self): return { 'title': 'series options', 'type': 'object', 'properties': { 'path': {'type': 'string'}, 'set': {'type': 'object'}, 'alternate_name': one_or_more({'type': 'string'}), # Custom regexp options 'name_regexp': one_or_more({'type': 'string', 'format': 'regex'}), 'ep_regexp': one_or_more({'type': 'string', 'format': 'regex'}), 'date_regexp': one_or_more({'type': 'string', 'format': 'regex'}), 'sequence_regexp': one_or_more({'type': 'string', 'format': 'regex'}), 'id_regexp': one_or_more({'type': 'string', 'format': 'regex'}), # Date parsing options 'date_yearfirst': {'type': 'boolean'}, 'date_dayfirst': {'type': 'boolean'}, # Quality options 'quality': {'type': 'string', 'format': 'quality_requirements'}, 'qualities': { 'type': 'array', 'items': {'type': 'string', 'format': 'quality_requirements'}, }, 'timeframe': {'type': 'string', 'format': 'interval'}, 'upgrade': {'type': 'boolean'}, 'target': {'type': 'string', 'format': 'quality_requirements'}, # Specials 'specials': {'type': 'boolean'}, # Propers (can be boolean, or an interval string) 'propers': {'type': ['boolean', 'string'], 'format': 'interval'}, # Identified by 'identified_by': { 'type': 'string', 'enum': ['ep', 'date', 'sequence', 'id', 'auto'], }, # Strict naming 'exact': {'type': 'boolean'}, # Begin takes an ep, sequence or date identifier 'begin': {'type': ['string', 'integer'], 'format': 'episode_or_season_id'}, 'from_group': one_or_more({'type': 'string'}), 'parse_only': {'type': 'boolean'}, 'special_ids': one_or_more({'type': 'string'}), 'prefer_specials': {'type': 'boolean'}, 'assume_special': {'type': 'boolean'}, # Season pack 'season_packs': { 'oneOf': [ {'type': 'boolean'}, {'type': 'integer'}, {'type': 'string', 'enum': ['always', 'only']}, { 'type': 'object', 'properties': { 'threshold': {'type': 'integer', 'minimum': 0}, 'reject_eps': {'type': 'boolean'}, }, 'required': ['threshold', 'reject_eps'], 'additionalProperties': False, }, ] }, }, 'dependentSchemas': { 'timeframe': { 'oneOf': [{'required': ['target']}, {'required': ['qualities']}], 'error': 'A `target` should be specified along with a timeframe.', } }, 'additionalProperties': False, }
[docs] def make_grouped_config(self, config): """Turn a simple series list into grouped format with a empty settings dict.""" if not isinstance(config, dict): # convert simplest configuration internally grouped format config = {'simple': config, 'settings': {}} else: # already in grouped format, just make sure there's settings config.setdefault('settings', {}) return config
[docs] def season_pack_opts(self, season_packs): """Parse the user's `season_packs` option, and turn it in to a more useful form.""" if season_packs in [False, None]: return False opts = {'threshold': 0, 'reject_eps': False} if season_packs is True: return opts if isinstance(season_packs, int): opts['threshold'] = season_packs elif isinstance(season_packs, str): if season_packs == 'always': opts['threshold'] = sys.maxsize else: # 'only' opts['reject_eps'] = True elif isinstance(season_packs, dict): opts = season_packs return opts
[docs] def apply_group_options(self, config): """Apply group settings to each item in series group and remove settings dict.""" # Make sure config is in grouped format first config = self.make_grouped_config(config) for group_name in config: if group_name == 'settings': continue group_series = [] if isinstance(group_name, str): # if group name is known quality, convenience create settings with that quality try: qualities.Requirements(group_name) config['settings'].setdefault(group_name, {}).setdefault('target', group_name) except ValueError: # If group name is not a valid quality requirement string, do nothing. pass group_settings = config['settings'].get(group_name, {}) for series in config[group_name]: # convert into dict-form if necessary series_settings = {} if isinstance(series, dict): series, series_settings = next(iter(series.items())) # Make sure this isn't a series with no name if not series: logger.warning('Series config contains a series with no name!') continue # make sure series name is a string to accommodate for "24" if not isinstance(series, str): series = str(series) # if series have given path instead of dict, convert it into a dict if isinstance(series_settings, str): series_settings = {'path': series_settings} # merge group settings into this series settings merge_dict_from_to(group_settings, series_settings) # Convert to dict if watched is in SXXEXX format if isinstance(series_settings.get('watched'), str): season, episode = series_settings['watched'].upper().split('E') season = season.lstrip('S') series_settings['watched'] = {'season': int(season), 'episode': int(episode)} # Convert enough to target for backwards compatibility if 'enough' in series_settings: logger.warning( 'Series setting `enough` has been renamed to `target`. Please update your config.' ) series_settings.setdefault('target', series_settings['enough']) group_series.append({series: series_settings}) config[group_name] = group_series del config['settings'] return config
[docs] def prepare_config(self, config): """Generate a list of unique series from configuration. This way we don't need to handle two different configuration formats in the logic. Apply group settings with advanced form. """ config = self.apply_group_options(config) return self.combine_series_lists(*list(config.values()))
[docs] def combine_series_lists(self, *series_lists, **kwargs): """Combine the series from multiple lists, making sure there are no doubles. If keyword argument log_once is set to True, an error message will be printed if a series is listed more than once, otherwise log_once will be used. """ unique_series = {} for series_list in series_lists: for series in series_list: series, series_settings = next(iter(series.items())) if series not in unique_series: unique_series[series] = series_settings else: if kwargs.get('log_once'): log_once( f'Series `{series}` is already configured in series plugin', logger ) else: logger.warning( 'Series `{}` is configured multiple times in series plugin.', series ) # Combine the config dicts for both instances of the show merge_dict_from_to(series_settings, unique_series[series]) # Turn our all_series dict back into a list # sort by reverse alpha, so that in the event of 2 series with common prefix, more specific is parsed first return [{s: unique_series[s]} for s in sorted(unique_series, reverse=True)]
[docs] def merge_config(self, task, config): """Merge another series config dict in with the current one.""" # Make sure we start with both configs as a list of complex series native_series = self.prepare_config(task.config.get('series', {})) merging_series = self.prepare_config(config) task.config['series'] = self.combine_series_lists( native_series, merging_series, log_once=True ) return task.config['series']
[docs] class FilterSeries(FilterSeriesBase): """Intelligent filter for tv-series. https://flexget.com/Plugins/series """ @property def schema(self): return { 'type': ['array', 'object'], # simple format: # - series # - another series 'items': { 'type': ['string', 'number', 'object'], 'additionalProperties': self.settings_schema, }, # advanced format: # settings: # group: {...} # group: # {...} 'properties': { 'settings': {'type': 'object', 'additionalProperties': self.settings_schema} }, 'additionalProperties': { 'type': 'array', 'items': { 'type': ['string', 'number', 'object'], 'additionalProperties': self.settings_schema, }, }, }
[docs] def auto_exact(self, config): """Automatically enable exact naming option for series that look like a problem.""" # generate list of all series in one dict all_series = {} for series_item in config: series_name, series_config = next(iter(series_item.items())) all_series[series_name] = series_config # scan for problematic names, enable exact mode for them for series_name, series_config in all_series.items(): for name in list(all_series.keys()): if ( (name.lower().startswith(series_name.lower())) and (name.lower() != series_name.lower()) and 'exact' not in series_config ): logger.verbose( 'Auto enabling exact matching for series `{}` (reason: `{}`)', series_name, name, ) series_config['exact'] = True
# Run after metainfo_quality and before metainfo_series
[docs] @plugin.priority(125) def on_task_metainfo(self, task, config): config = self.prepare_config(config) self.auto_exact(config) parser = plugin.get('parsing', self) start_time = preferred_clock() # Sort Entries into data model similar to https://en.wikipedia.org/wiki/Trie # Only process series if both the entry title and series title first letter match entries_map = defaultdict(list) for entry in task.entries: parsed = parser.parse_series(entry['title']) if parsed.name: entries_map[parsed.name[:1].lower()].append(entry) else: # If parsing failed, use first char of each word in the entry title for word in entry['title'].replace(' ', '.').split('.'): entries_map[word[:1].lower()].append(entry) with Session() as session: # Preload series # str() added to make sure number shows (e.g. 24) are turned into strings # First add all series config names (normalized) series_names = [str(normalize_series_name(next(iter(s.keys())))) for s in config] # Add series names from the config without normalization to capture configs # that use slightly different series names. See https://github.com/Flexget/Flexget/issues/2057 series_names.extend( str(next(iter(s.keys()))) for s in config if str(next(iter(s.keys()))) not in series_names ) existing_db_series = [] for chunk in chunked(series_names): existing_db_series.extend( session.query(db.Series).filter(db.Series.name.in_(chunk)) ) existing_db_series = {s.name_normalized: s for s in existing_db_series} for series_item in config: series_name, series_config = next(iter(series_item.items())) alt_names = get_config_as_array(series_config, 'alternate_name') db_series = existing_db_series.get(normalize_series_name(series_name)) db_identified_by = db_series.identified_by if db_series else None letters = set( [series_name[:1].lower()] + [normalize_series_name(series_name)[:1].lower()] + [alt[:1].lower() for alt in alt_names] ) entries = {entry for letter in letters for entry in entries_map.get(letter, [])} if entries: self.parse_series(entries, series_name, series_config, db_identified_by) logger.debug('series on_task_metainfo took {} to parse', preferred_clock() - start_time)
[docs] def on_task_filter(self, task, config): """Filter series.""" # Parsing was done in metainfo phase, create the dicts to pass to process_series from the task entries # key: series episode identifier ie. S01E02 # value: seriesparser config = self.prepare_config(config) found_series = {} for entry in task.entries: if ( entry.get('series_name') and entry.get('series_id') is not None and entry.get('series_parser') ): found_series.setdefault(entry['series_name'], []).append(entry) # Prefetch series with Session() as session: # str() added to make sure number shows (e.g. 24) are turned into strings series_names = [str(next(iter(s.keys()))) for s in config] existing_series = ( session .query(db.Series) .filter(db.Series.name.in_(series_names)) .options(joinedload(db.Series.alternate_names)) .all() ) existing_series_map = {s.name_normalized: s for s in existing_series} # Expunge so we can work on de-attached while processing the series to minimize db locks session.expunge_all() start_time = preferred_clock() for series_item in config: with Session() as session: series_name, series_config = next(iter(series_item.items())) if series_config.get('parse_only'): logger.debug( 'Skipping filtering of series `{}` because of parse_only', series_name ) continue # Make sure number shows (e.g. 24) are turned into strings series_name = str(series_name) db_series = existing_series_map.get(normalize_series_name(series_name)) if not db_series: logger.debug('adding series `{}` into db', series_name) db_series = db.Series() db_series.name = series_name db_series.identified_by = series_config.get('identified_by', 'auto') session.add(db_series) logger.debug('-> added `{}`', db_series) session.flush() # Flush to get an id on series before adding alternate names. alts = series_config.get('alternate_name', []) if not isinstance(alts, list): alts = [alts] for alt in alts: db._add_alt_name(alt, db_series, series_name, session) existing_series_map[db_series.name_normalized] = db_series else: # Add existing series back to session session.add(db_series) # Skip if series not within entries if series_name not in found_series: continue series_entries = {} for entry in found_series[series_name]: # store found episodes into database and save reference for later use releases = db.store_parser( session, entry['series_parser'], series=db_series, quality=entry.get('quality'), ) entry['series_releases'] = [r.id for r in releases] if hasattr(releases[0], 'episode'): entity = releases[0].episode else: entity = releases[0].season series_entries.setdefault(entity, []).append(entry) # If we didn't find any episodes for this series, continue if not series_entries: logger.trace('No entries found for `{}` this run.', series_name) continue # configuration always overrides everything if series_config.get('identified_by', 'auto') != 'auto': db_series.identified_by = series_config['identified_by'] # if series doesn't have identified_by flag already set, calculate one now that new eps are added to db auto_begin = False if not db_series.identified_by or db_series.identified_by == 'auto': db_series.identified_by = db.auto_identified_by(db_series) logger.debug( 'identified_by set to `{}` based on series history', db_series.identified_by, ) # Update begin only if locked into ep or seq mode if db_series.identified_by in ['ep', 'seq']: auto_begin = True # Remove begin episode if identified_by has now been set to a different type than begin ep if db_series.begin and db_series.identified_by not in ( 'auto', db_series.begin.identified_by, ): logger.warning( 'Removing begin episode for {} ({}) because it does not match the identified_by type for series ({})', series_name, db_series.begin.identifier, db_series.identified_by, ) del db_series.begin auto_begin = True # Set begin to latest release, or fall back to beginning if auto_begin: latest: db.Episode = db.get_latest_release(entity.series) ep_id = None if latest: if db_series.identified_by == 'ep': ep_id = f'S{latest.season}E01' else: ep_id = latest.identifier logger.verbose( f'Defaulting series `{series_name}` begin to start of current season `{ep_id}`' ) else: if db_series.identified_by == 'ep': ep_id = 'S01E01' elif db_series.identified_by == 'sequence': ep_id = '01' if ep_id is not None: logger.verbose( f'Defaulting series `{series_name}` begin to best guess `{ep_id}`' ) if ep_id is not None: db.set_series_begin(entity.series, ep_id) self.process_series(task, series_entries, series_config) logger.debug('processing series took {}', preferred_clock() - start_time)
[docs] def parse_series(self, entries, series_name, config, db_identified_by=None): """Search for `series_name` and populate all `series_*` fields in entries when successfully parsed. :param entries: List of entries to process :param series_name: Series name which is being processed :param config: Series config being processed :param db_identified_by: Series config being processed """ # set parser flags flags based on config / database identified_by = config.get('identified_by', 'auto') if identified_by == 'auto': # set flag from database identified_by = db_identified_by or 'auto' params = { 'identified_by': identified_by, 'alternate_names': get_config_as_array(config, 'alternate_name'), 'name_regexps': get_config_as_array(config, 'name_regexp'), 'strict_name': config.get('exact', False), 'allow_groups': get_config_as_array(config, 'from_group'), 'date_yearfirst': config.get('date_yearfirst'), 'date_dayfirst': config.get('date_dayfirst'), 'special_ids': get_config_as_array(config, 'special_ids'), 'prefer_specials': config.get('prefer_specials'), 'assume_special': config.get('assume_special'), } for id_type in plugin_parser_common.SERIES_ID_TYPES: params[id_type + '_regexps'] = get_config_as_array(config, id_type + '_regexp') parser = plugin.get('parsing', self) for entry in entries: # skip processed entries if ( entry.get('series_parser') and entry['series_parser'].valid and entry['series_parser'].name.lower() != series_name.lower() ): continue # Quality field may have been manipulated by e.g. assume_quality. Use quality field from entry if available. parsed = parser.parse_series(entry['title'], name=series_name, **params) if not parsed.valid: continue parsed.field = 'title' logger.debug( '`{}` detected as `{}`, field: `{}`', entry['title'], parsed, parsed.field ) populate_entry_fields(entry, parsed, config)
[docs] def process_series(self, task, series_entries, config): """Accept or Reject episode or season pack from available releases, or postpone choosing. :param task: Current Task :param series_entries: dict mapping Episodes or Seasons to entries for that episode or season_pack :param config: Series configuration """ accepted_seasons: list[int] = [] def _exclude_season_on_accept( *args, series_entity: db.Season | db.Episode, accepted_seasons_list: list[int], **kwargs, ) -> None: # need to reject all other episode/season packs for an accepted season during the task, # can't wait for task learn phase if series_entity.is_season: logger.debug( 'adding season number `{}` to accepted seasons for this task', series_entity.season, ) accepted_seasons_list.append(series_entity.season) # sort for season packs first, order by season number ascending. Uses -1 in case entity does not return a # season number or sort will crash for entity, entries in sorted( series_entries.items(), key=lambda e: (e[0].is_season, e[0].season or -1), reverse=True ): if not entries: continue # Add season exclude hook to all entries so it will get added to list in all code paths of entry acceptance for entry in entries: entry.add_hook( action='accept', func=_exclude_season_on_accept, series_entity=entity, accepted_seasons_list=accepted_seasons, ) reason = None logger.debug('start with entities: {}', [e['title'] for e in entries]) season_packs = self.season_pack_opts(config.get('season_packs', False)) # reject season packs unless specified if entity.is_season and not season_packs: for entry in entries: entry.reject('season pack support is turned off') continue # reject episodes if season pack is set to 'only' if not entity.is_season and season_packs and season_packs['reject_eps']: for entry in entries: entry.reject('season pack only mode') continue # Determine episode threshold for season pack ep_threshold = season_packs['threshold'] if season_packs else 0 # check that a season pack for this season wasn't already accepted in this task run if entity.season in accepted_seasons: for entry in entries: entry.reject( f'already accepted season pack for season `{entity.season}` in this task' ) continue # reject entity that have been marked as watched in config file if entity.series.begin and entity < entity.series.begin: for entry in entries: entry.reject( f'Entity `{entity.identifier}` is before begin value of `{entity.series.begin.identifier}`' ) continue # skip special episodes if special handling has been turned off if not config.get('specials', True) and entity.identified_by == 'special': logger.debug('Skipping special episode as support is turned off.') continue logger.debug('current entities: {}', [e['title'] for e in entries]) # quality filtering if 'quality' in config: entries = self.process_quality(config, entries) if not entries: continue reason = 'matches quality' # Many of the following functions need to know this info. Only look it up once. downloaded = entity.downloaded_releases downloaded_qualities = [rls.quality for rls in downloaded] # proper handling logger.debug('{} process_propers -->', '-' * 20) entries = self.process_propers(config, entity, entries) if not entries: continue # Remove any eps we already have from the list for entry in reversed( entries ): # Iterate in reverse so we can safely remove from the list while iterating if entry['quality'] in downloaded_qualities: entry.reject('quality already downloaded') entries.remove(entry) if not entries: continue # Figure out if we need an additional quality for this ep if downloaded: if config.get('upgrade'): # Remove all the qualities lower than what we have for entry in reversed(entries): if entry['quality'] < max(downloaded_qualities): entry.reject('worse quality than already downloaded.') entries.remove(entry) if not entries: continue if 'target' in config and config.get('upgrade'): # If we haven't grabbed the target yet, allow upgrade to it self.process_timeframe_target(config, entries, downloaded) continue if 'qualities' in config: # Grab any additional wanted qualities logger.debug('{} process_qualities -->', '-' * 20) self.process_qualities(config, entries, downloaded) continue if config.get('upgrade'): entries[0].accept('is an upgrade to existing quality') continue # Reject entity because we have them for entry in entries: entry.reject('entity has already been downloaded') continue best = entries[0] logger.debug('continuing w. entities: {}', [e['title'] for e in entries]) logger.debug('best entity is: `{}`', best['title']) # episode tracking. used only with season and sequence based series if entity.identified_by in ['ep', 'sequence']: logger.debug('{} tracking -->', '-' * 20) if self.process_entity_tracking( entity, entries, threshold=ep_threshold, ): continue # quality if 'target' in config or 'qualities' in config: if 'target' in config: if self.process_timeframe_target(config, entries, downloaded): continue elif 'qualities' in config and self.process_qualities(config, entries, downloaded): continue # We didn't make a quality target match, check timeframe to see # if we should get something anyway if 'timeframe' in config: if self.process_timeframe(task, config, entity, entries): continue reason = 'Timeframe expired, choosing best available' else: # If target or qualities is configured without timeframe, don't accept anything now continue # Just pick the best ep if we get here reason = reason or 'choosing first acceptable match' best.accept(reason)
[docs] def process_propers(self, config, episode, entries): """Accept needed propers. Nuke episodes from which there exists proper. :returns: A list of episodes to continue processing. """ pass_filter = [] # First find the best available proper for each quality without modifying incoming entry order sorted_entries = sorted( entries, key=lambda e: (e['quality'], e['proper_count']), reverse=True ) best_propers = { q: next(e) for q, e in itertools.groupby(sorted_entries, key=lambda e: e['quality']) } for entry in entries: if entry['proper_count'] < best_propers[entry['quality']]['proper_count']: # nuke qualities which there is a better proper available entry.reject('nuked') else: pass_filter.append(entry) # If propers support is turned off, or proper timeframe has expired just return the filtered eps list if isinstance(config.get('propers', True), bool): if not config.get('propers', True): return pass_filter else: # propers with timeframe logger.debug('proper timeframe: {}', config['propers']) timeframe = parse_timedelta(config['propers']) first_seen = episode.first_seen expires = first_seen + timeframe logger.debug('propers timeframe: {}', timeframe) logger.debug('first_seen: {}', first_seen) logger.debug('propers ignore after: {}', expires) if datetime.now() > expires: logger.debug('propers timeframe expired') return pass_filter downloaded_qualities = {d.quality: d.proper_count for d in episode.downloaded_releases} logger.debug('propers - downloaded qualities: {}', downloaded_qualities) # Accept propers we actually need, and remove them from the list of entries to continue processing for quality, entry in best_propers.items(): if ( quality in downloaded_qualities and entry['proper_count'] > downloaded_qualities[quality] ): entry.accept('proper') pass_filter.remove(entry) return pass_filter
[docs] def process_timeframe_target(self, config, entries, downloaded=None): """Accept first episode matching the quality configured for the series. :return: True if accepted something """ req = qualities.Requirements(config['target']) if downloaded and any(req.allows(release.quality) for release in downloaded): logger.debug('Target quality already achieved.') return True # scan for quality for entry in entries: if req.allows(entry['quality']): logger.debug( 'Accepted by series. `{}` meets quality requirement `{}`.', entry['title'], req ) entry.accept('target quality') return True return None
[docs] def process_quality(self, config, entries): """Filter eps that do not fall between within our defined quality standards. :returns: A list of eps that are in the acceptable range """ reqs = qualities.Requirements(config['quality']) logger.debug('quality req: {}', reqs) result = [] # see if any of the eps match accepted qualities for entry in entries: if reqs.allows(entry['quality']): result.append(entry) else: logger.verbose( 'Ignored `{}`. Does not meet quality requirement `{}`.', entry['title'], reqs ) if not result: logger.debug('no quality meets requirements') return result
[docs] def process_entity_tracking(self, entity, entries, threshold): """Rejects all entity that are too old or new, return True when this happens. :param entity: Entity model :param list entries: List of entries for given episode. :param int threshold: Number of episodes allowed in season """ latest: db.Episode = db.get_latest_release(entity.series) begin: db.Episode = entity.series.begin if begin and (not latest or begin > latest): latest = begin if not latest: return False # Make dummy begin if we don't have one if begin is None: begin = db.Episode() begin.identified_by = latest.identified_by begin.number = 1 if begin.identified_by == 'ep': begin.season = 1 begin.identifier = 'S01E01' logger.verbose('Series begin is unknown, defaulting to ep S01E01') elif begin.identified_by == 'sequence': begin.season = 0 begin.identifier = '1' logger.verbose('Series begin is unknown, defaulting to sequence 1') else: logger.debug('Unable to set default begin (identified_by is date, auto)') logger.debug('latest download: {}', latest) logger.debug('current: {}', entity) logger.debug('begin: {}', begin) # reject any entity if a season pack for this season was already downloaded if entity.season in entity.series.completed_seasons: logger.debug('season `{}` already completed for this series', entity.season) for entry in entries: entry.reject(f'Season `{entity.season}` is already completed') return True # Test if episode threshold has been met if entity.is_season and entity.series.episodes_for_season(entity.season) > threshold: logger.debug('threshold of {} has been met, skipping season pack', threshold) for entry in entries: entry.reject( 'The configured number of episodes for this season has already been downloaded' ) return True if ( latest.identified_by == entity.identified_by and latest.identified_by not in [ 'date', 'auto', ] and entity < begin ): logger.debug( 'episode {} before begin {}! rejecting all occurrences', entity.identifier, begin.identifier, ) for entry in entries: entry.reject( f'Episode `{{entity.identifier}}` is from before series begin value `{begin.identifier}`. ' ) return True # TODO: should not be in tracking ? if entity.series.identified_by not in (entity.identified_by, 'auto'): for entry in entries: entry.reject( f"Episode `{{entity.identifier}}` doesn't match series format `{entity.series.identified_by}`. " ) return None return None
[docs] def process_timeframe(self, task, config, episode, entries): """Run the timeframe logic to determine if we should wait for a better quality. Save current best to backlog if timeframe has not expired. :returns: True - if we should keep the quality (or qualities) restriction False - if the quality restriction should be released, due to timeframe expiring """ if 'timeframe' not in config: return True best = entries[0] # parse options logger.debug('timeframe: {}', config['timeframe']) timeframe = parse_timedelta(config['timeframe']) if config.get('quality'): req = qualities.Requirements(config['quality']) seen_times = [rls.first_seen for rls in episode.releases if req.allows(rls.quality)] else: seen_times = [rls.first_seen for rls in episode.releases] # Somehow we can get here without having qualifying releases (#2779) make sure min doesn't crash first_seen = min(seen_times) if seen_times else datetime.now() expires = pendulum.instance(first_seen, tz='local') + timeframe logger.debug('timeframe: {}, first_seen: {}, expires: {}', timeframe, first_seen, expires) stop = normalize_series_name(task.options.stop_waiting) == episode.series._name_normalized if expires <= pendulum.now() or stop: # Expire timeframe, accept anything logger.info('Timeframe expired, releasing quality restriction.') return False # verbose waiting, add to backlog logger.info( '`{}`: Timeframe will expire {}. Waiting for target `{}`, current best is `{}`.', episode.series.name, expires.diff_for_humans(), config.get('target', ' or '.join(config.get('qualities', []))), best['title'], ) # add best entry to backlog (backlog is able to handle duplicate adds) plugin.get('backlog', self).add_backlog(task, best, session=object_session(episode)) return True
[docs] def process_qualities(self, config, entries, downloaded): """Handle all modes that can accept more than one quality per episode. (qualities, upgrade). :returns: True - if at least one wanted quality has been downloaded or accepted. False - if no wanted qualities have been accepted """ # Get list of already downloaded qualities downloaded_qualities = [r.quality for r in downloaded] logger.debug('downloaded_qualities: {}', downloaded_qualities) # If qualities key is configured, we only want qualities defined in it. wanted_qualities = {qualities.Requirements(name) for name in config.get('qualities', [])} # Compute the requirements from our set that have not yet been fulfilled still_needed = [ req for req in wanted_qualities if not any(req.allows(qual) for qual in downloaded_qualities) ] logger.debug('wanted qualities: {}', wanted_qualities) def wanted(quality): """Return True if we want this quality based on the config options.""" wanted_q = not wanted_qualities or any(req.allows(quality) for req in wanted_qualities) if config.get('upgrade'): wanted_q = wanted_q and quality > max( downloaded_qualities or [qualities.Quality()] ) return wanted_q for entry in entries: quality = entry['quality'] logger.debug('ep: `{}`, quality: `{}`', entry['title'], quality) if not wanted(quality): logger.debug('`{}` is an unwanted quality', quality) continue if any(req.allows(quality) for req in still_needed): # Don't get worse qualities in upgrade mode if ( config.get('upgrade') and downloaded_qualities and quality < max(downloaded_qualities) ): continue entry.accept('quality wanted') downloaded_qualities.append(quality) downloaded.append(entry) # Re-calculate what is still needed still_needed = [req for req in still_needed if not req.allows(quality)] return bool(downloaded_qualities)
[docs] def on_task_learn(self, task, config): """Learn succeeded episodes.""" logger.debug('on_task_learn') for entry in task.accepted: if 'series_releases' in entry: with Session() as session: season_num = ep_num = 0 if entry['season_pack']: season_num = ( session .query(db.SeasonRelease) .filter(db.SeasonRelease.id.in_(entry['series_releases'])) .update({'downloaded': True}, synchronize_session=False) ) else: ep_num = ( session .query(db.EpisodeRelease) .filter(db.EpisodeRelease.id.in_(entry['series_releases'])) .update({'downloaded': True}, synchronize_session=False) ) logger.debug( 'marking {} episode releases and {} season releases as downloaded for `{}`', ep_num, season_num, entry, ) else: logger.debug('`{}` is not a series', entry['title'])
[docs] class SeriesDBManager(FilterSeriesBase): """Update in the database with series info from the config."""
[docs] @plugin.priority(0) def on_task_start(self, task, config): # Only operate if task changed if not task.config_modified: return # Clear all series from this task with Session() as session: add_series_tasks = {} session.query(db.SeriesTask).filter(db.SeriesTask.name == task.name).delete() if not task.config.get('series'): return config = self.prepare_config(task.config['series']) # Prefetch series names = [str(next(iter(series.keys()))) for series in config] existing_series = ( session .query(db.Series) .filter(db.Series.name.in_(names)) .options(joinedload(db.Series.alternate_names)) .all() ) existing_series_map = {s.name_normalized: s for s in existing_series} for series_item in config: series_name, series_config = next(iter(series_item.items())) # Make sure number shows (e.g. 24) are turned into strings series_name = str(series_name) db_series = existing_series_map.get(normalize_series_name(series_name)) alts = series_config.get('alternate_name', []) if not isinstance(alts, list): alts = [alts] if db_series: # Update database with capitalization from config db_series.name = series_name # Remove the alternate names not present in current config db_series.alternate_names = [ alt for alt in db_series.alternate_names if alt.alt_name in alts ] # Add/update the possibly new alternate names else: logger.debug( 'adding series `{}` `{}` into db (on_task_start)', series_name, normalize_series_name(series_name), ) logger.debug('adding series `{}` into db (on_task_start)', series_name) db_series = db.Series() db_series.name = series_name session.add(db_series) session.flush() # flush to get id on series before creating alternate names existing_series_map[db_series.name_normalized] = db_series logger.debug('-> added `{}`', db_series) for alt in alts: db._add_alt_name(alt, db_series, series_name, session) logger.debug('connecting series `{}` to task `{}`', db_series.name, task.name) # Add in bulk at the end if db_series.id not in add_series_tasks: series_task = db.SeriesTask(task.name) series_task.series_id = db_series.id add_series_tasks[db_series.id] = series_task if series_config.get('identified_by', 'auto') != 'auto': db_series.identified_by = series_config['identified_by'] # Set the begin episode if series_config.get('begin'): try: db.set_series_begin(db_series, series_config['begin']) except ValueError as e: raise plugin.PluginError(e) if add_series_tasks: session.bulk_save_objects(add_series_tasks.values())
[docs] @event('plugin.register') def register_plugin(): plugin.register(FilterSeries, 'series', api_ver=2) # This is a builtin so that it can update the database for tasks that may have had series plugin removed plugin.register(SeriesDBManager, 'series_db', builtin=True, api_ver=2)
[docs] @event('options.register') def register_parser_arguments(): exec_parser = options.get_parser('execute') exec_parser.add_argument( '--stop-waiting', action='store', dest='stop_waiting', default='', metavar='NAME', help='stop timeframe for a given series', )