Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[change] estimate_release - make configurable #3650

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
117 changes: 106 additions & 11 deletions flexget/components/estimate_release/estimate_release.py
@@ -1,9 +1,31 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Union

from loguru import logger

from flexget import plugin
from flexget.event import event
from flexget.plugin import PluginInfo
from flexget.task import Task
from flexget.utils.tools import parse_timedelta

logger = logger.bind(name='est_released')
ESTIMATOR_INTERFACE = "estimate_release"

# Mapping of available estimate providers to plugin instance
estimators = {}
# Task specific estimate configuration
task_estimate_config: Dict[str, Any] = {}

# We need to wait until manager startup to access other plugin instances, to make sure they have all been loaded
@event('manager.startup')
def init_estimators(manager) -> None:
"""Prepare the list of available estimator plugins."""

for provider in plugin.get_plugins(interface=ESTIMATOR_INTERFACE):
estimators[provider.name.replace('est_', '')] = provider

logger.debug('setting default estimators to {}', list(estimators.keys()))


class EstimateRelease:
Expand All @@ -12,32 +34,105 @@ class EstimateRelease:
for various things (series, movies).
"""

def estimate(self, entry):
def __init__(self):
self.task_estimate_config = {}

@property
def schema(self) -> Dict[str, Any]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the way to handle sub plugins & interfaces, please look at how discover plugin handles search interface.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. I'll take a look.

"""Create schema that allows configuring estimator providers and
related settings.
"""

schema = {
'type': 'object',
'properties': {
'providers': {
'type': 'array',
'items': {
'type': 'string',
'enum': [
p.name.replace('est_', '')
for p in plugin.get_plugins(interface=ESTIMATOR_INTERFACE)
],
},
},
'offset': {'type': 'string', 'format': 'interval', 'default': '0 days'},
},
'additionalProperties': False,
}

return schema

def on_task_start(self, task: Task, config) -> None:
# Load task specific estimator configuration
if config:
self.task_estimate_config = config

def on_task_exit(self, task: Task, config) -> None:
# Restore default estimator configuration for next task run
self.task_estimate_config = {}
Comment on lines +66 to +73
Copy link
Member

@paranoidi paranoidi Jan 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This modifying configuration and restoring later is not a good idea. Generally speaking plugins should be stateless and not rely on __init__.

Copy link
Contributor Author

@aidan- aidan- Jan 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, this is similar to how the parsing plugin does it which was mentioned in one of the above comments as a reference.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, apologies. I guess there's no better way for it in these kinds of situations then. There is still issue with having self.task_estimate_config and the one declared in module level (L18). I don't think the module one is used and can be removed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we have to do some hacky stuff for bits that don't directly get a task parameter, there isn't a better way at the moment. I was thinking of making a plugin method that worked like a context manager, to make plugins that do setup and teardown around tasks a bit easier. Something like:

def on_task_context(self, task, config):
    if config:
        self.task_estimate_config = config
    try:
        yield
    finally:
        self.task_estimate_config = {}

That way you don't have to hook task_start, task_exit, and task_abort trying to manage state. @paranoidi Thoughts?

I also had this crazier idea based on that, but my gut tells me it's probably bad. It would allow a plugin to retain state during a task without storing things to self.

def on_task_phases(self, task, config):
    yield 'start'
    setupstuff = somefunc()
    yield 'filter'
    for entry in task.all_entries:
        do_something(entry, setupstuff)
    yield 'exit'
    teardown(setupstuff)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh, sure. I have some thoughts. Not overly sold on the idea.

I've been thinking years that we should have a baseclass for plugins and in there have a prepare_config method that plugins can override to format config with default values etc. Then passing the already prepared config into the usual phase methods. Also this would make it possible to declare the phase methods in the baseclass making them much more explicit. Last time I checked there was only one dynamically added phase which would not be a hard to get rid of.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been thinking years that we should have a baseclass for plugins and in there have a prepare_config method that plugins can override to format config with default values etc. Then passing the already prepared config into the usual phase methods.

Yeah, I love this idea.
I think it's totally separate than a context manager type thing to do cleanup, and I'm not totally sold on that idea either. The problem of doing setup and teardown in on_task_start/exit is that you can't guarantee that they run in pairs. I just fixed an issue with the parsing plugin where the on_task_abort cleanup ran even though the task was aborted before its on_task_start even set up the thing it was trying to clean up. You could also run into the issue of an abort happening after on_task_exit handler was run, and it tries to clean up twice. Here is a naive search that shows some plugins that might be able to use a thing like the context manager hook.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the baseclass could introduce setup/teardown methods (although I kinda dislike those names as they are heavily unit test related) that are guaranteed to run as expected. It would be much more explicit than relying on on_task_start etc.


on_task_abort = on_task_exit

def get_estimators(self) -> List[PluginInfo]:
"""
Returns the list of configured estimator providers for the task. If no
providers are configured, all available providers are returned.

