feat: scaffhold worker command with gpt 5, it is actually pretty good lol

This commit is contained in:
Benexl
2025-08-08 18:57:08 +03:00
parent 55a7c7facf
commit ae62adf233
15 changed files with 774 additions and 99 deletions

View File

@@ -1,62 +1,218 @@
import click
from fastanime.core.config import AppConfig
from fastanime.core.exceptions import FastAnimeError
from fastanime.libs.media_api.params import MediaSearchParams
from fastanime.libs.media_api.types import (
MediaFormat,
MediaGenre,
MediaItem,
MediaSeason,
MediaSort,
MediaStatus,
MediaTag,
MediaType,
MediaYear,
)
@click.command(help="Queue episodes for the background worker to download.")
# Search/Filter options (mirrors 'fastanime anilist download')
@click.option("--title", "-t")
@click.option("--page", "-p", type=click.IntRange(min=1), default=1)
@click.option("--per-page", type=click.IntRange(min=1, max=50))
@click.option("--season", type=click.Choice([s.value for s in MediaSeason]))
@click.option(
"--title", "-t", required=True, multiple=True, help="Anime title to queue."
"--status", "-S", multiple=True, type=click.Choice([s.value for s in MediaStatus])
)
@click.option(
"--episode-range", "-r", required=True, help="Range of episodes (e.g., '1-10')."
"--status-not", multiple=True, type=click.Choice([s.value for s in MediaStatus])
)
@click.option("--sort", "-s", type=click.Choice([s.value for s in MediaSort]))
@click.option(
"--genres", "-g", multiple=True, type=click.Choice([g.value for g in MediaGenre])
)
@click.option(
"--genres-not", multiple=True, type=click.Choice([g.value for g in MediaGenre])
)
@click.option("--tags", "-T", multiple=True, type=click.Choice([t.value for t in MediaTag]))
@click.option("--tags-not", multiple=True, type=click.Choice([t.value for t in MediaTag]))
@click.option(
"--media-format",
"-f",
multiple=True,
type=click.Choice([f.value for f in MediaFormat]),
)
@click.option("--media-type", type=click.Choice([t.value for t in MediaType]))
@click.option("--year", "-y", type=click.Choice([y.value for y in MediaYear]))
@click.option("--popularity-greater", type=click.IntRange(min=0))
@click.option("--popularity-lesser", type=click.IntRange(min=0))
@click.option("--score-greater", type=click.IntRange(min=0, max=100))
@click.option("--score-lesser", type=click.IntRange(min=0, max=100))
@click.option("--start-date-greater", type=int)
@click.option("--start-date-lesser", type=int)
@click.option("--end-date-greater", type=int)
@click.option("--end-date-lesser", type=int)
@click.option("--on-list/--not-on-list", "-L/-no-L", type=bool, default=None)
# Queue-specific options
@click.option(
"--episode-range",
"-r",
required=True,
help="Range of episodes to queue (e.g., '1-10', '5', '8:12').",
)
@click.option(
"--yes",
"-Y",
is_flag=True,
help="Automatically queue from all found anime without prompting for selection.",
)
@click.pass_obj
def queue(config: AppConfig, title: tuple, episode_range: str):
def queue(config: AppConfig, **options):
"""
Searches for an anime and adds the specified episodes to the download queue.
The background worker must be running for the downloads to start.
Search AniList with filters, select one or more anime (or use --yes),
and queue the specified episode range for background download.
The background worker should be running to process the queue.
"""
from fastanime.cli.service.download.service import DownloadService
from fastanime.cli.service.feedback import FeedbackService
from fastanime.cli.service.registry import MediaRegistryService
from fastanime.cli.utils.parser import parse_episode_range
from fastanime.libs.media_api.params import MediaSearchParams
from fastanime.libs.media_api.api import create_api_client
from fastanime.libs.provider.anime.provider import create_provider
from fastanime.libs.selectors import create_selector
from rich.progress import Progress
feedback = FeedbackService(config)
selector = create_selector(config)
media_api = create_api_client(config.general.media_api, config)
provider = create_provider(config.general.provider)
registry = MediaRegistryService(config.general.media_api, config.media_registry)
download_service = DownloadService(config, registry, media_api, provider)
for anime_title in title:
try:
feedback.info(f"Searching for '{anime_title}'...")
search_result = media_api.search_media(
MediaSearchParams(query=anime_title, per_page=1)
)
try:
# Build search params mirroring anilist download
sort_val = options.get("sort")
status_val = options.get("status")
status_not_val = options.get("status_not")
genres_val = options.get("genres")
genres_not_val = options.get("genres_not")
tags_val = options.get("tags")
tags_not_val = options.get("tags_not")
media_format_val = options.get("media_format")
media_type_val = options.get("media_type")
season_val = options.get("season")
year_val = options.get("year")
if not search_result or not search_result.media:
feedback.warning(f"Could not find '{anime_title}' on AniList.")
search_params = MediaSearchParams(
query=options.get("title"),
page=options.get("page", 1),
per_page=options.get("per_page"),
sort=MediaSort(sort_val) if sort_val else None,
status_in=[MediaStatus(s) for s in status_val] if status_val else None,
status_not_in=[MediaStatus(s) for s in status_not_val]
if status_not_val
else None,
genre_in=[MediaGenre(g) for g in genres_val] if genres_val else None,
genre_not_in=[MediaGenre(g) for g in genres_not_val]
if genres_not_val
else None,
tag_in=[MediaTag(t) for t in tags_val] if tags_val else None,
tag_not_in=[MediaTag(t) for t in tags_not_val] if tags_not_val else None,
format_in=[MediaFormat(f) for f in media_format_val]
if media_format_val
else None,
type=MediaType(media_type_val) if media_type_val else None,
season=MediaSeason(season_val) if season_val else None,
seasonYear=int(year_val) if year_val else None,
popularity_greater=options.get("popularity_greater"),
popularity_lesser=options.get("popularity_lesser"),
averageScore_greater=options.get("score_greater"),
averageScore_lesser=options.get("score_lesser"),
startDate_greater=options.get("start_date_greater"),
startDate_lesser=options.get("start_date_lesser"),
endDate_greater=options.get("end_date_greater"),
endDate_lesser=options.get("end_date_lesser"),
on_list=options.get("on_list"),
)
with Progress() as progress:
progress.add_task("Searching AniList...", total=None)
search_result = media_api.search_media(search_params)
if not search_result or not search_result.media:
raise FastAnimeError("No anime found matching your search criteria.")
if options.get("yes"):
anime_to_queue = search_result.media
else:
choice_map: dict[str, MediaItem] = {
(item.title.english or item.title.romaji or f"ID: {item.id}"): item
for item in search_result.media
}
preview_command = None
if config.general.preview != "none":
from ..utils.preview import create_preview_context # type: ignore
with create_preview_context() as preview_ctx:
preview_command = preview_ctx.get_anime_preview(
list(choice_map.values()),
list(choice_map.keys()),
config,
)
selected_titles = selector.choose_multiple(
"Select anime to queue",
list(choice_map.keys()),
preview=preview_command,
)
else:
selected_titles = selector.choose_multiple(
"Select anime to queue", list(choice_map.keys())
)
if not selected_titles:
feedback.warning("No anime selected. Nothing queued.")
return
anime_to_queue = [choice_map[title] for title in selected_titles]
episode_range_str = options.get("episode_range")
total_queued = 0
for media_item in anime_to_queue:
available_episodes = [str(i + 1) for i in range(media_item.episodes or 0)]
if not available_episodes:
feedback.warning(
f"No episode information for '{media_item.title.english}', skipping."
)
continue
media_item = search_result.media[0]
available_episodes = [str(i + 1) for i in range(media_item.episodes or 0)]
episodes_to_queue = list(
parse_episode_range(episode_range, available_episodes)
)
try:
episodes_to_queue = list(
parse_episode_range(episode_range_str, available_episodes)
)
if not episodes_to_queue:
feedback.warning(
f"Episode range '{episode_range_str}' resulted in no episodes for '{media_item.title.english}'."
)
continue
queued_count = 0
for ep in episodes_to_queue:
if download_service.add_to_queue(media_item, ep):
queued_count += 1
queued_count = 0
for ep in episodes_to_queue:
if download_service.add_to_queue(media_item, ep):
queued_count += 1
feedback.success(
f"Successfully queued {queued_count} episodes for '{media_item.title.english}'."
)
total_queued += queued_count
feedback.success(
f"Queued {queued_count} episodes for '{media_item.title.english}'."
)
except (ValueError, IndexError) as e:
feedback.error(
f"Invalid episode range for '{media_item.title.english}': {e}"
)
except FastAnimeError as e:
feedback.error(f"Failed to queue '{anime_title}'", str(e))
except Exception as e:
feedback.error("An unexpected error occurred", str(e))
feedback.success(
f"Done. Total of {total_queued} episode(s) queued across all selections."
)
except FastAnimeError as e:
feedback.error("Queue command failed", str(e))
except Exception as e:
feedback.error("An unexpected error occurred", str(e))

