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

Merged
merged 1 commit into from
Dec 7, 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
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 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 @@
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
Loading