Skip to content

Commit

Permalink
feat: Improve progress display and episodes report
Browse files Browse the repository at this point in the history
  • Loading branch information
janw committed Dec 7, 2024
1 parent 1ac699d commit ce93ce9
Show file tree
Hide file tree
Showing 20 changed files with 476 additions and 243 deletions.
260 changes: 129 additions & 131 deletions .assets/podcast-archiver-help.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 1 addition & 2 deletions hack/rich-codex.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
#!/bin/sh

export FORCE_COLOR="1"
export TERMINAL_WIDTH="140"
export TERMINAL_THEME=MONOKAI
export COLUMNS="140"
export CREATED_FILES="created.txt"
export DELETED_FILES="deleted.txt"
export NO_CONFIRM="true"
Expand Down
15 changes: 9 additions & 6 deletions podcast_archiver/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@
import xml.etree.ElementTree as etree
from typing import TYPE_CHECKING, Any

from podcast_archiver.config import Settings
from podcast_archiver.logging import logger, rprint
from podcast_archiver.processor import FeedProcessor
from podcast_archiver.utils.progress import progress_manager

if TYPE_CHECKING:
from pathlib import Path

import rich_click as click

from podcast_archiver.config import Settings
from podcast_archiver.database import BaseDatabase


class PodcastArchiver:
settings: Settings
feeds: list[str]

def __init__(self, settings: Settings):
self.settings = settings
self.processor = FeedProcessor(settings=self.settings)
def __init__(self, settings: Settings | None = None, database: BaseDatabase | None = None):
self.settings = settings or Settings()
self.processor = FeedProcessor(settings=self.settings, database=database)

logger.debug("Initializing with settings: %s", settings)

Expand All @@ -35,8 +37,9 @@ def __init__(self, settings: Settings):
def register_cleanup(self, ctx: click.RichContext) -> None:
def _cleanup(signum: int, *args: Any) -> None:
logger.debug("Signal %s received", signum)
rprint("[error]Terminating.[/]")
rprint("[error]Terminating.[/]")

Check warning on line 40 in podcast_archiver/base.py

View check run for this annotation

Codecov / codecov/patch

podcast_archiver/base.py#L40

Added line #L40 was not covered by tests
self.processor.shutdown()
progress_manager.stop()

Check warning on line 42 in podcast_archiver/base.py

View check run for this annotation

Codecov / codecov/patch

podcast_archiver/base.py#L42

Added line #L42 was not covered by tests
ctx.close()
sys.exit(0)

Expand Down Expand Up @@ -64,5 +67,5 @@ def run(self) -> int:
result = self.processor.process(url)
failures += result.failures

rprint("\n[bar.finished]Done.[/]\n")
rprint("\n[completed]Done.[/]\n")
return failures
5 changes: 2 additions & 3 deletions podcast_archiver/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from podcast_archiver import constants
from podcast_archiver.base import PodcastArchiver
from podcast_archiver.config import Settings, in_ci
from podcast_archiver.console import console
from podcast_archiver.exceptions import InvalidSettings
from podcast_archiver.logging import configure_logging, rprint

Expand Down Expand Up @@ -117,9 +118,7 @@ def generate_default_config(ctx: click.Context, param: click.Parameter, value: b


@click.command(
context_settings={
"auto_envvar_prefix": constants.ENVVAR_PREFIX,
},
context_settings={"auto_envvar_prefix": constants.ENVVAR_PREFIX, "rich_console": console},
help="Archive all of your favorite podcasts",
)
@click.help_option("-h", "--help")
Expand Down
4 changes: 2 additions & 2 deletions podcast_archiver/compat.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import sys

if sys.version_info >= (3, 11):
if sys.version_info >= (3, 11): # pragma: no-cover-lt-311
from datetime import UTC
else:
else: # pragma: no-cover-gte-311
from datetime import timezone

UTC = timezone.utc
14 changes: 0 additions & 14 deletions podcast_archiver/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@

from podcast_archiver import __version__ as version
from podcast_archiver import constants
from podcast_archiver.database import BaseDatabase, Database, DummyDatabase
from podcast_archiver.exceptions import InvalidSettings
from podcast_archiver.logging import rprint
from podcast_archiver.utils import get_field_titles
Expand Down Expand Up @@ -229,16 +228,3 @@ def generate_default_config(cls, file: IO[Text] | None = None) -> None:
else:
with file:
file.write(contents)

def get_database(self) -> BaseDatabase:
if getenv("TESTING", "0").lower() in ("1", "true"):
return DummyDatabase()

if self.database:
db_path = str(self.database)
elif self.config:
db_path = str(self.config.parent / constants.DEFAULT_DATABASE_FILENAME)
else:
db_path = constants.DEFAULT_DATABASE_FILENAME

return Database(filename=db_path, ignore_existing=self.ignore_database)
15 changes: 15 additions & 0 deletions podcast_archiver/console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from __future__ import annotations

from rich.console import Console
from rich.theme import Theme

_theme = Theme(
{
"error": "bold dark_red",
"warning": "magenta",
"missing": "orange1",
"completed": "bold dark_cyan",
"success": "dark_cyan",
}
)
console = Console(theme=_theme)
2 changes: 1 addition & 1 deletion podcast_archiver/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
DOWNLOAD_CHUNK_SIZE = 256 * 1024
DEBUG_PARTIAL_SIZE = DOWNLOAD_CHUNK_SIZE * 4

MAX_TITLE_LENGTH = 96
MAX_TITLE_LENGTH = 84


DEFAULT_DATETIME_FORMAT = "%Y-%m-%d"
Expand Down
14 changes: 14 additions & 0 deletions podcast_archiver/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
from threading import Lock
from typing import TYPE_CHECKING, Iterator

from podcast_archiver import constants
from podcast_archiver.logging import logger

if TYPE_CHECKING:
from pathlib import Path

from podcast_archiver.models.episode import BaseEpisode


Expand Down Expand Up @@ -127,3 +130,14 @@ def exists(self, episode: BaseEpisode) -> EpisodeInDb | None:
)
match = result.fetchone()
return EpisodeInDb(**match) if match else None