Providers are sorted by the plugin priority.

:return: Sorted list of estimator plugin instances.
"""
if "providers" in self.task_estimate_config:
# Map task configured providers to plugin instance map
try:
task_estimators = [
estimators[p].instance.estimate for p in self.task_estimate_config['providers']
]
except KeyError as error:
logger.error(f"invalid provider plugin given: {error}")
raise
else:
# Use all loaded estimator plugins
task_estimators = [e.instance.estimate for e in estimators.values()]

return sorted(
task_estimators,
key=lambda e: getattr(e, 'priority', plugin.PRIORITY_DEFAULT),
reverse=True,
)

@property
def offset(self) -> str:
"""
Return the configured offset for the task.
"""
Estimate release schedule for Entry
return self.task_estimate_config.get('offset', '0 days')

def estimate(self, entry) -> Dict[str, Union[bool, Optional[datetime]]]:
"""
Estimate release schedule for entry

:param entry:
:return: estimated date of released for the entry, None if it can't figure it out
"""

logger.debug(entry['title'])
estimators = [
e.instance.estimate for e in plugin.get_plugins(interface='estimate_release')
]
for estimator in sorted(
estimators, key=lambda e: getattr(e, 'priority', plugin.PRIORITY_DEFAULT), reverse=True
):
logger.debug(f"estimating release date for {entry['title']}")
for estimator in self.get_estimators():
estimate = estimator(entry)
# return first successful estimation
# Return first successful estimation
if estimate is not None:
estimation = estimate
break
else:
estimation = {'data_exists': False, 'entity_date': None}

if estimation['entity_date']:
estimation['entity_date'] = estimation['entity_date'] + parse_timedelta(self.offset)

return estimation


@event('plugin.register')
def register_plugin():
plugin.register(EstimateRelease, 'estimate_release', api_ver=2, interfaces=[])
plugin.register(EstimateRelease, 'estimate_release', api_ver=2)
Expand Up @@ -4,12 +4,11 @@
from sqlalchemy import desc, func

from flexget import plugin
from flexget.components.series import db
from flexget.event import event
from flexget.manager import Session
from flexget.utils.tools import multiply_timedelta

from . import db

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


Expand Down
44 changes: 9 additions & 35 deletions flexget/plugins/input/discover.py
Expand Up @@ -86,18 +86,9 @@ class Discover:
},
'interval': {'type': 'string', 'format': 'interval', 'default': '5 hours'},
'release_estimations': {
'oneOf': [
{
'type': 'string',
'default': 'strict',
'enum': ['loose', 'strict', 'ignore', 'smart'],
},
{
'type': 'object',
'properties': {'optimistic': {'type': 'string', 'format': 'interval'}},
'required': ['optimistic'],
},
]
'type': 'string',
'default': 'strict',
'enum': ['loose', 'strict', 'ignore', 'smart'],
},
'limit': {'type': 'integer', 'minimum': 1},
},
Expand Down Expand Up @@ -190,21 +181,21 @@ def estimated(self, entries, estimation_mode):
data_exists = estimation['data_exists']

if est_date is None:
if estimation_mode['mode'] == 'strict':
if estimation_mode == 'strict':
logger.debug('No release date could be determined for {}', entry['title'])
entry.reject('has no release date')
entry.complete()
elif estimation_mode['mode'] == 'smart' and data_exists:
elif estimation_mode == 'smart' and data_exists:
logger.debug(
'No release date could be determined for {}, but exists data',
'No release date could be determined for {}, but data exists',
entry['title'],
)
entry.reject('exists but has no release date')
entry.complete()
elif estimation_mode['mode'] == 'smart' and not data_exists:
elif estimation_mode == 'smart' and not data_exists:
logger.debug(
'Discovering because mode is \'{}\' and no data is found for entry',
estimation_mode['mode'],
estimation_mode,
)
result.append(entry)
else:
Expand All @@ -216,16 +207,6 @@ def estimated(self, entries, estimation_mode):
if datetime.datetime.now() >= est_date:
logger.debug('{} has been released at {}', entry['title'], est_date)
result.append(entry)
elif datetime.datetime.now() >= est_date - parse_timedelta(
estimation_mode['optimistic']
):
logger.debug(
'{} will be released at {}. Ignoring release estimation because estimated release date is in less than {}',
entry['title'],
est_date,
estimation_mode['optimistic'],
)
result.append(entry)
else:
entry.reject('has not been released')
entry.complete()
Expand Down Expand Up @@ -292,13 +273,6 @@ def interval_expired(self, config, task, entries):
return result

def on_task_input(self, task, config):
config.setdefault('release_estimations', {})
if not isinstance(config['release_estimations'], dict):
config['release_estimations'] = {'mode': config['release_estimations']}

config['release_estimations'].setdefault('mode', 'strict')
config['release_estimations'].setdefault('optimistic', '0 days')

task.no_entries_ok = True
entries = aggregate_inputs(task, config['what'])
logger.verbose('Discovering {} titles ...', len(entries))
Expand All @@ -310,7 +284,7 @@ def on_task_input(self, task, config):
# TODO: the entries that are estimated should be given priority over expiration
entries = self.interval_expired(config, task, entries)
estimation_mode = config['release_estimations']
if estimation_mode['mode'] != 'ignore':
if estimation_mode != 'ignore':
entries = self.estimated(entries, estimation_mode)
return self.execute_searches(config, entries, task)

Expand Down
100 changes: 97 additions & 3 deletions flexget/tests/test_discover.py
@@ -1,4 +1,7 @@
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch

import pytest

from flexget import plugin
from flexget.entry import Entry
Expand Down Expand Up @@ -30,14 +33,14 @@ def search(self, task, entry, config=None):
plugin.register(SearchPlugin, 'test_search', interfaces=['search'], api_ver=2)


class EstRelease:
class FakeEstimator:
"""Fake release estimate plugin. Just returns 'est_release' entry field."""

def estimate(self, entry):
return entry.get('est_release')


plugin.register(EstRelease, 'test_release', interfaces=['estimate_release'], api_ver=2)
plugin.register(FakeEstimator, 'fake_estimator', interfaces=['estimate_release'], api_ver=2)


class TestDiscover:
Expand Down Expand Up @@ -225,7 +228,7 @@ class TestEmitSeriesInDiscover:
begin: s02e01
identified_by: ep
season_packs: yes
max_reruns: 0
max_reruns: 0
"""

