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

feat: Improve progress display and episodes report (#213) #215

Merged
merged 1 commit into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion podcast_archiver/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{
"error": "bold dark_red",
"warning": "magenta",
"missing": "orange1",
"missing": "orange1 bold",
"completed": "bold dark_cyan",
"success": "dark_cyan",
}
Expand Down
3 changes: 1 addition & 2 deletions podcast_archiver/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
DOWNLOAD_CHUNK_SIZE = 256 * 1024
DEBUG_PARTIAL_SIZE = DOWNLOAD_CHUNK_SIZE * 4

MAX_TITLE_LENGTH = 84

MAX_TITLE_LENGTH = 120

DEFAULT_DATETIME_FORMAT = "%Y-%m-%d"
DEFAULT_ARCHIVE_DIRECTORY = pathlib.Path(".")
Expand Down
19 changes: 8 additions & 11 deletions podcast_archiver/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from podcast_archiver import constants
from podcast_archiver.enums import DownloadResult
from podcast_archiver.exceptions import NotCompleted
from podcast_archiver.logging import logger, rprint
from podcast_archiver.logging import logger
from podcast_archiver.session import session
from podcast_archiver.types import EpisodeResult
from podcast_archiver.utils import atomic_write
Expand All @@ -32,27 +32,24 @@ class DownloadJob:

def __call__(self) -> EpisodeResult:
try:
return self.run()
self.run()
result = DownloadResult.COMPLETED_SUCCESSFULLY
except NotCompleted:
res = EpisodeResult(self.episode, DownloadResult.ABORTED)
result = DownloadResult.ABORTED
except Exception as exc:
logger.error("Download failed: %s; %s", self.episode, exc)
logger.debug("Exception while downloading", exc_info=exc)
res = EpisodeResult(self.episode, DownloadResult.FAILED)
result = DownloadResult.FAILED

rprint(f"[error]✘ {res.result}:[/] {res.episode}")
return res
return EpisodeResult(self.episode, result)

def run(self) -> EpisodeResult:
def run(self) -> None:
self.target.parent.mkdir(parents=True, exist_ok=True)
logger.info("Downloading: %s", self.episode)
response = session.get_and_raise(self.episode.enclosure.href, stream=True)
with self.write_info_json(), atomic_write(self.target, mode="wb") as fp:
self.receive_data(fp, response)

logger.info("Completed: %s", self.episode)
rprint(f"[dark_cyan]✔ {DownloadResult.COMPLETED_SUCCESSFULLY}:[/] {self.episode}")
return EpisodeResult(self.episode, DownloadResult.COMPLETED_SUCCESSFULLY)

@property
def infojsonfile(self) -> Path:
Expand All @@ -64,7 +61,7 @@ def receive_data(self, fp: IO[bytes], response: Response) -> None:
max_bytes = self.max_download_bytes
for chunk in progress_manager.track(
response.iter_content(chunk_size=constants.DOWNLOAD_CHUNK_SIZE),
description=str(self.episode),
episode=self.episode,
total=total_size,
):
total_written += fp.write(chunk)
Expand Down
8 changes: 4 additions & 4 deletions podcast_archiver/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ def __str__(self) -> str:


class QueueCompletionType(StrEnum):
COMPLETED = "Archived all episodes"
FOUND_EXISTING = "Archive is up to date"
MAX_EPISODES = "Maximum episode count reached"
FAILED = "Failed"
COMPLETED = "Archived all episodes"
FOUND_EXISTING = "Archive is up to date"
MAX_EPISODES = "Maximum episode count reached"
FAILED = "Failed"


class DownloadResult(StrEnum):
Expand Down
14 changes: 9 additions & 5 deletions podcast_archiver/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,30 @@
import logging.config
import sys
from os import environ
from typing import Any
from typing import TYPE_CHECKING, Any

from rich.logging import RichHandler
from rich.text import Text

from podcast_archiver.console import console

if TYPE_CHECKING:
from rich.console import RenderableType

logger = logging.getLogger("podcast_archiver")


REDIRECT_VIA_LOGGING: bool = False


def rprint(msg: str, **kwargs: Any) -> None:
def rprint(*msg: RenderableType, **kwargs: Any) -> None:
if not REDIRECT_VIA_LOGGING:
console.print(msg, **kwargs)
console.print(*msg, **kwargs)
return

text = Text.from_markup(msg.strip()).plain.strip()
logger.info(text)
for m in msg:
if isinstance(m, Text):
logger.info(m.plain.strip())

Check warning on line 30 in podcast_archiver/logging.py

View check run for this annotation

Codecov / codecov/patch

podcast_archiver/logging.py#L30

Added line #L30 was not covered by tests


def is_interactive() -> bool:
Expand Down
21 changes: 19 additions & 2 deletions podcast_archiver/models/episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from functools import cached_property
from pathlib import Path
from typing import Annotated
from typing import TYPE_CHECKING, Annotated
from urllib.parse import urlparse

from pydantic import (
Expand All @@ -11,13 +11,18 @@
field_validator,
model_validator,
)
from rich.table import Table
from rich.text import Text

from podcast_archiver.constants import DEFAULT_DATETIME_FORMAT, MAX_TITLE_LENGTH
from podcast_archiver.exceptions import MissingDownloadUrl
from podcast_archiver.models.field_types import FallbackToNone, LenientDatetime
from podcast_archiver.models.misc import Link
from podcast_archiver.utils import get_generic_extension, truncate

if TYPE_CHECKING:
from rich.console import RenderableType


class Chapter(BaseModel):
start: str
Expand All @@ -36,11 +41,23 @@ class BaseEpisode(BaseModel):
published_time: LenientDatetime = Field(alias="published_parsed", title="episode.published_time")

original_filename: str = Field(default="", repr=False, title="episode.original_filename")
original_title: str = Field(default="Untitled Episode", repr=False, validation_alias="title")

guid: str = Field(default=None, alias="id") # type: ignore[assignment]

def __str__(self) -> str:
return f"{self.title} ({self.published_time.strftime(DEFAULT_DATETIME_FORMAT)})"
return f"{self.published_time.strftime(DEFAULT_DATETIME_FORMAT)} {self.title}"

def __rich__(self) -> RenderableType:
"""Makes the Progress class itself renderable."""
grid = Table.grid()
grid.add_column(style="dim")
grid.add_column()
grid.add_row(
Text(f"{self.published_time:%Y-%m-%d} "),
Text(self.title, overflow="ellipsis", no_wrap=True),
)
return grid

@field_validator("title", mode="after")
@classmethod
Expand Down
32 changes: 24 additions & 8 deletions podcast_archiver/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@
from threading import Event
from typing import TYPE_CHECKING

from rich.console import Group
from rich.text import Text

from podcast_archiver import constants
from podcast_archiver.config import Settings
from podcast_archiver.database import get_database
from podcast_archiver.download import DownloadJob
from podcast_archiver.enums import DownloadResult, QueueCompletionType
from podcast_archiver.logging import logger, rprint
from podcast_archiver.models.feed import Feed, FeedInfo
from podcast_archiver.types import EpisodeResult, EpisodeResultsList, FutureEpisodeResult, ProcessingResult
from podcast_archiver.types import (
EpisodeResult,
EpisodeResultsList,
FutureEpisodeResult,
ProcessingResult,
)
from podcast_archiver.utils import FilenameFormatter, handle_feed_request
from podcast_archiver.utils.pretty_printing import PrettyPrintEpisodeRange

Expand Down Expand Up @@ -48,7 +56,7 @@ def process(self, url: str) -> ProcessingResult:
return ProcessingResult(feed=None, tombstone=QueueCompletionType.FAILED)

result = self.process_feed(feed=feed)
rprint(f"[completed]{result.tombstone}[/]")
rprint(result.tombstone, style="completed")
return result

def load_feed(self, url: str, known_feeds: dict[str, FeedInfo]) -> Feed | None:
Expand Down Expand Up @@ -116,11 +124,11 @@ def process_feed(self, feed: Feed) -> ProcessingResult:
success, failures = self._handle_results(results)
return ProcessingResult(feed=feed, success=success, failures=failures, tombstone=tombstone)

def _enqueue_episode(self, episode: BaseEpisode, feed_info: FeedInfo) -> FutureEpisodeResult | EpisodeResult:
def _enqueue_episode(self, episode: BaseEpisode, feed_info: FeedInfo) -> FutureEpisodeResult:
target = self.filename_formatter.format(episode=episode, feed_info=feed_info)
if self._does_already_exist(episode, target=target):
result = DownloadResult.ALREADY_EXISTS
return EpisodeResult(episode, result)
return EpisodeResult(episode, result, is_eager=True)

logger.debug("Queueing download for %r", episode)
return self.pool_executor.submit(
Expand All @@ -139,12 +147,20 @@ def _handle_results(self, episode_results: EpisodeResultsList) -> tuple[int, int
if isinstance(episode_result, Future):
episode_result = episode_result.result()

if episode_result.result not in DownloadResult.successful():
failures += 1
if episode_result.is_eager:
success += 1
self.database.add(episode_result.episode)
continue

self.database.add(episode_result.episode)
success += 1
if episode_result.result in DownloadResult.successful():
prefix = Text(f"✔ {episode_result.result} ", style="success", end=" ")
success += 1
self.database.add(episode_result.episode)
else:
prefix = Text(f"✖ {episode_result.result} ", style="error", end=" ")
failures += 1

rprint(Group(prefix, episode_result.episode))
return success, failures

def shutdown(self) -> None:
Expand Down
1 change: 1 addition & 0 deletions podcast_archiver/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
class EpisodeResult:
episode: BaseEpisode
result: DownloadResult
is_eager: bool = False


@dataclass(slots=True, frozen=True)
Expand Down
90 changes: 51 additions & 39 deletions podcast_archiver/utils/pretty_printing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,81 +3,93 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from podcast_archiver.enums import DownloadResult
from rich.table import Table
from rich.text import Text

from podcast_archiver.logging import rprint

if TYPE_CHECKING:
from podcast_archiver.models.episode import BaseEpisode


MSG_1 = """\
{prefix} {first}"""

MSG_2 = """\
{prefix} {first}
{last}"""
from rich.console import ConsoleRenderable

MSG_MORE = """\
{prefix} {first}
[dim]...[/]
{last}"""
from podcast_archiver.models.episode import BaseEpisode


@dataclass(slots=True)
class _ValPair:
prefix: str
style: str
first: BaseEpisode | None = None
last: BaseEpisode | None = None
length: int = 0

def populate(self, obj: BaseEpisode) -> None:
if not self.first:
self.first = obj
else:
self.last = obj
self.last = obj
self.length += 1

def emit(self) -> str | None:
msg = None
if self.length == 1:
msg = MSG_1.format(prefix=self.prefix, first=self.first, last=self.last)
if self.length == 2:
msg = MSG_2.format(prefix=self.prefix, first=self.first, last=self.last)
elif self.length > 2:
msg = MSG_MORE.format(prefix=self.prefix, first=self.first, last=self.last)

self.first = None
self.last = None
self.length = 0
return msg
def emit(self) -> _ValPair | None:
if not self.first:
return None
return self


class PrettyPrintEpisodeRange:
_existing: _ValPair
_missing: _ValPair
_last_populated: _ValPair
pairs: list[_ValPair]

__slots__ = ("_existing", "_missing", "_last_populated")
__slots__ = ("_existing", "_missing", "_last_populated", "pairs")

def __init__(self) -> None:
self._existing = _ValPair(prefix=f"[success]✔ {DownloadResult.ALREADY_EXISTS}:[/]")
self._missing = self._last_populated = _ValPair(prefix="[missing]✘ Missing:[/]")
self._existing = _ValPair(prefix="✔ Present", style="success")
self._missing = self._last_populated = _ValPair("✘ Missing", style="missing")
self.pairs = []

def __enter__(self) -> PrettyPrintEpisodeRange:
return self

def __exit__(self, *args: Any) -> None:
if msg := self._last_populated.emit():
rprint(msg)
if emitted := self._last_populated.emit():
self.pairs.append(emitted)
rprint(self)

def _update_state(self, obj: BaseEpisode, to_populate: _ValPair, to_emit: _ValPair) -> None:
def _update_state(self, obj: BaseEpisode, to_populate: _ValPair, to_emit: _ValPair) -> _ValPair:
self._last_populated = to_populate
to_populate.populate(obj)
if msg := to_emit.emit():
rprint(msg)
if emitted := to_emit.emit():
self.pairs.append(emitted)
return _ValPair(prefix=to_emit.prefix, style=to_emit.style)
return to_emit

def update(self, exists: bool, obj: BaseEpisode) -> None:
if exists:
self._update_state(obj, to_populate=self._existing, to_emit=self._missing)
self._missing = self._update_state(obj, to_populate=self._existing, to_emit=self._missing)
else:
self._update_state(obj, to_populate=self._missing, to_emit=self._existing)
self._existing = self._update_state(obj, to_populate=self._missing, to_emit=self._existing)

def __rich__(self) -> ConsoleRenderable | str:
if not self.pairs:
return ""
grid = Table.grid()
grid.add_column()
grid.add_column()
grid.add_column()
for pair in self.pairs:
grid.add_row(
Text(pair.prefix, style=pair.style),
Text("╶┬╴" if pair.length > 1 else " ", style=pair.style),
pair.first,
)
if pair.length == 1:
continue
if pair.length > 1:
grid.add_row("", " │ ", "", style=pair.style)
grid.add_row(
"",
Text(" ╰╴" if pair.last else " ", style=pair.style),
pair.last,
)
grid.add_row("", "", "")
return grid
Loading
Loading