def get_database(path: Path | None, ignore_existing: bool = False) -> BaseDatabase:
if path is None:
db_path = constants.DEFAULT_DATABASE_FILENAME
elif path.is_dir():
db_path = str(path / constants.DEFAULT_DATABASE_FILENAME)
else:
db_path = str(constants.DEFAULT_DATABASE_FILENAME)

Check warning on line 141 in podcast_archiver/database.py

View check run for this annotation

Codecov / codecov/patch

podcast_archiver/database.py#L141

Added line #L141 was not covered by tests

return Database(filename=db_path, ignore_existing=ignore_existing)
15 changes: 10 additions & 5 deletions podcast_archiver/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
from podcast_archiver import constants
from podcast_archiver.enums import DownloadResult
from podcast_archiver.exceptions import NotCompleted
from podcast_archiver.logging import logger, wrapped_tqdm
from podcast_archiver.logging import logger, rprint
from podcast_archiver.session import session
from podcast_archiver.types import EpisodeResult
from podcast_archiver.utils import atomic_write
from podcast_archiver.utils.progress import progress_manager

if TYPE_CHECKING:
from pathlib import Path
Expand All @@ -33,11 +34,14 @@ def __call__(self) -> EpisodeResult:
try:
return self.run()
except NotCompleted:
return EpisodeResult(self.episode, DownloadResult.ABORTED)
res = EpisodeResult(self.episode, DownloadResult.ABORTED)
except Exception as exc:
logger.error("Download failed: %s; %s", self.episode, exc)
logger.debug("Exception while downloading", exc_info=exc)
return EpisodeResult(self.episode, DownloadResult.FAILED)
res = EpisodeResult(self.episode, DownloadResult.FAILED)

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