View File

@@ -0,0 +1,3 @@
from .cmd import queue
__all__ = ["queue"]

View File

@@ -0,0 +1,26 @@
import click
from ...utils.lazyloader import LazyGroup
commands = {
"add": "add.add",
"list": "list.list_cmd",
"resume": "resume.resume",
"clear": "clear.clear_cmd",
}
@click.group(
cls=LazyGroup,
name="queue",
root="fastanime.cli.commands.queue.commands",
invoke_without_command=False,
help="Manage the download queue (add, list, resume, clear).",
short_help="Manage the download queue.",
lazy_subcommands=commands,
)
@click.pass_context
def queue(ctx: click.Context):
"""Queue management root command."""
# No-op root; subcommands are lazy-loaded
pass

View File

@@ -0,0 +1,213 @@
import click
from fastanime.core.config import AppConfig
from fastanime.core.exceptions import FastAnimeError
from fastanime.libs.media_api.types import (
MediaFormat,
MediaGenre,
MediaItem,
MediaSeason,
MediaSort,
MediaStatus,
MediaTag,
MediaType,
MediaYear,
)
@click.command(name="add", help="Add episodes to the background download queue.")
# Search/Filter options (mirrors 'fastanime anilist download')
@click.option("--title", "-t")
@click.option("--page", "-p", type=click.IntRange(min=1), default=1)
@click.option("--per-page", type=click.IntRange(min=1, max=50))
@click.option("--season", type=click.Choice([s.value for s in MediaSeason]))
@click.option(
"--status", "-S", multiple=True, type=click.Choice([s.value for s in MediaStatus])
)
@click.option(
"--status-not", multiple=True, type=click.Choice([s.value for s in MediaStatus])
)
@click.option("--sort", "-s", type=click.Choice([s.value for s in MediaSort]))
@click.option(
"--genres", "-g", multiple=True, type=click.Choice([g.value for g in MediaGenre])
)
@click.option(
"--genres-not", multiple=True, type=click.Choice([g.value for g in MediaGenre])
)
@click.option("--tags", "-T", multiple=True, type=click.Choice([t.value for t in MediaTag]))
@click.option("--tags-not", multiple=True, type=click.Choice([t.value for t in MediaTag]))
@click.option(
"--media-format",
"-f",
multiple=True,
type=click.Choice([f.value for f in MediaFormat]),
)
@click.option("--media-type", type=click.Choice([t.value for t in MediaType]))
@click.option("--year", "-y", type=click.Choice([y.value for y in MediaYear]))
@click.option("--popularity-greater", type=click.IntRange(min=0))
@click.option("--popularity-lesser", type=click.IntRange(min=0))
@click.option("--score-greater", type=click.IntRange(min=0, max=100))
@click.option("--score-lesser", type=click.IntRange(min=0, max=100))
@click.option("--start-date-greater", type=int)
@click.option("--start-date-lesser", type=int)
@click.option("--end-date-greater", type=int)
@click.option("--end-date-lesser", type=int)
@click.option("--on-list/--not-on-list", "-L/-no-L", type=bool, default=None)
# Queue-specific options
@click.option(
"--episode-range",
"-r",
required=True,
help="Range of episodes to queue (e.g., '1-10', '5', '8:12').",
)
@click.option(
"--yes",
"-Y",
is_flag=True,
help="Queue for all found anime without prompting for selection.",
)
@click.pass_obj
def add(config: AppConfig, **options):
from fastanime.cli.service.download.service import DownloadService
from fastanime.cli.service.feedback import FeedbackService
from fastanime.cli.service.registry import MediaRegistryService
from fastanime.cli.utils.parser import parse_episode_range
from fastanime.libs.media_api.params import MediaSearchParams
from fastanime.libs.media_api.api import create_api_client
from fastanime.libs.provider.anime.provider import create_provider
from fastanime.libs.selectors import create_selector
from rich.progress import Progress
feedback = FeedbackService(config)
selector = create_selector(config)
media_api = create_api_client(config.general.media_api, config)
provider = create_provider(config.general.provider)
registry = MediaRegistryService(config.general.media_api, config.media_registry)
download_service = DownloadService(config, registry, media_api, provider)
try:
# Build search params mirroring anilist download
sort_val = options.get("sort")
status_val = options.get("status")
status_not_val = options.get("status_not")
genres_val = options.get("genres")
genres_not_val = options.get("genres_not")
tags_val = options.get("tags")
tags_not_val = options.get("tags_not")
media_format_val = options.get("media_format")
media_type_val = options.get("media_type")
season_val = options.get("season")
year_val = options.get("year")
search_params = MediaSearchParams(
query=options.get("title"),
page=options.get("page", 1),
per_page=options.get("per_page"),
sort=MediaSort(sort_val) if sort_val else None,
status_in=[MediaStatus(s) for s in status_val] if status_val else None,
status_not_in=[MediaStatus(s) for s in status_not_val]
if status_not_val
else None,
genre_in=[MediaGenre(g) for g in genres_val] if genres_val else None,
genre_not_in=[MediaGenre(g) for g in genres_not_val]
if genres_not_val
else None,
tag_in=[MediaTag(t) for t in tags_val] if tags_val else None,
tag_not_in=[MediaTag(t) for t in tags_not_val] if tags_not_val else None,
format_in=[MediaFormat(f) for f in media_format_val]
if media_format_val
else None,
type=MediaType(media_type_val) if media_type_val else None,
season=MediaSeason(season_val) if season_val else None,
seasonYear=int(year_val) if year_val else None,
popularity_greater=options.get("popularity_greater"),
popularity_lesser=options.get("popularity_lesser"),
averageScore_greater=options.get("score_greater"),
averageScore_lesser=options.get("score_lesser"),
startDate_greater=options.get("start_date_greater"),
startDate_lesser=options.get("start_date_lesser"),
endDate_greater=options.get("end_date_greater"),
endDate_lesser=options.get("end_date_lesser"),
on_list=options.get("on_list"),
)
with Progress() as progress:
progress.add_task("Searching AniList...", total=None)
search_result = media_api.search_media(search_params)
if not search_result or not search_result.media:
raise FastAnimeError("No anime found matching your search criteria.")
if options.get("yes"):
anime_to_queue = search_result.media
else:
choice_map: dict[str, MediaItem] = {
(item.title.english or item.title.romaji or f"ID: {item.id}"): item
for item in search_result.media
}
preview_command = None
if config.general.preview != "none":
from ...utils.preview import create_preview_context # type: ignore
with create_preview_context() as preview_ctx:
preview_command = preview_ctx.get_anime_preview(
list(choice_map.values()),
list(choice_map.keys()),
config,
)
selected_titles = selector.choose_multiple(
"Select anime to queue",
list(choice_map.keys()),
preview=preview_command,
)
else:
selected_titles = selector.choose_multiple(
"Select anime to queue", list(choice_map.keys())
)
if not selected_titles:
feedback.warning("No anime selected. Nothing queued.")
return
anime_to_queue = [choice_map[title] for title in selected_titles]
episode_range_str = options.get("episode_range")
total_queued = 0
for media_item in anime_to_queue:
available_episodes = [str(i + 1) for i in range(media_item.episodes or 0)]
if not available_episodes:
feedback.warning(
f"No episode information for '{media_item.title.english}', skipping."
)
continue
try:
episodes_to_queue = list(
parse_episode_range(episode_range_str, available_episodes)
)
if not episodes_to_queue:
feedback.warning(
f"Episode range '{episode_range_str}' resulted in no episodes for '{media_item.title.english}'."
)
continue
queued_count = 0
for ep in episodes_to_queue:
if download_service.add_to_queue(media_item, ep):
queued_count += 1
total_queued += queued_count
feedback.success(
f"Queued {queued_count} episodes for '{media_item.title.english}'."
)
except (ValueError, IndexError) as e:
feedback.error(
f"Invalid episode range for '{media_item.title.english}': {e}"
)
feedback.success(
f"Done. Total of {total_queued} episode(s) queued across all selections."
)
except FastAnimeError as e:
feedback.error("Queue add failed", str(e))
except Exception as e:
feedback.error("An unexpected error occurred", str(e))