def test_next_series_episodes_rerun(self, execute_task):
Expand Down Expand Up @@ -282,3 +285,94 @@ def test_next_series_seasons_with_completed_seasons(self, execute_task):
)
task = execute_task('test_next_series_seasons')
assert task.find_entry(title='My Show 2 S03')


class TestEstimateReleaseViaDiscover:
"""Suite of tests focusing on the configuration of the estimate_release
plugin.
"""

config = """
tasks:
test_estimates:
discover:
interval: 0 seconds
what:
- mock:
- title: Foo
from:
- test_search: yes
"""

def test_default_release_date_modifier(self, execute_task, manager):
"""Test that the default release_date_modifier value of '0 days'
results in only matching entries released in the past.
"""
mock_config = manager.config['tasks']['test_estimates']['discover']['what'][0]['mock']
# It should not be searched before the release date
mock_config[0]['est_release'] = {
'data_exists': True,
'entity_date': (datetime.now() + timedelta(days=1)),
}
task = execute_task('test_estimates')
assert len(task.entries) == 0
# It should be searched after the release date
mock_config[0]['est_release'] = {'data_exists': True, 'entity_date': datetime.now()}
task = execute_task('test_estimates')
assert len(task.entries) == 1

def test_release_date_modifier_positive(self, execute_task, manager):
"""Test that providing a 'positive' offset value for the
estimate_release config results in matching entries that have been
released far enough in the past.
"""
manager.config['tasks']['test_estimates']['estimate_release'] = {"offset": '7 days'}
discover_config = manager.config['tasks']['test_estimates']['discover']
mock_config = discover_config['what'][0]['mock']
mock_config[0]['est_release'] = {
'data_exists': True,
'entity_date': datetime.now(),
}
task = execute_task('test_estimates')
assert len(task.entries) == 0
mock_config[0]['est_release'] = {
'data_exists': True,
'entity_date': (datetime.now() - timedelta(days=7)),
}
task = execute_task('test_estimates')
assert len(task.entries) == 1

def test_release_date_modifier_negative(self, execute_task, manager):
"""Test that providing a 'negative' offset value for the
estimate_release config results in matching entries that have a release
date in the future.
"""
manager.config['tasks']['test_estimates']['estimate_release'] = {"offset": '-7 days'}
discover_config = manager.config['tasks']['test_estimates']['discover']
mock_config = discover_config['what'][0]['mock']
mock_config[0]['est_release'] = {
'data_exists': True,
'entity_date': datetime.now() + timedelta(days=5),
}
task = execute_task('test_estimates')
assert len(task.entries) == 1
mock_config[0]['est_release'] = {
'data_exists': True,
'entity_date': (datetime.now() + timedelta(days=9)),
}
task = execute_task('test_estimates')
assert len(task.entries) == 0

def test_provider_override_invalid(self, execute_task, manager):
"""Test that an invalid provider results in an exception being raised."""
manager.config['tasks']['test_estimates']['estimate_release'] = {
"providers": ['does-not-exist']
}
discover_config = manager.config['tasks']['test_estimates']['discover']
mock_config = discover_config['what'][0]['mock']
mock_config[0]['est_release'] = {
'data_exists': True,
'entity_date': datetime.now() + timedelta(days=5),
}
with pytest.raises(Exception):
execute_task('test_estimates')