Source code for flexget.components.series.next_series_episodes
import contextlib
import re
from loguru import logger
from sqlalchemy import desc
from flexget import plugin
from flexget.entry import Entry
from flexget.event import event
from flexget.manager import Session
from . import db
logger = logger.bind(name='next_series_episodes')
BACKFILL_LIMIT_DEFAULT = 25
[docs]
class NextSeriesEpisodes:
"""Emit next episode number from all series configured in this task.
Supports only 'ep' and 'sequence' mode series.
"""
schema = {
'oneOf': [
{'type': 'boolean'},
{
'type': 'object',
'properties': {
'from_start': {'type': 'boolean', 'default': False},
'backfill': {'type': 'boolean', 'default': False},
'backfill_limit': {
'type': 'integer',
'default': BACKFILL_LIMIT_DEFAULT,
'description': 'If the gap between episodes is larger than this limit, '
'they will not be emitted.',
},
'only_same_season': {'type': 'boolean', 'default': False},
},
'additionalProperties': False,
},
]
}
def __init__(self):
self.rerun_entries = []
[docs]
def ep_identifiers(self, season, episode):
return [f'S{season:02d}E{episode:02d}', f'{season}x{episode:02d}']
[docs]
def sequence_identifiers(self, episode):
# Use a set to remove doubles, which will happen depending on number of digits in episode
return {f'{episode}', f'{episode:02d}', f'{episode:03d}'}
[docs]
def search_entry(self, series, season, episode, task, rerun=True):
# Extract the alternate names for the series
alts = [alt.alt_name for alt in series.alternate_names]
# Also consider series name without parenthetical (year, country) an alternate name
paren_match = re.match(r'(.+?)( \(.+\))?$', series.name)
if paren_match.group(2):
alts.append(paren_match.group(1))
if series.identified_by == 'ep':
search_strings = [f'{series.name} {id}' for id in self.ep_identifiers(season, episode)]
series_id = f'S{season:02d}E{episode:02d}'
for alt in alts:
search_strings.extend([
f'{alt} {id}' for id in self.ep_identifiers(season, episode)
])
else:
search_strings = [f'{series.name} {id}' for id in self.sequence_identifiers(episode)]
series_id = episode
for alt in alts:
search_strings.extend([f'{alt} {id}' for id in self.sequence_identifiers(episode)])
entry = Entry(
title=search_strings[0],
url='',
search_strings=search_strings,
series_name=series.name,
series_alternate_names=alts, # Not sure if this field is useful down the road.
series_season=season,
series_episode=episode,
season_pack_lookup=False,
series_id=series_id,
series_id_type=series.identified_by,
)
if rerun:
entry.on_complete(
self.on_search_complete, task=task, identified_by=series.identified_by
)
return entry
[docs]
def on_task_input(self, task, config):
if not config:
return None
if isinstance(config, bool):
config = {}
config.setdefault('backfill_limit', BACKFILL_LIMIT_DEFAULT)
self.config = config
if task.is_rerun:
# Just return calculated next eps on reruns
entries = self.rerun_entries
self.rerun_entries = []
return entries
self.rerun_entries = []
entries = []
impossible = {}
with Session() as session:
for seriestask in (
session.query(db.SeriesTask).filter(db.SeriesTask.name == task.name).all()
):
series = seriestask.series
logger.trace('evaluating {}', series.name)
if not series:
# TODO: How can this happen?
logger.debug('Found SeriesTask item without series specified. Cleaning up.')
session.delete(seriestask)
continue
if series.identified_by not in ['ep', 'sequence']:
logger.trace('unsupported identified_by scheme')
reason = series.identified_by or 'auto'
impossible.setdefault(reason, []).append(series.name)
continue
low_season = 0 if series.identified_by == 'ep' else -1
# Don't look for seasons older than begin ep
if series.begin and series.begin.season and series.begin.season > 1:
# begin-1 or the range() loop will never get to the begin season
low_season = max(series.begin.season - 1, 0)
new_season = None
check_downloaded = not config.get('backfill')
latest_season = db.get_latest_release(series, downloaded=check_downloaded)
if latest_season:
if latest_season.season <= low_season:
latest_season = new_season = low_season + 1
elif latest_season.season in series.completed_seasons:
latest_season = new_season = latest_season.season + 1
else:
latest_season = latest_season.season
else:
latest_season = low_season + 1
for season in range(latest_season, low_season, -1):
if season in series.completed_seasons:
logger.debug('season {} is marked as completed, skipping', season)
continue
logger.trace(
'Evaluating episodes for series {}, season {}', series.name, season
)
latest = db.get_latest_release(
series, season=season, downloaded=check_downloaded
)
if (
series.begin
and season == series.begin.season
and (not latest or latest < series.begin)
):
# In case series.begin season is already completed, look in next available season
lookup_season = series.begin.season
ep_number = series.begin.number
while lookup_season in series.completed_seasons:
lookup_season += 1
# If season number was bumped, start looking for ep 1
ep_number = 1
entries.append(self.search_entry(series, lookup_season, ep_number, task))
elif latest and not config.get('backfill'):
entries.append(
self.search_entry(series, latest.season, latest.number + 1, task)
)
elif latest:
start_at_ep = 1
episodes_this_season = (
session
.query(db.Episode)
.filter(db.Episode.series_id == series.id)
.filter(db.Episode.season == season)
)
if series.identified_by == 'sequence':
# Don't look for missing too far back with sequence shows
start_at_ep = max(latest.number - 10, 1)
episodes_this_season = episodes_this_season.filter(
db.Episode.number >= start_at_ep
)
latest_ep_this_season = episodes_this_season.order_by(
desc(db.Episode.number)
).first()
if latest_ep_this_season:
downloaded_this_season = (
episodes_this_season
.join(db.Episode.releases)
.filter(db.EpisodeRelease.downloaded)
.all()
)
# Calculate the episodes we still need to get from this season
if series.begin and series.begin.season == season:
start_at_ep = max(start_at_ep, series.begin.number)
eps_to_get = list(range(start_at_ep, latest_ep_this_season.number + 1))
for ep in downloaded_this_season:
with contextlib.suppress(ValueError):
eps_to_get.remove(ep.number)
if len(eps_to_get) > config['backfill_limit']:
logger.warning(
'Series {} has more than 50 episodes to backfill. Assuming this is an '
'error and not searching for them.',
series.name,
)
else:
entries.extend(
self.search_entry(series, season, x, task, rerun=False)
for x in eps_to_get
)
# If we have already downloaded the latest known episode, try the next episode
if latest_ep_this_season.releases:
entries.append(
self.search_entry(
series, season, latest_ep_this_season.number + 1, task
)
)
else:
# No episode means that latest is a season pack, emit episode 1
entries.append(self.search_entry(series, season, 1, task))
# First iteration of a new season with no show begin and show has downloads
elif (
(new_season and season == new_season)
or config.get('from_start')
or config.get('backfill')
):
entries.append(self.search_entry(series, season, 1, task))
else:
logger.verbose(
'Series `{}` has no history. Set begin option, or use CLI `series begin` '
'subcommand to set first episode to emit',
series.name,
)
break
# Skip older seasons if we are not in backfill mode
if not config.get('backfill'):
logger.debug('backfill is not enabled; skipping older seasons')
break
for reason, series in impossible.items():
logger.verbose(
'Series `{}` with identified_by value `{}` are not supported. ',
', '.join(sorted(series)),
reason,
)
return entries
[docs]
def on_search_complete(self, entry, task=None, identified_by=None, **kwargs):
"""Decides whether we should look for next ep/season based on whether we found/accepted any episodes."""
with Session() as session:
series = (
session.query(db.Series).filter(db.Series.name == entry['series_name']).first()
)
latest = db.get_latest_release(series)
db_release = (
session
.query(db.EpisodeRelease)
.join(db.EpisodeRelease.episode)
.join(db.Episode.series)
.filter(db.Series.name == entry['series_name'])
.filter(db.Episode.season == entry['series_season'])
.filter(db.Episode.number == entry['series_episode'])
.first()
)
if entry.accepted:
logger.debug(
'{} {} was accepted, rerunning to look for next ep.',
entry['series_name'],
entry['series_id'],
)
self.rerun_entries.append(
self.search_entry(
series, entry['series_season'], entry['series_episode'] + 1, task
)
)
# Increase rerun limit by one if we have matches, this way
# we keep searching as long as matches are found!
# TODO: this should ideally be in discover so it would be more generic
task.max_reruns += 1
task.rerun(plugin='next_series_episodes', reason='Look for next episode')
elif db_release:
# There are know releases of this episode, but none were accepted
return
elif latest:
if latest.is_season:
# A season pack was picked up in the task, no need to look for more episodes
return
if (
not self.config.get('only_same_season')
and identified_by == 'ep'
and (
entry['series_season'] == latest.season
and entry['series_episode'] == latest.number + 1
)
):
# We searched for next predicted episode of this season unsuccessfully, try the next season
self.rerun_entries.append(
self.search_entry(series, latest.season + 1, 1, task)
)
logger.debug(
'{} {} not found, rerunning to look for next season',
entry['series_name'],
entry['series_id'],
)
task.rerun(plugin='next_series_episodes', reason='Look for next season')
[docs]
@event('plugin.register')
def register_plugin():
plugin.register(NextSeriesEpisodes, 'next_series_episodes', api_ver=2)