View File

@@ -0,0 +1,30 @@
import click
from fastanime.core.config import AppConfig
@click.command(name="clear", help="Clear queued items from the registry (QUEUED -> NOT_DOWNLOADED).")
@click.option("--force", is_flag=True, help="Do not prompt for confirmation.")
@click.pass_obj
def clear_cmd(config: AppConfig, force: bool):
from fastanime.cli.service.feedback import FeedbackService
from fastanime.cli.service.registry import MediaRegistryService
from fastanime.cli.service.registry.models import DownloadStatus
feedback = FeedbackService(config)
registry = MediaRegistryService(config.general.media_api, config.media_registry)
if not force and not click.confirm("This will clear all queued items. Continue?"):
feedback.info("Aborted.")
return
cleared = 0
queued = registry.get_episodes_by_download_status(DownloadStatus.QUEUED)
for media_id, ep in queued:
ok = registry.update_episode_download_status(
media_id=media_id,
episode_number=ep,
status=DownloadStatus.NOT_DOWNLOADED,
)
if ok:
cleared += 1
feedback.success(f"Cleared {cleared} queued episode(s).")

View File

@@ -0,0 +1,45 @@
import click
from fastanime.core.config import AppConfig
@click.command(name="list", help="List items in the download queue and their statuses.")
@click.option("--status", type=click.Choice(["queued", "downloading", "completed", "failed", "paused"]))
@click.pass_obj
def list_cmd(config: AppConfig, status: str | None):
from fastanime.cli.service.registry import MediaRegistryService
from fastanime.cli.service.registry.models import DownloadStatus
from fastanime.cli.service.feedback import FeedbackService
feedback = FeedbackService(config)
registry = MediaRegistryService(config.general.media_api, config.media_registry)
status_map = {
"queued": DownloadStatus.QUEUED,
"downloading": DownloadStatus.DOWNLOADING,
"completed": DownloadStatus.COMPLETED,
"failed": DownloadStatus.FAILED,
"paused": DownloadStatus.PAUSED,
}
if status:
target = status_map[status]
episodes = registry.get_episodes_by_download_status(target)
feedback.info(f"{len(episodes)} episode(s) with status {status}.")
for media_id, ep in episodes:
feedback.info(f"- media:{media_id} episode:{ep}")
else:
from rich.table import Table
from rich.console import Console
stats = registry.get_download_statistics()
table = Table(title="Queue Status")
table.add_column("Metric")
table.add_column("Value")
table.add_row("Queued", str(stats.get("queued", 0)))
table.add_row("Downloading", str(stats.get("downloading", 0)))
table.add_row("Completed", str(stats.get("downloaded", 0)))
table.add_row("Failed", str(stats.get("failed", 0)))
table.add_row("Paused", str(stats.get("paused", 0)))
console = Console()
console.print(table)

