diff --git a/docs/instructions.md b/docs/instructions.md index 339dc47..a7ed265 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -119,661 +119,672 @@ For each task completed: ## TODO List: -1. fix issue: - 2026-01-23 18:28:39 [info ] DownloadService initialized max*retries=3 - INFO: QueueRepository initialized - ERROR: Failed to get all items: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s) - 2026-01-23 18:28:39 [error ] Failed to load queue from database: Failed to get all items: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s) - ╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮ - │ /home/lukas/Volume/repo/Aniworld/src/server/services/queue_repository.py:292 in get_all_items │ - │ │ - │ 289 │ │ │ db_items = await DownloadQueueService.get_all( │ - │ 290 │ │ │ │ session, with_series=True │ - │ 291 │ │ │ ) │ - │ ❱ 292 │ │ │ return [self._from_db_model(item) for item in db_items] │ - │ 293 │ │ │ - │ 294 │ │ except Exception as e: │ - │ 295 │ │ │ logger.error("Failed to get all items: %s", e) │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ db = None │ │ - │ │ db_items = [ │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ , │ │ - │ │ │ ... +21 │ │ - │ │ ] │ │ - │ │ manage_session = True │ │ - │ │ self = │ │ - │ │ session = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/Volume/repo/Aniworld/src/server/services/queue_repository.py:97 in \_from_db_model │ - │ │ - │ 94 │ │ │ Pydantic download item with default status/priority │ - │ 95 │ │ """ │ - │ 96 │ │ # Get episode info from the related Episode object │ - │ ❱ 97 │ │ episode = db_item.episode │ - │ 98 │ │ series = db_item.series │ - │ 99 │ │ │ - │ 100 │ │ episode_identifier = EpisodeIdentifier( │ - │ │ - │ ╭───────────────────────────────────────── locals ──────────────────────────────────────────╮ │ - │ │ db_item = │ │ - │ │ item_id = None │ │ - │ │ self = │ │ - │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/attributes.py:5 │ - │ 69 in **get** │ - │ │ - │ 566 │ │ │ │ state = instance_state(instance) │ - │ 567 │ │ │ except AttributeError as err: │ - │ 568 │ │ │ │ raise orm_exc.UnmappedInstanceError(instance) from err │ - │ ❱ 569 │ │ │ return self.impl.get(state, dict*) # type: ignore[no-any-return] │ - │ 570 │ - │ 571 │ - │ 572 @dataclasses.dataclass(frozen=True) │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ dict* = { │ │ - │ │ │ '\_sa_instance_state': , │ │ - │ │ │ 'id': 8, │ │ - │ │ │ 'error_message': None, │ │ - │ │ │ 'file_destination': None, │ │ - │ │ │ 'completed_at': None, │ │ - │ │ │ 'updated_at': datetime.datetime(2026, 1, 23, 17, 10, 47), │ │ - │ │ │ 'series_id': 146, │ │ - │ │ │ 'episode_id': 26, │ │ - │ │ │ 'download_url': None, │ │ - │ │ │ 'started_at': None, │ │ - │ │ │ ... +2 │ │ - │ │ } │ │ - │ │ instance = │ │ - │ │ self = │ │ - │ │ state = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/attributes.py:1 │ - │ 096 in get │ - │ │ - │ 1093 │ │ │ │ if not passive & CALLABLES_OK: │ - │ 1094 │ │ │ │ │ return PASSIVE_NO_RESULT │ - │ 1095 │ │ │ │ │ - │ ❱ 1096 │ │ │ │ value = self.\_fire_loader_callables(state, key, passive) │ - │ 1097 │ │ │ │ │ - │ 1098 │ │ │ │ if value is PASSIVE_NO_RESULT or value is NO_VALUE: │ - │ 1099 │ │ │ │ │ return value │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ dict* = { │ │ - │ │ │ '_sa_instance_state': , │ │ - │ │ │ 'id': 8, │ │ - │ │ │ 'error_message': None, │ │ - │ │ │ 'file_destination': None, │ │ - │ │ │ 'completed_at': None, │ │ - │ │ │ 'updated_at': datetime.datetime(2026, 1, 23, 17, 10, 47), │ │ - │ │ │ 'series_id': 146, │ │ - │ │ │ 'episode_id': 26, │ │ - │ │ │ 'download_url': None, │ │ - │ │ │ 'started_at': None, │ │ - │ │ │ ... +2 │ │ - │ │ } │ │ - │ │ key = 'episode' │ │ - │ │ passive = symbol('PASSIVE_OFF') │ │ - │ │ self = │ │ - │ │ state = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/attributes.py:1 │ - │ 131 in \_fire_loader_callables │ - │ │ - │ 1128 │ │ │ callable_ = state.callables[key] │ - │ 1129 │ │ │ return callable*(state, passive) │ - │ 1130 │ │ elif self.callable*: │ - │ ❱ 1131 │ │ │ return self.callable*(state, passive) │ - │ 1132 │ │ else: │ - │ 1133 │ │ │ return ATTR_EMPTY │ - │ 1134 │ - │ │ - │ ╭───────────────────────────────────────── locals ─────────────────────────────────────────╮ │ - │ │ key = 'episode' │ │ - │ │ passive = symbol('PASSIVE_OFF') │ │ - │ │ self = │ │ - │ │ state = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/strategies.py:9 │ - │ 78 in \_load_for_state │ - │ │ - │ 975 │ │ │ ): │ - │ 976 │ │ │ │ return LoaderCallableStatus.PASSIVE_NO_RESULT │ - │ 977 │ │ │ - │ ❱ 978 │ │ return self.\_emit_lazyload( │ - │ 979 │ │ │ session, │ - │ 980 │ │ │ state, │ - │ 981 │ │ │ primary_key_identity, │ - │ │ - │ ╭────────────────────────────────────────── locals ──────────────────────────────────────────╮ │ - │ │ alternate_effective_path = None │ │ - │ │ execution_options = immutabledict({}) │ │ - │ │ extra_criteria = () │ │ - │ │ extra_options = () │ │ - │ │ instance = None │ │ - │ │ loadopt = None │ │ - │ │ passive = symbol('PASSIVE_OFF') │ │ - │ │ pending = False │ │ - │ │ primary_key_identity = [26] │ │ - │ │ self = │ │ - │ │ session = │ │ - │ │ state = │ │ - │ │ use_get = True │ │ - │ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/strategies.py:1 │ - │ 079 in \_emit_lazyload │ - │ │ - │ 1076 │ │ │ if self.\_raise_on_sql and not passive & PassiveFlag.NO_RAISE: │ - │ 1077 │ │ │ │ self.\_invoke_raise_load(state, passive, "raise_on_sql") │ - │ 1078 │ │ │ │ - │ ❱ 1079 │ │ │ return loading.load_on_pk_identity( │ - │ 1080 │ │ │ │ session, │ - │ 1081 │ │ │ │ stmt, │ - │ 1082 │ │ │ │ primary_key_identity, │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ alternate_effective_path = None │ │ - │ │ clauseelement = Table('episodes', MetaData(), Column('id', Integer(), │ │ - │ │ table=, primary_key=True, nullable=False), │ │ - │ │ Column('series_id', Integer(), ForeignKey('anime_series.id'), │ │ - │ │ table=, nullable=False), Column('season', Integer(), │ │ - │ │ table=, nullable=False), Column('episode_number', │ │ - │ │ Integer(), table=, nullable=False), Column('title', │ │ - │ │ String(length=500), table=), Column('file_path', │ │ - │ │ String(length=1000), table=), Column('is_downloaded', │ │ - │ │ Boolean(), table=, nullable=False, │ │ - │ │ default=ScalarElementColumnDefault(False)), Column('created_at', │ │ - │ │ DateTime(timezone=True), table=, nullable=False, │ │ - │ │ server_default=DefaultClause(, for_update=False)), Column('updated_at', │ │ - │ │ DateTime(timezone=True), table=, nullable=False, │ │ - │ │ onupdate=ColumnElementColumnDefault(), │ │ - │ │ server_default=DefaultClause(, for_update=False)), schema=None) │ │ - │ │ effective_path = PropRegistry((, │ │ - │ │ <\_RelationshipDeclared at 0x7ed681866df0; episode>)) │ │ - │ │ execution_options = immutabledict({}) │ │ - │ │ extra_criteria = () │ │ - │ │ extra_options = () │ │ - │ │ load_options = default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=) │ │ - │ │ loadopt = None │ │ - │ │ opts = ( │ │ - │ │ │ , │ │ - │ │ ) │ │ - │ │ passive = symbol('PASSIVE_OFF') │ │ - │ │ pending = False │ │ - │ │ primary_key_identity = [26] │ │ - │ │ self = │ │ - │ │ session = │ │ - │ │ state = │ │ - │ │ stmt = │ │ - │ │ strategy_options = │ │ - │ │ \_get_params = { │ │ - │ │ │ Column('id', Integer(), table=, primary_key=True, │ │ - │ │ nullable=False): BindParameter('pk_1', None, type*=Integer()) │ │ - │ │ } │ │ - │ │ bind*arguments = immutabledict({}) │ │ - │ │ compile_options = default_compile_options(\_current_path=PropRegistry((, <\_RelationshipDeclared at │ │ - │ │ 0x7ed681866df0; episode>))) │ │ - │ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ - │ │ default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=)}) │ │ - │ │ identity_token = None │ │ - │ │ is_user_refresh = False │ │ - │ │ load_options = default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=) │ │ - │ │ mapper = │ │ - │ │ new_compile_options = default_compile_options(\_current_path=PropRegistry((, <\_RelationshipDeclared at │ │ - │ │ 0x7ed681866df0; episode>))) │ │ - │ │ no_autoflush = False │ │ - │ │ only_load_props = None │ │ - │ │ params = {'pk_1': 26} │ │ - │ │ primary_key_identity = [26] │ │ - │ │ q = │ │ - │ │ query = │ │ - │ │ refresh_state = None │ │ - │ │ require_pk_cols = False │ │ - │ │ session = │ │ - │ │ statement = │ │ - │ │ version_check = False │ │ - │ │ with_for_update = None │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/session.py:2351 │ - │ in execute │ - │ │ - │ 2348 │ │ │ - │ 2349 │ │ │ - │ 2350 │ │ """ │ - │ ❱ 2351 │ │ return self.\_execute_internal( │ - │ 2352 │ │ │ statement, │ - │ 2353 │ │ │ params, │ - │ 2354 │ │ │ execution_options=execution_options, │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ \_add_event = None │ │ - │ │ \_parent_execute_state = None │ │ - │ │ bind_arguments = immutabledict({}) │ │ - │ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ - │ │ default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=)}) │ │ - │ │ params = {'pk_1': 26} │ │ - │ │ self = │ │ - │ │ statement = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/session.py:2249 │ - │ in \_execute_internal │ - │ │ - │ 2246 │ │ │ ) │ - │ 2247 │ │ │ - │ 2248 │ │ if compile_state_cls: │ - │ ❱ 2249 │ │ │ result: Result[Any] = compile_state_cls.orm_execute_statement( │ - │ 2250 │ │ │ │ self, │ - │ 2251 │ │ │ │ statement, │ - │ 2252 │ │ │ │ params or {}, │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ \_add_event = None │ │ - │ │ \_parent_execute_state = None │ │ - │ │ \_scalar_result = False │ │ - │ │ bind = Engine(sqlite+aiosqlite:///./data/aniworld.db) │ │ - │ │ bind_arguments = { │ │ - │ │ │ 'clause': , │ │ - │ │ │ 'mapper': │ │ - │ │ } │ │ - │ │ conn = │ │ - │ │ events_todo = │ │ - │ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ - │ │ default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ - │ │ params = {'pk_1': 26} │ │ - │ │ self = │ │ - │ │ statement = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/context.py:306 │ - │ in orm_execute_statement │ - │ │ - │ 303 │ │ bind_arguments, │ - │ 304 │ │ conn, │ - │ 305 │ ) -> Result: │ - │ ❱ 306 │ │ result = conn.execute( │ - │ 307 │ │ │ statement, params or {}, execution_options=execution_options │ - │ 308 │ │ ) │ - │ 309 │ │ return cls.orm_setup_cursor_result( │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ bind_arguments = { │ │ - │ │ │ 'clause': , │ │ - │ │ │ 'mapper': │ │ - │ │ } │ │ - │ │ conn = │ │ - │ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ - │ │ default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ - │ │ params = {'pk_1': 26} │ │ - │ │ session = │ │ - │ │ statement = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1419 │ - │ in execute │ - │ │ - │ 1416 │ │ except AttributeError as err: │ - │ 1417 │ │ │ raise exc.ObjectNotExecutableError(statement) from err │ - │ 1418 │ │ else: │ - │ ❱ 1419 │ │ │ return meth( │ - │ 1420 │ │ │ │ self, │ - │ 1421 │ │ │ │ distilled_parameters, │ - │ 1422 │ │ │ │ execution_options or NO_OPTIONS, │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ distilled_parameters = [{'pk_1': 26}] │ │ - │ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ - │ │ default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ - │ │ meth = > │ │ - │ │ parameters = {'pk_1': 26} │ │ - │ │ self = │ │ - │ │ statement = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/sql/elements.py:526 │ - │ in \_execute_on_connection │ - │ │ - │ 523 │ │ if self.supports_execution: │ - │ 524 │ │ │ if TYPE_CHECKING: │ - │ 525 │ │ │ │ assert isinstance(self, Executable) │ - │ ❱ 526 │ │ │ return connection.\_execute_clauseelement( │ - │ 527 │ │ │ │ self, distilled_params, execution_options │ - │ 528 │ │ │ ) │ - │ 529 │ │ else: │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ connection = │ │ - │ │ distilled_params = [{'pk_1': 26}] │ │ - │ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ - │ │ default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ - │ │ self = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1641 │ - │ in \_execute_clauseelement │ - │ │ - │ 1638 │ │ │ schema_translate_map=schema_translate_map, │ - │ 1639 │ │ │ linting=self.dialect.compiler_linting | compiler.WARN_LINTING, │ - │ 1640 │ │ ) │ - │ ❱ 1641 │ │ ret = self.\_execute_context( │ - │ 1642 │ │ │ dialect, │ - │ 1643 │ │ │ dialect.execution_ctx_cls.\_init_compiled, │ - │ 1644 │ │ │ compiled_sql, │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ cache_hit = │ │ - │ │ compiled_cache = │ │ - │ │ compiled_sql = │ │ - │ │ dialect = │ │ - │ │ distilled_parameters = [{'pk_1': 26}] │ │ - │ │ elem = │ │ - │ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ - │ │ default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ - │ │ extracted_params = [AnnotatedBindParameter('pk_1', None, type*=Integer())] │ │ - │ │ for*executemany = False │ │ - │ │ has_events = False │ │ - │ │ keys = ['pk_1'] │ │ - │ │ schema_translate_map = None │ │ - │ │ self = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1846 │ - │ in \_execute_context │ - │ │ - │ 1843 │ │ if context.execute_style is ExecuteStyle.INSERTMANYVALUES: │ - │ 1844 │ │ │ return self.\_exec_insertmany_context(dialect, context) │ - │ 1845 │ │ else: │ - │ ❱ 1846 │ │ │ return self.\_exec_single_context( │ - │ 1847 │ │ │ │ dialect, context, statement, parameters │ - │ 1848 │ │ │ ) │ - │ 1849 │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ args = ( │ │ - │ │ │ , │ │ - │ │ │ [{'pk_1': 26}], │ │ - │ │ │ , │ │ - │ │ │ [AnnotatedBindParameter('pk_1', None, type*=Integer())] │ │ - │ │ ) │ │ - │ │ conn = │ │ - │ │ constructor = │ │ - │ │ dialect = │ │ - │ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ - │ │ default_load_options(\_invoke_all_eagers=False, │ │ - │ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ - │ │ kw = {'cache_hit': } │ │ - │ │ parameters = [{'pk_1': 26}] │ │ - │ │ self = │ │ - │ │ statement = │ │ - │ │ yp = None │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1986 │ - │ in \_exec_single_context │ - │ │ - │ 1983 │ │ │ result = context.\_setup_result_proxy() │ - │ 1984 │ │ │ - │ 1985 │ │ except BaseException as e: │ - │ ❱ 1986 │ │ │ self.\_handle_dbapi_exception( │ - │ 1987 │ │ │ │ e, str_statement, effective_parameters, cursor, context │ - │ 1988 │ │ │ ) │ - │ 1989 │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ context = │ │ - │ │ cursor = │ │ - │ │ dialect = │ │ - │ │ effective_parameters = (26,) │ │ - │ │ evt_handled = False │ │ - │ │ parameters = [(26,)] │ │ - │ │ self = │ │ - │ │ statement = │ │ - │ │ str_statement = 'SELECT episodes.id AS episodes_id, episodes.series_id AS │ │ - │ │ episodes_series_id, epi'+335 │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:2358 │ - │ in \_handle_dbapi_exception │ - │ │ - │ 2355 │ │ │ │ raise sqlalchemy_exception.with_traceback(exc_info[2]) from e │ - │ 2356 │ │ │ else: │ - │ 2357 │ │ │ │ assert exc_info[1] is not None │ - │ ❱ 2358 │ │ │ │ raise exc_info[1].with_traceback(exc_info[2]) │ - │ 2359 │ │ finally: │ - │ 2360 │ │ │ del self.\_reentrant_error │ - │ 2361 │ │ │ if self.\_is_disconnect: │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ context = │ │ - │ │ cursor = │ │ - │ │ e = MissingGreenlet("greenlet*spawn has not been called; can't │ │ - │ │ call await_only() here. Was IO attempted in an unexpected │ │ - │ │ place?") │ │ - │ │ exc_info = ( │ │ - │ │ │ , │ │ - │ │ │ MissingGreenlet("greenlet_spawn has not been called; │ │ - │ │ can't call await_only() here. Was IO attempted in an │ │ - │ │ unexpected place?"), │ │ - │ │ │ │ │ - │ │ ) │ │ - │ │ invalidate_pool_on_disconnect = True │ │ - │ │ is_exit_exception = False │ │ - │ │ is_sub_exec = False │ │ - │ │ ismulti = False │ │ - │ │ newraise = None │ │ - │ │ parameters = (26,) │ │ - │ │ self = │ │ - │ │ should_wrap = False │ │ - │ │ sqlalchemy_exception = None │ │ - │ │ statement = 'SELECT episodes.id AS episodes_id, episodes.series_id AS │ │ - │ │ episodes_series_id, epi'+335 │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1967 │ - │ in \_exec_single_context │ - │ │ - │ 1964 │ │ │ │ │ │ │ evt_handled = True │ - │ 1965 │ │ │ │ │ │ │ break │ - │ 1966 │ │ │ │ if not evt_handled: │ - │ ❱ 1967 │ │ │ │ │ self.dialect.do_execute( │ - │ 1968 │ │ │ │ │ │ cursor, str_statement, effective_parameters, context │ - │ 1969 │ │ │ │ │ ) │ - │ 1970 │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ context = │ │ - │ │ cursor = │ │ - │ │ dialect = │ │ - │ │ effective_parameters = (26,) │ │ - │ │ evt_handled = False │ │ - │ │ parameters = [(26,)] │ │ - │ │ self = │ │ - │ │ statement = │ │ - │ │ str_statement = 'SELECT episodes.id AS episodes_id, episodes.series_id AS │ │ - │ │ episodes_series_id, epi'+335 │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/default.py:9 │ - │ 51 in do_execute │ - │ │ - │ 948 │ │ cursor.executemany(statement, parameters) │ - │ 949 │ │ - │ 950 │ def do_execute(self, cursor, statement, parameters, context=None): │ - │ ❱ 951 │ │ cursor.execute(statement, parameters) │ - │ 952 │ │ - │ 953 │ def do_execute_no_params(self, cursor, statement, context=None): │ - │ 954 │ │ cursor.execute(statement) │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ context = │ │ - │ │ cursor = │ │ - │ │ parameters = (26,) │ │ - │ │ self = │ │ - │ │ statement = 'SELECT episodes.id AS episodes_id, episodes.series_id AS episodes_series_id, │ │ - │ │ epi'+335 │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/dialects/sqlite/aio │ - │ sqlite.py:180 in execute │ - │ │ - │ 177 │ │ │ else: │ - │ 178 │ │ │ │ self.\_cursor = \_cursor # type: ignore[misc] │ - │ 179 │ │ except Exception as error: │ - │ ❱ 180 │ │ │ self.\_adapt_connection.\_handle_exception(error) │ - │ 181 │ │ - │ 182 │ def executemany( │ - │ 183 │ │ self, │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ operation = 'SELECT episodes.id AS episodes_id, episodes.series_id AS episodes_series_id, │ │ - │ │ epi'+335 │ │ - │ │ parameters = (26,) │ │ - │ │ self = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/dialects/sqlite/aio │ - │ sqlite.py:340 in \_handle_exception │ - │ │ - │ 337 │ │ │ │ "no active connection" │ - │ 338 │ │ │ ) from error │ - │ 339 │ │ else: │ - │ ❱ 340 │ │ │ raise error │ - │ 341 │ - │ 342 │ - │ 343 class AsyncAdaptFallback_aiosqlite_connection(AsyncAdapt_aiosqlite_connection): │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ error = MissingGreenlet("greenlet_spawn has not been called; can't call await_only() here. │ │ - │ │ Was IO attempted in an unexpected place?") │ │ - │ │ self = > │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/dialects/sqlite/aio │ - │ sqlite.py:157 in execute │ - │ │ - │ 154 │ ) -> Any: │ - │ 155 │ │ │ - │ 156 │ │ try: │ - │ ❱ 157 │ │ │ \_cursor: AsyncIODBAPICursor = self.await*(self._connection.cursor()) # type │ - │ 158 │ │ │ │ - │ 159 │ │ │ if parameters is None: │ - │ 160 │ │ │ │ self.await_(\_cursor.execute(operation)) │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ operation = 'SELECT episodes.id AS episodes_id, episodes.series_id AS episodes_series_id, │ │ - │ │ epi'+335 │ │ - │ │ parameters = (26,) │ │ - │ │ self = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - │ │ - │ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/util/\_concurrency_p │ - │ y3k.py:123 in await_only │ - │ │ - │ 120 │ if not getattr(current, "**sqlalchemy_greenlet_provider**", False): │ - │ 121 │ │ \_safe_cancel_awaitable(awaitable) │ - │ 122 │ │ │ - │ ❱ 123 │ │ raise exc.MissingGreenlet( │ - │ 124 │ │ │ "greenlet_spawn has not been called; can't call await_only() " │ - │ 125 │ │ │ "here. Was IO attempted in an unexpected place?" │ - │ 126 │ │ ) │ - │ │ - │ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ - │ │ awaitable = │ │ - │ │ current = │ │ - │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ - ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ - MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted - in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s) +✅ **COMPLETED (1):** Fixed greenlet_spawn async lazy-loading error - Added selectinload for episode relationship in DownloadQueueService.get_all() + +✅ **COMPLETED (2):** Fixed anime add endpoint 500 error - Added explicit commit/rollback in database session dependencies + +✅ **COMPLETED (3):** Added database transactions - All database operations properly use session context managers with automatic commit/rollback + +**TODO (4):** Create series filter to filter all series with no episodes found in folder + +--- + +### ~~1. fix issue (COMPLETED):~~ + +2026-01-23 18:28:39 [info ] DownloadService initialized max*retries=3 +INFO: QueueRepository initialized +ERROR: Failed to get all items: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s) +2026-01-23 18:28:39 [error ] Failed to load queue from database: Failed to get all items: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s) +╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮ +│ /home/lukas/Volume/repo/Aniworld/src/server/services/queue_repository.py:292 in get_all_items │ +│ │ +│ 289 │ │ │ db_items = await DownloadQueueService.get_all( │ +│ 290 │ │ │ │ session, with_series=True │ +│ 291 │ │ │ ) │ +│ ❱ 292 │ │ │ return [self._from_db_model(item) for item in db_items] │ +│ 293 │ │ │ +│ 294 │ │ except Exception as e: │ +│ 295 │ │ │ logger.error("Failed to get all items: %s", e) │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ db = None │ │ +│ │ db_items = [ │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ , │ │ +│ │ │ ... +21 │ │ +│ │ ] │ │ +│ │ manage_session = True │ │ +│ │ self = │ │ +│ │ session = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/Volume/repo/Aniworld/src/server/services/queue_repository.py:97 in \_from_db_model │ +│ │ +│ 94 │ │ │ Pydantic download item with default status/priority │ +│ 95 │ │ """ │ +│ 96 │ │ # Get episode info from the related Episode object │ +│ ❱ 97 │ │ episode = db_item.episode │ +│ 98 │ │ series = db_item.series │ +│ 99 │ │ │ +│ 100 │ │ episode_identifier = EpisodeIdentifier( │ +│ │ +│ ╭───────────────────────────────────────── locals ──────────────────────────────────────────╮ │ +│ │ db_item = │ │ +│ │ item_id = None │ │ +│ │ self = │ │ +│ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/attributes.py:5 │ +│ 69 in **get** │ +│ │ +│ 566 │ │ │ │ state = instance_state(instance) │ +│ 567 │ │ │ except AttributeError as err: │ +│ 568 │ │ │ │ raise orm_exc.UnmappedInstanceError(instance) from err │ +│ ❱ 569 │ │ │ return self.impl.get(state, dict*) # type: ignore[no-any-return] │ +│ 570 │ +│ 571 │ +│ 572 @dataclasses.dataclass(frozen=True) │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ dict* = { │ │ +│ │ │ '\_sa_instance_state': , │ │ +│ │ │ 'id': 8, │ │ +│ │ │ 'error_message': None, │ │ +│ │ │ 'file_destination': None, │ │ +│ │ │ 'completed_at': None, │ │ +│ │ │ 'updated_at': datetime.datetime(2026, 1, 23, 17, 10, 47), │ │ +│ │ │ 'series_id': 146, │ │ +│ │ │ 'episode_id': 26, │ │ +│ │ │ 'download_url': None, │ │ +│ │ │ 'started_at': None, │ │ +│ │ │ ... +2 │ │ +│ │ } │ │ +│ │ instance = │ │ +│ │ self = │ │ +│ │ state = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/attributes.py:1 │ +│ 096 in get │ +│ │ +│ 1093 │ │ │ │ if not passive & CALLABLES_OK: │ +│ 1094 │ │ │ │ │ return PASSIVE_NO_RESULT │ +│ 1095 │ │ │ │ │ +│ ❱ 1096 │ │ │ │ value = self.\_fire_loader_callables(state, key, passive) │ +│ 1097 │ │ │ │ │ +│ 1098 │ │ │ │ if value is PASSIVE_NO_RESULT or value is NO_VALUE: │ +│ 1099 │ │ │ │ │ return value │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ dict* = { │ │ +│ │ │ '_sa_instance_state': , │ │ +│ │ │ 'id': 8, │ │ +│ │ │ 'error_message': None, │ │ +│ │ │ 'file_destination': None, │ │ +│ │ │ 'completed_at': None, │ │ +│ │ │ 'updated_at': datetime.datetime(2026, 1, 23, 17, 10, 47), │ │ +│ │ │ 'series_id': 146, │ │ +│ │ │ 'episode_id': 26, │ │ +│ │ │ 'download_url': None, │ │ +│ │ │ 'started_at': None, │ │ +│ │ │ ... +2 │ │ +│ │ } │ │ +│ │ key = 'episode' │ │ +│ │ passive = symbol('PASSIVE_OFF') │ │ +│ │ self = │ │ +│ │ state = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/attributes.py:1 │ +│ 131 in \_fire_loader_callables │ +│ │ +│ 1128 │ │ │ callable_ = state.callables[key] │ +│ 1129 │ │ │ return callable*(state, passive) │ +│ 1130 │ │ elif self.callable*: │ +│ ❱ 1131 │ │ │ return self.callable*(state, passive) │ +│ 1132 │ │ else: │ +│ 1133 │ │ │ return ATTR_EMPTY │ +│ 1134 │ +│ │ +│ ╭───────────────────────────────────────── locals ─────────────────────────────────────────╮ │ +│ │ key = 'episode' │ │ +│ │ passive = symbol('PASSIVE_OFF') │ │ +│ │ self = │ │ +│ │ state = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/strategies.py:9 │ +│ 78 in \_load_for_state │ +│ │ +│ 975 │ │ │ ): │ +│ 976 │ │ │ │ return LoaderCallableStatus.PASSIVE_NO_RESULT │ +│ 977 │ │ │ +│ ❱ 978 │ │ return self.\_emit_lazyload( │ +│ 979 │ │ │ session, │ +│ 980 │ │ │ state, │ +│ 981 │ │ │ primary_key_identity, │ +│ │ +│ ╭────────────────────────────────────────── locals ──────────────────────────────────────────╮ │ +│ │ alternate_effective_path = None │ │ +│ │ execution_options = immutabledict({}) │ │ +│ │ extra_criteria = () │ │ +│ │ extra_options = () │ │ +│ │ instance = None │ │ +│ │ loadopt = None │ │ +│ │ passive = symbol('PASSIVE_OFF') │ │ +│ │ pending = False │ │ +│ │ primary_key_identity = [26] │ │ +│ │ self = │ │ +│ │ session = │ │ +│ │ state = │ │ +│ │ use_get = True │ │ +│ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/strategies.py:1 │ +│ 079 in \_emit_lazyload │ +│ │ +│ 1076 │ │ │ if self.\_raise_on_sql and not passive & PassiveFlag.NO_RAISE: │ +│ 1077 │ │ │ │ self.\_invoke_raise_load(state, passive, "raise_on_sql") │ +│ 1078 │ │ │ │ +│ ❱ 1079 │ │ │ return loading.load_on_pk_identity( │ +│ 1080 │ │ │ │ session, │ +│ 1081 │ │ │ │ stmt, │ +│ 1082 │ │ │ │ primary_key_identity, │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ alternate_effective_path = None │ │ +│ │ clauseelement = Table('episodes', MetaData(), Column('id', Integer(), │ │ +│ │ table=, primary_key=True, nullable=False), │ │ +│ │ Column('series_id', Integer(), ForeignKey('anime_series.id'), │ │ +│ │ table=, nullable=False), Column('season', Integer(), │ │ +│ │ table=, nullable=False), Column('episode_number', │ │ +│ │ Integer(), table=, nullable=False), Column('title', │ │ +│ │ String(length=500), table=), Column('file_path', │ │ +│ │ String(length=1000), table=), Column('is_downloaded', │ │ +│ │ Boolean(), table=, nullable=False, │ │ +│ │ default=ScalarElementColumnDefault(False)), Column('created_at', │ │ +│ │ DateTime(timezone=True), table=, nullable=False, │ │ +│ │ server_default=DefaultClause(, for_update=False)), Column('updated_at', │ │ +│ │ DateTime(timezone=True), table=, nullable=False, │ │ +│ │ onupdate=ColumnElementColumnDefault(), │ │ +│ │ server_default=DefaultClause(, for_update=False)), schema=None) │ │ +│ │ effective_path = PropRegistry((, │ │ +│ │ <\_RelationshipDeclared at 0x7ed681866df0; episode>)) │ │ +│ │ execution_options = immutabledict({}) │ │ +│ │ extra_criteria = () │ │ +│ │ extra_options = () │ │ +│ │ load_options = default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=) │ │ +│ │ loadopt = None │ │ +│ │ opts = ( │ │ +│ │ │ , │ │ +│ │ ) │ │ +│ │ passive = symbol('PASSIVE_OFF') │ │ +│ │ pending = False │ │ +│ │ primary_key_identity = [26] │ │ +│ │ self = │ │ +│ │ session = │ │ +│ │ state = │ │ +│ │ stmt = │ │ +│ │ strategy_options = │ │ +│ │ \_get_params = { │ │ +│ │ │ Column('id', Integer(), table=, primary_key=True, │ │ +│ │ nullable=False): BindParameter('pk_1', None, type*=Integer()) │ │ +│ │ } │ │ +│ │ bind*arguments = immutabledict({}) │ │ +│ │ compile_options = default_compile_options(\_current_path=PropRegistry((, <\_RelationshipDeclared at │ │ +│ │ 0x7ed681866df0; episode>))) │ │ +│ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ +│ │ default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=)}) │ │ +│ │ identity_token = None │ │ +│ │ is_user_refresh = False │ │ +│ │ load_options = default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=) │ │ +│ │ mapper = │ │ +│ │ new_compile_options = default_compile_options(\_current_path=PropRegistry((, <\_RelationshipDeclared at │ │ +│ │ 0x7ed681866df0; episode>))) │ │ +│ │ no_autoflush = False │ │ +│ │ only_load_props = None │ │ +│ │ params = {'pk_1': 26} │ │ +│ │ primary_key_identity = [26] │ │ +│ │ q = │ │ +│ │ query = │ │ +│ │ refresh_state = None │ │ +│ │ require_pk_cols = False │ │ +│ │ session = │ │ +│ │ statement = │ │ +│ │ version_check = False │ │ +│ │ with_for_update = None │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/session.py:2351 │ +│ in execute │ +│ │ +│ 2348 │ │ │ +│ 2349 │ │ │ +│ 2350 │ │ """ │ +│ ❱ 2351 │ │ return self.\_execute_internal( │ +│ 2352 │ │ │ statement, │ +│ 2353 │ │ │ params, │ +│ 2354 │ │ │ execution_options=execution_options, │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ \_add_event = None │ │ +│ │ \_parent_execute_state = None │ │ +│ │ bind_arguments = immutabledict({}) │ │ +│ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ +│ │ default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=)}) │ │ +│ │ params = {'pk_1': 26} │ │ +│ │ self = │ │ +│ │ statement = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/session.py:2249 │ +│ in \_execute_internal │ +│ │ +│ 2246 │ │ │ ) │ +│ 2247 │ │ │ +│ 2248 │ │ if compile_state_cls: │ +│ ❱ 2249 │ │ │ result: Result[Any] = compile_state_cls.orm_execute_statement( │ +│ 2250 │ │ │ │ self, │ +│ 2251 │ │ │ │ statement, │ +│ 2252 │ │ │ │ params or {}, │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ \_add_event = None │ │ +│ │ \_parent_execute_state = None │ │ +│ │ \_scalar_result = False │ │ +│ │ bind = Engine(sqlite+aiosqlite:///./data/aniworld.db) │ │ +│ │ bind_arguments = { │ │ +│ │ │ 'clause': , │ │ +│ │ │ 'mapper': │ │ +│ │ } │ │ +│ │ conn = │ │ +│ │ events_todo = │ │ +│ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ +│ │ default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ +│ │ params = {'pk_1': 26} │ │ +│ │ self = │ │ +│ │ statement = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/orm/context.py:306 │ +│ in orm_execute_statement │ +│ │ +│ 303 │ │ bind_arguments, │ +│ 304 │ │ conn, │ +│ 305 │ ) -> Result: │ +│ ❱ 306 │ │ result = conn.execute( │ +│ 307 │ │ │ statement, params or {}, execution_options=execution_options │ +│ 308 │ │ ) │ +│ 309 │ │ return cls.orm_setup_cursor_result( │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ bind_arguments = { │ │ +│ │ │ 'clause': , │ │ +│ │ │ 'mapper': │ │ +│ │ } │ │ +│ │ conn = │ │ +│ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ +│ │ default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ +│ │ params = {'pk_1': 26} │ │ +│ │ session = │ │ +│ │ statement = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1419 │ +│ in execute │ +│ │ +│ 1416 │ │ except AttributeError as err: │ +│ 1417 │ │ │ raise exc.ObjectNotExecutableError(statement) from err │ +│ 1418 │ │ else: │ +│ ❱ 1419 │ │ │ return meth( │ +│ 1420 │ │ │ │ self, │ +│ 1421 │ │ │ │ distilled_parameters, │ +│ 1422 │ │ │ │ execution_options or NO_OPTIONS, │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ distilled_parameters = [{'pk_1': 26}] │ │ +│ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ +│ │ default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ +│ │ meth = > │ │ +│ │ parameters = {'pk_1': 26} │ │ +│ │ self = │ │ +│ │ statement = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/sql/elements.py:526 │ +│ in \_execute_on_connection │ +│ │ +│ 523 │ │ if self.supports_execution: │ +│ 524 │ │ │ if TYPE_CHECKING: │ +│ 525 │ │ │ │ assert isinstance(self, Executable) │ +│ ❱ 526 │ │ │ return connection.\_execute_clauseelement( │ +│ 527 │ │ │ │ self, distilled_params, execution_options │ +│ 528 │ │ │ ) │ +│ 529 │ │ else: │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ connection = │ │ +│ │ distilled_params = [{'pk_1': 26}] │ │ +│ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ +│ │ default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ +│ │ self = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1641 │ +│ in \_execute_clauseelement │ +│ │ +│ 1638 │ │ │ schema_translate_map=schema_translate_map, │ +│ 1639 │ │ │ linting=self.dialect.compiler_linting | compiler.WARN_LINTING, │ +│ 1640 │ │ ) │ +│ ❱ 1641 │ │ ret = self.\_execute_context( │ +│ 1642 │ │ │ dialect, │ +│ 1643 │ │ │ dialect.execution_ctx_cls.\_init_compiled, │ +│ 1644 │ │ │ compiled_sql, │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ cache_hit = │ │ +│ │ compiled_cache = │ │ +│ │ compiled_sql = │ │ +│ │ dialect = │ │ +│ │ distilled_parameters = [{'pk_1': 26}] │ │ +│ │ elem = │ │ +│ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ +│ │ default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ +│ │ extracted_params = [AnnotatedBindParameter('pk_1', None, type*=Integer())] │ │ +│ │ for*executemany = False │ │ +│ │ has_events = False │ │ +│ │ keys = ['pk_1'] │ │ +│ │ schema_translate_map = None │ │ +│ │ self = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1846 │ +│ in \_execute_context │ +│ │ +│ 1843 │ │ if context.execute_style is ExecuteStyle.INSERTMANYVALUES: │ +│ 1844 │ │ │ return self.\_exec_insertmany_context(dialect, context) │ +│ 1845 │ │ else: │ +│ ❱ 1846 │ │ │ return self.\_exec_single_context( │ +│ 1847 │ │ │ │ dialect, context, statement, parameters │ +│ 1848 │ │ │ ) │ +│ 1849 │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ args = ( │ │ +│ │ │ , │ │ +│ │ │ [{'pk_1': 26}], │ │ +│ │ │ , │ │ +│ │ │ [AnnotatedBindParameter('pk_1', None, type*=Integer())] │ │ +│ │ ) │ │ +│ │ conn = │ │ +│ │ constructor = │ │ +│ │ dialect = │ │ +│ │ execution_options = immutabledict({'\_sa_orm_load_options': │ │ +│ │ default_load_options(\_invoke_all_eagers=False, │ │ +│ │ \_lazy_loaded_from=), '\_result_disable_adapt_to_context': True}) │ │ +│ │ kw = {'cache_hit': } │ │ +│ │ parameters = [{'pk_1': 26}] │ │ +│ │ self = │ │ +│ │ statement = │ │ +│ │ yp = None │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1986 │ +│ in \_exec_single_context │ +│ │ +│ 1983 │ │ │ result = context.\_setup_result_proxy() │ +│ 1984 │ │ │ +│ 1985 │ │ except BaseException as e: │ +│ ❱ 1986 │ │ │ self.\_handle_dbapi_exception( │ +│ 1987 │ │ │ │ e, str_statement, effective_parameters, cursor, context │ +│ 1988 │ │ │ ) │ +│ 1989 │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ context = │ │ +│ │ cursor = │ │ +│ │ dialect = │ │ +│ │ effective_parameters = (26,) │ │ +│ │ evt_handled = False │ │ +│ │ parameters = [(26,)] │ │ +│ │ self = │ │ +│ │ statement = │ │ +│ │ str_statement = 'SELECT episodes.id AS episodes_id, episodes.series_id AS │ │ +│ │ episodes_series_id, epi'+335 │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:2358 │ +│ in \_handle_dbapi_exception │ +│ │ +│ 2355 │ │ │ │ raise sqlalchemy_exception.with_traceback(exc_info[2]) from e │ +│ 2356 │ │ │ else: │ +│ 2357 │ │ │ │ assert exc_info[1] is not None │ +│ ❱ 2358 │ │ │ │ raise exc_info[1].with_traceback(exc_info[2]) │ +│ 2359 │ │ finally: │ +│ 2360 │ │ │ del self.\_reentrant_error │ +│ 2361 │ │ │ if self.\_is_disconnect: │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ context = │ │ +│ │ cursor = │ │ +│ │ e = MissingGreenlet("greenlet*spawn has not been called; can't │ │ +│ │ call await_only() here. Was IO attempted in an unexpected │ │ +│ │ place?") │ │ +│ │ exc_info = ( │ │ +│ │ │ , │ │ +│ │ │ MissingGreenlet("greenlet_spawn has not been called; │ │ +│ │ can't call await_only() here. Was IO attempted in an │ │ +│ │ unexpected place?"), │ │ +│ │ │ │ │ +│ │ ) │ │ +│ │ invalidate_pool_on_disconnect = True │ │ +│ │ is_exit_exception = False │ │ +│ │ is_sub_exec = False │ │ +│ │ ismulti = False │ │ +│ │ newraise = None │ │ +│ │ parameters = (26,) │ │ +│ │ self = │ │ +│ │ should_wrap = False │ │ +│ │ sqlalchemy_exception = None │ │ +│ │ statement = 'SELECT episodes.id AS episodes_id, episodes.series_id AS │ │ +│ │ episodes_series_id, epi'+335 │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/base.py:1967 │ +│ in \_exec_single_context │ +│ │ +│ 1964 │ │ │ │ │ │ │ evt_handled = True │ +│ 1965 │ │ │ │ │ │ │ break │ +│ 1966 │ │ │ │ if not evt_handled: │ +│ ❱ 1967 │ │ │ │ │ self.dialect.do_execute( │ +│ 1968 │ │ │ │ │ │ cursor, str_statement, effective_parameters, context │ +│ 1969 │ │ │ │ │ ) │ +│ 1970 │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ context = │ │ +│ │ cursor = │ │ +│ │ dialect = │ │ +│ │ effective_parameters = (26,) │ │ +│ │ evt_handled = False │ │ +│ │ parameters = [(26,)] │ │ +│ │ self = │ │ +│ │ statement = │ │ +│ │ str_statement = 'SELECT episodes.id AS episodes_id, episodes.series_id AS │ │ +│ │ episodes_series_id, epi'+335 │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/engine/default.py:9 │ +│ 51 in do_execute │ +│ │ +│ 948 │ │ cursor.executemany(statement, parameters) │ +│ 949 │ │ +│ 950 │ def do_execute(self, cursor, statement, parameters, context=None): │ +│ ❱ 951 │ │ cursor.execute(statement, parameters) │ +│ 952 │ │ +│ 953 │ def do_execute_no_params(self, cursor, statement, context=None): │ +│ 954 │ │ cursor.execute(statement) │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ context = │ │ +│ │ cursor = │ │ +│ │ parameters = (26,) │ │ +│ │ self = │ │ +│ │ statement = 'SELECT episodes.id AS episodes_id, episodes.series_id AS episodes_series_id, │ │ +│ │ epi'+335 │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/dialects/sqlite/aio │ +│ sqlite.py:180 in execute │ +│ │ +│ 177 │ │ │ else: │ +│ 178 │ │ │ │ self.\_cursor = \_cursor # type: ignore[misc] │ +│ 179 │ │ except Exception as error: │ +│ ❱ 180 │ │ │ self.\_adapt_connection.\_handle_exception(error) │ +│ 181 │ │ +│ 182 │ def executemany( │ +│ 183 │ │ self, │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ operation = 'SELECT episodes.id AS episodes_id, episodes.series_id AS episodes_series_id, │ │ +│ │ epi'+335 │ │ +│ │ parameters = (26,) │ │ +│ │ self = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/dialects/sqlite/aio │ +│ sqlite.py:340 in \_handle_exception │ +│ │ +│ 337 │ │ │ │ "no active connection" │ +│ 338 │ │ │ ) from error │ +│ 339 │ │ else: │ +│ ❱ 340 │ │ │ raise error │ +│ 341 │ +│ 342 │ +│ 343 class AsyncAdaptFallback_aiosqlite_connection(AsyncAdapt_aiosqlite_connection): │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ error = MissingGreenlet("greenlet_spawn has not been called; can't call await_only() here. │ │ +│ │ Was IO attempted in an unexpected place?") │ │ +│ │ self = > │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/dialects/sqlite/aio │ +│ sqlite.py:157 in execute │ +│ │ +│ 154 │ ) -> Any: │ +│ 155 │ │ │ +│ 156 │ │ try: │ +│ ❱ 157 │ │ │ \_cursor: AsyncIODBAPICursor = self.await*(self._connection.cursor()) # type │ +│ 158 │ │ │ │ +│ 159 │ │ │ if parameters is None: │ +│ 160 │ │ │ │ self.await_(\_cursor.execute(operation)) │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ operation = 'SELECT episodes.id AS episodes_id, episodes.series_id AS episodes_series_id, │ │ +│ │ epi'+335 │ │ +│ │ parameters = (26,) │ │ +│ │ self = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/sqlalchemy/util/\_concurrency_p │ +│ y3k.py:123 in await_only │ +│ │ +│ 120 │ if not getattr(current, "**sqlalchemy_greenlet_provider**", False): │ +│ 121 │ │ \_safe_cancel_awaitable(awaitable) │ +│ 122 │ │ │ +│ ❱ 123 │ │ raise exc.MissingGreenlet( │ +│ 124 │ │ │ "greenlet_spawn has not been called; can't call await_only() " │ +│ 125 │ │ │ "here. Was IO attempted in an unexpected place?" │ +│ 126 │ │ ) │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ awaitable = │ │ +│ │ current = │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted +in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s) The above exception was the direct cause of the following exception: @@ -838,7 +849,8 @@ ERROR: Exception in ASGI application Traceback (most recent call last): File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 98, in receive return self.receive_nowait() -~~~~~~~~~~~~~~~~~~~^^ + +```^^ File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 93, in receive_nowait raise WouldBlock anyio.WouldBlock @@ -916,3 +928,8 @@ RuntimeError: generator didn't stop after athrow() 3. transactions go throw code and add transactions. so that application stops the db is not curropted + +4. filter + +make a series filter to filter all series with no episodes found in folder +``` diff --git a/src/server/api/anime.py b/src/server/api/anime.py index 83fa4e2..56f1d26 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -237,7 +237,9 @@ async def list_anime( per_page: Items per page (must be positive, max 1000) sort_by: Optional sorting parameter. Allowed: title, id, name, missing_episodes - filter: Optional filter parameter (validated for security) + filter: Optional filter parameter. Allowed values: + - "no_episodes": Show only series with no downloaded + episodes in folder _auth: Ensures the caller is authenticated (value unused) series_app: Core SeriesApp instance provided via dependency. @@ -308,6 +310,14 @@ async def list_anime( raise ValidationError( message="Invalid filter parameter" ) + + # Validate allowed filter values + allowed_filters = ["no_episodes"] + if filter not in allowed_filters: + allowed = ", ".join(allowed_filters) + raise ValidationError( + message=f"Invalid filter value. Allowed: {allowed}" + ) try: # Get all series from series app @@ -317,15 +327,24 @@ async def list_anime( series = series_app.list.GetList() summaries: List[AnimeSummary] = [] - # Build a map of folder -> NFO data for efficient lookup + # Build a map of folder -> NFO data and episode counts + # for efficient lookup nfo_map = {} + # Track series with no downloaded episodes + series_with_no_episodes = set() + try: # Get all series from database to fetch NFO metadata + # and episode counts from src.server.database.connection import get_sync_session - from src.server.database.models import AnimeSeries as DBAnimeSeries + from src.server.database.models import ( + AnimeSeries as DBAnimeSeries, + Episode + ) session = get_sync_session() try: + # Get NFO data for all series db_series_list = session.query(DBAnimeSeries).all() for db_series in db_series_list: nfo_created = ( @@ -342,12 +361,42 @@ async def list_anime( "nfo_updated_at": nfo_updated, "tmdb_id": db_series.tmdb_id, "tvdb_id": db_series.tvdb_id, + "series_id": db_series.id, } + + # If filter is "no_episodes", get series with + # no downloaded episodes + if filter == "no_episodes": + # Query for series that have no downloaded episodes + # This includes series with no episodes at all + # or only undownloaded episodes + series_ids_with_downloads = ( + session.query(Episode.series_id) + .filter(Episode.is_downloaded.is_(True)) + .distinct() + .all() + ) + series_ids_with_downloads = { + row[0] for row in series_ids_with_downloads + } + + # All series that are NOT in the downloaded set + all_series_ids = { + db_series.id for db_series in db_series_list + } + series_with_no_episodes_ids = ( + all_series_ids - series_ids_with_downloads + ) + + # Map back to folder names for filtering + for db_series in db_series_list: + if db_series.id in series_with_no_episodes_ids: + series_with_no_episodes.add(db_series.folder) finally: session.close() except Exception as e: - logger.warning(f"Could not fetch NFO data from database: {e}") - # Continue without NFO data if database query fails + logger.warning(f"Could not fetch data from database: {e}") + # Continue without filter data if database query fails for serie in series: # Get all properties from the serie object @@ -357,6 +406,12 @@ async def list_anime( folder = getattr(serie, "folder", "") episode_dict = getattr(serie, "episodeDict", {}) or {} + # Apply filter if specified + if filter == "no_episodes": + # Skip series that are not in the no_episodes set + if folder not in series_with_no_episodes: + continue + # Convert episode dict keys to strings for JSON serialization missing_episodes = {str(k): v for k, v in episode_dict.items()} diff --git a/src/server/database/service.py b/src/server/database/service.py index d774478..46f1eb4 100644 --- a/src/server/database/service.py +++ b/src/server/database/service.py @@ -251,6 +251,59 @@ class AnimeSeriesService: .limit(limit) ) return list(result.scalars().all()) + + @staticmethod + async def get_series_with_no_episodes( + db: AsyncSession, + limit: Optional[int] = None, + offset: int = 0, + ) -> List[AnimeSeries]: + """Get anime series that have no downloaded episodes in folder. + + Returns series where either: + - No episodes exist in the database, OR + - All episodes have is_downloaded=False + + Args: + db: Database session + limit: Optional limit for results + offset: Offset for pagination + + Returns: + List of AnimeSeries instances with no downloaded episodes + """ + from sqlalchemy import func, or_ + + # Subquery to count downloaded episodes per series + downloaded_count = ( + select( + Episode.series_id, + func.count(Episode.id).label('downloaded_count') + ) + .where(Episode.is_downloaded.is_(True)) + .group_by(Episode.series_id) + .subquery() + ) + + # Select series that either have no episodes or no downloaded episodes + query = ( + select(AnimeSeries) + .outerjoin(downloaded_count, AnimeSeries.id == downloaded_count.c.series_id) + .where( + or_( + downloaded_count.c.downloaded_count == None, + downloaded_count.c.downloaded_count == 0 + ) + ) + .order_by(AnimeSeries.name) + .offset(offset) + ) + + if limit: + query = query.limit(limit) + + result = await db.execute(query) + return list(result.scalars().all()) # ============================================================================ diff --git a/tests/unit/test_series_filter.py b/tests/unit/test_series_filter.py new file mode 100644 index 0000000..fc35e49 --- /dev/null +++ b/tests/unit/test_series_filter.py @@ -0,0 +1,191 @@ +"""Tests for series filtering functionality.""" +import pytest +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from src.server.database.models import Base +from src.server.database.service import AnimeSeriesService, EpisodeService + + +@pytest.fixture +async def db_engine(): + """Create test database engine.""" + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + ) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + + +@pytest.fixture +async def session_factory(db_engine): + """Create session factory for testing.""" + return async_sessionmaker( + db_engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + autocommit=False, + ) + + +@pytest.fixture +async def async_session(session_factory): + """Create database session for testing.""" + async with session_factory() as session: + yield session + await session.rollback() + + +@pytest.mark.asyncio +async def test_get_series_with_no_episodes_empty_database( + async_session: AsyncSession +): + """Test that empty database returns empty list.""" + result = await AnimeSeriesService.get_series_with_no_episodes( + async_session + ) + assert result == [] + + +@pytest.mark.asyncio +async def test_get_series_with_no_episodes_no_downloaded_episodes( + async_session: AsyncSession +): + """Test that series with no downloaded episodes are returned.""" + # Create a series with no episodes + series1 = await AnimeSeriesService.create( + async_session, + key="test-series-1", + name="Test Series 1", + folder="Test Series 1 (2024)", + site="https://example.com/test1", + ) + + # Create a series with undownloaded episodes + series2 = await AnimeSeriesService.create( + async_session, + key="test-series-2", + name="Test Series 2", + folder="Test Series 2 (2024)", + site="https://example.com/test2", + ) + await EpisodeService.create( + async_session, + series_id=series2.id, + season=1, + episode_number=1, + is_downloaded=False, + ) + + # Create a series with downloaded episodes (should not be in result) + series3 = await AnimeSeriesService.create( + async_session, + key="test-series-3", + name="Test Series 3", + folder="Test Series 3 (2024)", + site="https://example.com/test3", + ) + await EpisodeService.create( + async_session, + series_id=series3.id, + season=1, + episode_number=1, + is_downloaded=True, + ) + + await async_session.commit() + + # Query for series with no downloaded episodes + result = await AnimeSeriesService.get_series_with_no_episodes( + async_session + ) + + # Should return series1 and series2 but not series3 + result_ids = {s.id for s in result} + assert series1.id in result_ids + assert series2.id in result_ids + assert series3.id not in result_ids + + +@pytest.mark.asyncio +async def test_get_series_with_no_episodes_mixed_downloads( + async_session: AsyncSession +): + """Test series with mixed downloaded/undownloaded episodes.""" + # Create series with some downloaded and some undownloaded episodes + series = await AnimeSeriesService.create( + async_session, + key="test-series-mixed", + name="Test Series Mixed", + folder="Test Series Mixed (2024)", + site="https://example.com/testmixed", + ) + + # Add downloaded episode + await EpisodeService.create( + async_session, + series_id=series.id, + season=1, + episode_number=1, + is_downloaded=True, + ) + + # Add undownloaded episode + await EpisodeService.create( + async_session, + series_id=series.id, + season=1, + episode_number=2, + is_downloaded=False, + ) + + await async_session.commit() + + # Query for series with no downloaded episodes + result = await AnimeSeriesService.get_series_with_no_episodes( + async_session + ) + + # Should NOT include series with at least one downloaded episode + result_ids = {s.id for s in result} + assert series.id not in result_ids + + +@pytest.mark.asyncio +async def test_get_series_with_no_episodes_pagination( + async_session: AsyncSession +): + """Test pagination works correctly.""" + # Create multiple series without downloaded episodes + for i in range(5): + await AnimeSeriesService.create( + async_session, + key=f"test-series-{i}", + name=f"Test Series {i}", + folder=f"Test Series {i} (2024)", + site=f"https://example.com/test{i}", + ) + + await async_session.commit() + + # Test limit + result = await AnimeSeriesService.get_series_with_no_episodes( + async_session, limit=3 + ) + assert len(result) == 3 + + # Test offset + result = await AnimeSeriesService.get_series_with_no_episodes( + async_session, offset=2, limit=2 + ) + assert len(result) == 2