diff --git a/groove/console.py b/groove/console.py index 3d592c6..48c231a 100644 --- a/groove/console.py +++ b/groove/console.py @@ -110,6 +110,13 @@ class Console(_Console): """ super().print(txt, overflow=self._overflow, **kwargs) + def debug(self, txt: str, **kwargs) -> None: + """ + Print text to the console with the current theme's debug style applied, if debugging is enabled. + """ + if os.environ.get('DEBUG', None): + self.print(dedent(txt), style='debug') + def error(self, txt: str, **kwargs) -> None: """ Print text to the console with the current theme's error style applied. diff --git a/groove/media/__init__.py b/groove/media/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/groove/media/transcoder.py b/groove/media/transcoder.py new file mode 100644 index 0000000..7e5ea85 --- /dev/null +++ b/groove/media/transcoder.py @@ -0,0 +1,139 @@ +import asyncio +import logging +import os +import subprocess + +from typing import Union, List + +import rich.repr + +from rich.console import Console +from rich.progress import ( + Progress, + TextColumn, + BarColumn, + SpinnerColumn, + TimeRemainingColumn +) + +import groove.path + +from groove.exceptions import ConfigurationError + + +@rich.repr.auto(angular=True) +class Transcoder: + """ + SYNOPSIS + + USAGE + + ARGS + + EXAMPLES + + INSTANCE ATTRIBUTES + """ + + def __init__(self, console: Union[Console, None] = None) -> None: + self.console = console or Console() + self._transcoded = 0 + self._processed = 0 + self._total = 0 + + def transcode(self, sources: List) -> int: + """ + Transcode the list of source files + """ + count = len(sources) + + if not os.environ.get('TRANSCODER', None): + raise ConfigurationError("Cannot transcode tracks without a TRANSCODR defined in your environment.") + + cache = groove.path.cache_root() + if not cache.exists(): + cache.mkdir() + + async def _do_transcode(progress, task_id): + tasks = set() + for relpath in sources: + self._total += 1 + progress.update(task_id, total=self._total) + tasks.add(asyncio.create_task(self._transcode_one_track(relpath, progress, task_id))) + progress.start_task(task_id) + + progress = Progress( + TimeRemainingColumn(compact=True, elapsed_when_finished=True), + BarColumn(bar_width=15), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%", justify="left"), + TextColumn("[dim]|"), + TextColumn("[title]{task.total:-6d}[/title] [b]total", justify="right"), + TextColumn("[dim]|"), + TextColumn("[title]{task.fields[transcoded]:-6d}[/title] [b]new", justify="right"), + TextColumn("[dim]|"), + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=self.console, + ) + with progress: + task_id = progress.add_task( + f"[bright]Transcoding [link]{count} tracks[/link]...", + transcoded=0, + total=0, + start=False + ) + asyncio.run(_do_transcode(progress, task_id)) + progress.update( + task_id, + transcoded=self._transcoded, + completed=self._total, + description=f"[bright]Transcode of [link]{count} tracks[/link] complete!", + ) + + def _get_or_create_cache_dir(self, relpath): + cached_path = groove.path.transcoded_media(relpath) + cached_path.parent.mkdir(parents=True, exist_ok=True) + return cached_path + + def _run_transcoder(self, infile, outfile): + cmd = [] + for part in os.environ['TRANSCODER'].split(): + if part == 'INFILE': + cmd.append(str(infile)) + elif part == 'OUTFILE': + cmd.append(str(outfile)) + else: + cmd.append(part) + + output = '' + try: + output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + self.console.error(f"Transcoder failed with status {e.returncode}: {' '.join(cmd)}") + self.console.error(output) + + async def _transcode_one_track(self, relpath, progress, task_id): + """ + Transcode one track, if it isn't already cached. + """ + self._processed += 1 + + source_path = groove.path.media(relpath) + if not source_path.exists(): + logging.error(f"Source does not exist: [link]{source_path}[/link].") + return + + cached_path = self._get_or_create_cache_dir(relpath) + if cached_path.exists(): + self.console.debug(f"Skipping existing [link]{cached_path}[/link].") + return + + self.console.debug(f"Transcoding [link]{cached_path}[/link]") + self._run_transcoder(source_path, cached_path) + + progress.update( + task_id, + transcoded=self._transcoded, + processed=self._processed, + description=f"[bright]Transcoded [link]{relpath}[/link]", + ) diff --git a/groove/path.py b/groove/path.py index ed05f66..013fdff 100644 --- a/groove/path.py +++ b/groove/path.py @@ -32,10 +32,6 @@ def cache_root(): if not path: raise ConfigurationError(f"CACHE_ROOT is not defined in your environment.\n\n{_setup_hint}") path = Path(path).expanduser() - if not path.exists() or not path.is_dir(): - raise ConfigurationError( - "The cache_root directory (CACHE_ROOT) doesn't exist, or isn't a directory.\n\n{_setup_hint}" - ) logging.debug(f"Media cache root is {path}") return path @@ -46,7 +42,7 @@ def media(relpath): def transcoded_media(relpath): - path = cache_root() / Path(relpath) + path = cache_root() / Path(relpath + '.webm') return path diff --git a/groove/shell/interactive_shell.py b/groove/shell/interactive_shell.py index 9ab19d7..f164c18 100644 --- a/groove/shell/interactive_shell.py +++ b/groove/shell/interactive_shell.py @@ -2,6 +2,7 @@ from slugify import slugify from groove.db.manager import database_manager from groove.media.scanner import MediaScanner +from groove.media.transcoder import Transcoder from groove.shell.base import BasePrompt, command from groove.exceptions import InvalidPathError from groove import db @@ -90,6 +91,26 @@ class InteractiveShell(BasePrompt): return True scanner.scan() + @command(usage=""" + [title]TRANSCODING[/title] + + Groove on Demand will stream audio to web clients in the native format of your source media files, but for maximum + portability, performance, and interoperability with reverse proxies, it's a good idea to transcode to .webm first. + Use the [b]transcode[/b] command to cache transcoded copies of every track currently in a playlist that isn't + already in .webm format. Existing cache entries will be skipped. See also [b]cache[/b]. + + [title]USAGE[/title] + + [link]> transcode[/link] + """) + def transcode(self, parts): + """ + Run the transcoder. + """ + tracks = self.manager.session.query(db.track).filter(db.entry.c.track_id == db.track.c.id).all() + transcoder = Transcoder(console=self.console) + transcoder.transcode([track['relpath'] for track in tracks]) + @command(""" [title]LISTS FOR THE LIST LOVER[/title] diff --git a/groove/static/themes/blue_train/console.cfg b/groove/static/themes/blue_train/console.cfg index 1e5c724..d5163ae 100644 --- a/groove/static/themes/blue_train/console.cfg +++ b/groove/static/themes/blue_train/console.cfg @@ -18,6 +18,7 @@ help = #999999 background = #001321 info = #88FF88 +debug = #888888 error = #FF8888 danger = #FF8888