Skip to content

Commit

Permalink
feat: Improve progress display and episodes report (#213)
Browse files Browse the repository at this point in the history
[no-bump]
  • Loading branch information
janw authored Dec 7, 2024
1 parent 1ac699d commit 73a1715
Show file tree
Hide file tree
Showing 22 changed files with 512 additions and 260 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.[/]")
self.processor.shutdown()
progress_manager.stop()
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
43 changes: 33 additions & 10 deletions podcast_archiver/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from threading import Lock
from typing import TYPE_CHECKING, Iterator
from typing import TYPE_CHECKING, Iterator, Literal

from podcast_archiver import constants
from podcast_archiver.logging import logger

if TYPE_CHECKING:
Expand All @@ -33,6 +35,15 @@ class EpisodeInDb:


class BaseDatabase:
filename: str
ignore_existing: bool

__slots__ = ("filename", "ignore_existing")

def __init__(self, filename: str, ignore_existing: bool = False) -> None:
self.filename = filename
self.ignore_existing = ignore_existing

@abstractmethod
def add(self, episode: BaseEpisode) -> None:
pass # pragma: no cover
Expand All @@ -51,19 +62,20 @@ def exists(self, episode: BaseEpisode) -> EpisodeInDb | None:


class Database(BaseDatabase):
filename: str
ignore_existing: bool
lock: Lock
conn: sqlite3.Connection

lock = Lock()
__slots__ = ("lock", "conn")

def __init__(self, filename: str, ignore_existing: bool) -> None:
self.filename = filename
self.ignore_existing = ignore_existing
super().__init__(filename=filename, ignore_existing=ignore_existing)
self.lock = Lock()
self.conn = sqlite3.connect(self.filename, detect_types=sqlite3.PARSE_DECLTYPES)
self.migrate()

@contextmanager
def get_conn(self) -> Iterator[sqlite3.Connection]:
with self.lock, sqlite3.connect(self.filename, detect_types=sqlite3.PARSE_DECLTYPES) as conn:
with self.lock, self.conn as conn:
conn.row_factory = sqlite3.Row
yield conn

Expand All @@ -74,12 +86,12 @@ def migrate(self) -> None:
"""\
CREATE TABLE IF NOT EXISTS episodes(
guid TEXT UNIQUE NOT NULL,
title TEXT,
length UNSIGNED BIG INT,
published_time TIMESTAMP
title TEXT
)"""
)

# NOTE: This is is a rudimentary migration system. It's not perfect but it's
# good enough for now, and does not require additional dependencies.
self._add_column_if_missing(
"length",
"ALTER TABLE episodes ADD COLUMN length UNSIGNED BIG INT",
Expand Down Expand Up @@ -127,3 +139,14 @@ def exists(self, episode: BaseEpisode) -> EpisodeInDb | None:
)
match = result.fetchone()
return EpisodeInDb(**match) if match else None


def get_database(path: Path | Literal[":memory:"] | None, ignore_existing: bool = False) -> Database:
if path is None:
db_path = constants.DEFAULT_DATABASE_FILENAME
elif isinstance(path, Path) and path.is_dir():
db_path = str(path / constants.DEFAULT_DATABASE_FILENAME)
else:
db_path = str(path)

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
Loading

0 comments on commit 73a1715

Please sign in to comment.