View File

@@ -0,0 +1,22 @@
import click
from fastanime.core.config import AppConfig
@click.command(name="resume", help="Submit any queued or in-progress downloads to the worker.")
@click.pass_obj
def resume(config: AppConfig):
from fastanime.cli.service.download.service import DownloadService
from fastanime.cli.service.feedback import FeedbackService
from fastanime.cli.service.registry import MediaRegistryService
from fastanime.libs.media_api.api import create_api_client
from fastanime.libs.provider.anime.provider import create_provider
feedback = FeedbackService(config)
media_api = create_api_client(config.general.media_api, config)
provider = create_provider(config.general.provider)
registry = MediaRegistryService(config.general.media_api, config.media_registry)
download_service = DownloadService(config, registry, media_api, provider)
download_service.start()
download_service.resume_unfinished_downloads()
feedback.success("Submitted queued downloads to background worker.")

View File

@@ -16,6 +16,7 @@ def worker(config: AppConfig):
from fastanime.cli.service.notification.service import NotificationService
from fastanime.cli.service.registry.service import MediaRegistryService
from fastanime.cli.service.worker.service import BackgroundWorkerService
from fastanime.cli.service.auth import AuthService
from fastanime.libs.media_api.api import create_api_client
from fastanime.libs.provider.anime.provider import create_provider
@@ -26,6 +27,13 @@ def worker(config: AppConfig):
# Instantiate services
media_api = create_api_client(config.general.media_api, config)
# Authenticate if credentials exist (enables notifications)
auth = AuthService(config.general.media_api)
if profile := auth.get_auth():
try:
media_api.authenticate(profile.token)
except Exception:
pass
provider = create_provider(config.general.provider)
registry = MediaRegistryService(config.general.media_api, config.media_registry)