def run(self) -> EpisodeResult:
self.target.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -47,6 +51,7 @@ def run(self) -> EpisodeResult:
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
Expand All @@ -57,9 +62,9 @@ def receive_data(self, fp: IO[bytes], response: Response) -> None:
total_size = int(response.headers.get("content-length", "0"))
total_written = 0
max_bytes = self.max_download_bytes
for chunk in wrapped_tqdm(
for chunk in progress_manager.track(
response.iter_content(chunk_size=constants.DOWNLOAD_CHUNK_SIZE),
desc=str(self.episode),
description=str(self.episode),
total=total_size,
):
total_written += fp.write(chunk)
Expand Down
6 changes: 3 additions & 3 deletions podcast_archiver/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ class QueueCompletionType(StrEnum):


class DownloadResult(StrEnum):
ALREADY_EXISTS = "Exists"
COMPLETED_SUCCESSFULLY = "Completed"
FAILED = "Failed"
ALREADY_EXISTS = "Present"
COMPLETED_SUCCESSFULLY = "Archived"
FAILED = " Failed"
ABORTED = "Aborted"

@classmethod
Expand Down
37 changes: 8 additions & 29 deletions podcast_archiver/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,58 +4,37 @@
import logging.config
import sys
from os import environ
from typing import Any, Generator, Iterable
from typing import Any

from rich import print as _print
from rich.logging import RichHandler
from rich.text import Text
from tqdm import tqdm
from tqdm.contrib.logging import logging_redirect_tqdm

from podcast_archiver.console import console

logger = logging.getLogger("podcast_archiver")


_REDIRECT_VIA_TQDM: bool = False
_REDIRECT_VIA_LOGGING: bool = False
REDIRECT_VIA_LOGGING: bool = False


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

text = Text.from_markup(msg.strip()).plain.strip()
logger.info(text)


def wrapped_tqdm(iterable: Iterable[bytes], desc: str, total: int) -> Generator[bytes, None, None]:
if _REDIRECT_VIA_LOGGING:
yield from iterable
return

with (
logging_redirect_tqdm(),
tqdm(desc=desc, total=total, unit_scale=True, unit="B") as progress,
):
global _REDIRECT_VIA_TQDM
_REDIRECT_VIA_TQDM = True
try:
for chunk in iterable:
progress.update(len(chunk))
yield chunk
finally:
_REDIRECT_VIA_TQDM = False


def is_interactive() -> bool:
return sys.stdout.isatty() and environ.get("TERM", "").lower() not in ("dumb", "unknown")


def configure_level(verbosity: int, quiet: bool) -> int:
global _REDIRECT_VIA_LOGGING
global REDIRECT_VIA_LOGGING
interactive = is_interactive()
if not interactive or quiet or verbosity > 0:
_REDIRECT_VIA_LOGGING = True
REDIRECT_VIA_LOGGING = True

if verbosity > 1 and not quiet:
return logging.DEBUG
Expand Down
51 changes: 30 additions & 21 deletions podcast_archiver/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

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.utils import FilenameFormatter, handle_feed_request
from podcast_archiver.utils.pretty_printing import PrettyPrintEpisodeRange

if TYPE_CHECKING:
from pathlib import Path
Expand All @@ -21,6 +23,15 @@


class FeedProcessor:
__slots__ = (
"settings",
"database",
"filename_formatter",
"pool_executor",
"stop_event",
"known_feeds",
)

settings: Settings
database: BaseDatabase
filename_formatter: FilenameFormatter
Expand All @@ -30,10 +41,11 @@ class FeedProcessor:

known_feeds: dict[str, FeedInfo]

def __init__(self, settings: Settings | None = None) -> None:
def __init__(self, settings: Settings | None = None, database: BaseDatabase | None = None) -> None:
self.settings = settings or Settings()
database_path = self.settings.database or (self.settings.config.parent if self.settings.config else None)
self.database = database or get_database(database_path, ignore_existing=self.settings.ignore_database)
self.filename_formatter = FilenameFormatter(self.settings)
self.database = self.settings.get_database()
self.pool_executor = ThreadPoolExecutor(max_workers=self.settings.concurrency)
self.stop_event = Event()
self.known_feeds = {}
Expand All @@ -43,8 +55,7 @@ def process(self, url: str) -> ProcessingResult:
return ProcessingResult(feed=None, tombstone=QueueCompletionType.FAILED)

result = self.process_feed(feed=feed)

rprint(f"\n[bar.finished]✔ {result.tombstone} for: {feed}[/]")
rprint(f"[completed]{result.tombstone}[/]")
return result

def load_feed(self, url: str, known_feeds: dict[str, FeedInfo]) -> Feed | None:
Expand Down Expand Up @@ -92,32 +103,30 @@ def _does_already_exist(self, episode: BaseEpisode, *, target: Path) -> bool:
return True

def process_feed(self, feed: Feed) -> ProcessingResult:
rprint(f"\n[bold bright_magenta]Downloading archive for: {feed}[/]\n")
rprint(f"\n[bold bright_magenta]Archiving: {feed}[/]\n")
tombstone = QueueCompletionType.COMPLETED
results: EpisodeResultsList = []
for idx, episode in enumerate(feed.episodes, 1):
if episode is None:
logger.debug("Skipping invalid episode at idx %s", idx)
continue
if (enqueued := self._enqueue_episode(episode, feed.info)) is None:
tombstone = QueueCompletionType.FOUND_EXISTING
break

results.append(enqueued)

if (max_count := self.settings.maximum_episode_count) and idx == max_count:
logger.debug("Reached requested maximum episode count of %s", max_count)
tombstone = QueueCompletionType.MAX_EPISODES
break
with PrettyPrintEpisodeRange() as pretty_range:
for idx, episode in enumerate(feed.episodes, 1):
if episode is None:
logger.debug("Skipping invalid episode at idx %s", idx)
continue

Check warning on line 113 in podcast_archiver/processor.py

View check run for this annotation

Codecov / codecov/patch

podcast_archiver/processor.py#L112-L113

Added lines #L112 - L113 were not covered by tests
enqueued = self._enqueue_episode(episode, feed.info)
pretty_range.update(isinstance(enqueued, EpisodeResult), episode)
results.append(enqueued)

if (max_count := self.settings.maximum_episode_count) and idx == max_count:
logger.debug("Reached requested maximum episode count of %s", max_count)
tombstone = QueueCompletionType.MAX_EPISODES
break

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 | None:
def _enqueue_episode(self, episode: BaseEpisode, feed_info: FeedInfo) -> FutureEpisodeResult | EpisodeResult:
target = self.filename_formatter.format(episode=episode, feed_info=feed_info)
if self._does_already_exist(episode, target=target):
result = DownloadResult.ALREADY_EXISTS
rprint(f"[bar.finished]✔ {result}: {episode}[/]")
return EpisodeResult(episode, result)

logger.debug("Queueing download for %r", episode)
Expand Down
File renamed without changes.
Loading

0 comments on commit ce93ce9

Please sign in to comment.