def print_meta(video: MappedVideo, stream: TextIO = sys.stdout) -> None: with StdOutOverride(stream): def print_separator(text: Optional[str] = None, fat: bool = False) -> None: columns = shutil.get_terminal_size().columns sep = "━" if fat else "─" if not text: print(sep * columns) else: sep_len = (columns - len(text) - 2) padding = sep_len // 2 printt(sep * padding) printt(" ", text, " ", bold=fat) printtln(sep * (padding + (sep_len % 2))) print_separator("Playing now", fat=True) printt(_(" Title: ")) printtln(video.title, bold=True) printt(_("In playlist(s): ")) printtln(", ".join(v.name for v in video.playlists), bold=True) description = video.description if description is not None: columns = shutil.get_terminal_size().columns lines = description.splitlines() print_separator(_("Video description")) for line in lines: print(wrap.fill(line, width=columns)) print_separator(fat=True) print()
def run(self) -> None: selectable = VideoSelection(config.tui.alphabet, self.videos) printer = TablePrinter() printer.filter = ["key", *config.ytcc.video_attrs] while True: remaining_tags = list(selectable.keys()) # Clear display and set cursor to (1,1). Allows scrolling back in some terminals terminal.clear_screen() printer.print(selectable) tag, hook_triggered = self.command_line(remaining_tags) video = selectable.get(tag) if video is None and not hook_triggered: break if video is not None: if self.action is Action.MARK_WATCHED: self.core.mark_watched(video) del selectable[tag] elif self.action is Action.DOWNLOAD_AUDIO: print() self.download_video(video, True) del selectable[tag] elif self.action is Action.DOWNLOAD_VIDEO: print() self.download_video(video, False) del selectable[tag] elif self.action is Action.PLAY_AUDIO: print() self.play(video, True) del selectable[tag] elif self.action is Action.PLAY_VIDEO: print() self.play(video, False) del selectable[tag] elif self.action is Action.SHOW_HELP: self.action = self.previous_action terminal.clear_screen() print( _(" <F1> Display this help text.\n" " <F2> Set action: Play video.\n" " <F3> Set action: Play audio.\n" " <F4> Set action: Mark as watched.\n" " <F5> Refresh video list.\n" " <F6> Set action: Download video.\n" " <F7> Set action: Download audio.\n" " <Enter> Accept first video.\n" "<CTRL+D> Exit.\n")) input(_("Press Enter to continue")) elif self.action is Action.REFRESH: self.action = self.previous_action terminal.clear_screen() self.core.update() self.videos = list(self.core.list_videos()) self.run() break
def play(video: Video, audio_only: bool) -> None: print(_('Playing "{video.title}" by "{video.channel.displayname}"...').format(video=video)) maybe_print_description(video.description) if not ytcc_core.play_video(video, audio_only): print() print(_("WARNING: The video player terminated with an error.\n" " The last video is not marked as watched!")) print()
def add_channel(name: str, channel_url: str) -> None: try: ytcc_core.add_channel(name, channel_url) except BadURLException: print(_("{!r} is not a valid YouTube URL").format(channel_url)) except DuplicateChannelException: print(_("You are already subscribed to {!r}").format(name)) except ChannelDoesNotExistException: print(_("The channel {!r} does not exist").format(channel_url))
def video_to_list(video: Video) -> List[str]: timestamp = unpack_optional(video.publish_date, lambda: 0) return [ str(video.id), datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M"), str(video.channel.displayname), str(video.title), ytcc_core.get_youtube_video_url(video.yt_videoid), _("Yes") if video.watched else _("No") ]
def import_channels(file: TextIO) -> None: print(_("Importing...")) try: ytcc_core.import_channels(file) subscriptions = _("Subscriptions") print() print(subscriptions) print("=" * len(subscriptions)) print_channels() except core.InvalidSubscriptionFileError: print(_("The given file is not valid YouTube export file"))
def download_video(self, video: MappedVideo, audio_only: bool = False) -> None: print( _('Downloading "{video.title}" in playlist(s) "{playlists}"...'). format(video=video, playlists=", ".join(v.name for v in video.playlists))) if self.core.download_video(video=video, audio_only=audio_only): self.core.mark_watched(video) else: print(_("An Error occured while downloading the video"))
def mark_watched(video_ids: List[int]) -> None: ytcc_core.set_video_id_filter(video_ids) videos = ytcc_core.list_videos() if not videos: print(_("No videos were marked as watched")) return for video in videos: video.watched = True print(_("Following videos were marked as watched:")) print() print_videos(videos)
def get_prompt_text(self) -> str: if self.action == Action.MARK_WATCHED: return _("Mark as watched") if self.action == Action.DOWNLOAD_AUDIO: return _("Download audio") if self.action == Action.DOWNLOAD_VIDEO: return _("Download video") if self.action == Action.PLAY_AUDIO: return _("Play audio") if self.action == Action.PLAY_VIDEO: return _("Play video") return ""
def print_channels() -> None: channels = ytcc_core.get_channels() if not channels: print(_("No channels added, yet.")) else: for channel in channels: print(channel.displayname)
def command_line(self, tags: List[str], alphabet: Set[str]) -> Tuple[str, bool]: prompt_format = "{prompt_text} >" prompt = prompt_format.format(prompt_text=self.get_prompt_text()) print() print(_("Type a valid TAG. <F1> for help.")) print(prompt, end=" ", flush=True) tag = "" hook_triggered = False while tag not in tags: char = getkey.getkey() if char in self.hooks: hook_triggered = True if self.hooks[char](): break if char in {"\x04", "\x03"}: # Ctrl+d, Ctrl+d break if char in {"\r", ""}: tag = tags[0] break if char == "\x7f": # DEL tag = tag[:-1] elif char in alphabet: tag += char prompt = prompt_format.format(prompt_text=self.get_prompt_text()) # Clear line, reset cursor, print prompt and tag print(f"\033[2K\r{prompt}", tag, end="", flush=True) print() return tag, hook_triggered
def watch(video_ids: Iterable[int]) -> None: ytcc_core.set_video_id_filter(video_ids) videos = ytcc_core.list_videos() if not videos: print(_("No videos to watch. No videos match the given criteria.")) elif not INTERACTIVE_ENABLED: for video in videos: play(video, NO_VIDEO) else: Interactive(videos).run()
def maybe_print_description(description: Optional[str]) -> None: if DESCRIPTION_ENABLED and description is not None: columns = shutil.get_terminal_size().columns delimiter = "=" * columns lines = description.splitlines() print() print(_("Video description:")) print(delimiter) for line in lines: print(wrap.fill(line, width=columns)) print(delimiter, end="\n\n")
class Action(Enum): def __init__(self, text: str, hotkey: str, color: Callable[[], int]): self.text = text self.hotkey = hotkey self.color = color @staticmethod def from_config(): return Action.__dict__.get(config.tui.default_action.value.upper(), Action.PLAY_VIDEO) SHOW_HELP = (None, FKeys.F1, None) PLAY_VIDEO = (_("Play video"), FKeys.F2, lambda: config.theme.prompt_play_video) PLAY_AUDIO = (_("Play audio"), FKeys.F3, lambda: config.theme.prompt_play_audio) MARK_WATCHED = (_("Mark as watched"), FKeys.F4, lambda: config.theme.prompt_mark_watched) REFRESH = (None, FKeys.F5, None) DOWNLOAD_AUDIO = (_("Download audio"), FKeys.F7, lambda: config.theme.prompt_download_audio) DOWNLOAD_VIDEO = (_("Download video"), FKeys.F6, lambda: config.theme.prompt_download_video)
def command_line(self, tags: List[str]) -> Tuple[str, bool]: def print_prompt(): prompt_format = "{prompt_text} > " prompt = prompt_format.format(prompt_text=self.get_prompt_text()) printt(prompt, foreground=self.get_prompt_color(), bold=True, replace=True) print() print(_("Type a valid TAG. <F1> for help.")) print_prompt() tag = "" hook_triggered = False while tag not in tags: char: Optional[str] = terminal.getkey() if char in self.hooks: hook_triggered = True if self.hooks[char](): break char = None if char in {"\x04", "\x03"}: # Ctrl+d, Ctrl+d hook_triggered = False break if char in {"\r", ""} and tags: tag = tags[0] break if char == FKeys.DEL: tag = tag[:-1] elif char and char in config.tui.alphabet: tag += char print_prompt() printt(tag) print() return tag, hook_triggered
def is_directory(string: str) -> str: if not os.path.isdir(string): msg = _("{!r} is not a directory").format(string) raise argparse.ArgumentTypeError(msg) return string
from ytcc import core, arguments, getkey, _ from ytcc.database import Video from ytcc.exceptions import BadConfigException, ChannelDoesNotExistException, \ DuplicateChannelException, BadURLException from ytcc.utils import unpack_optional try: ytcc_core = core.Ytcc() # pylint: disable=C0103 COLUMN_FILTER = [ytcc_core.config.table_format.getboolean("ID"), ytcc_core.config.table_format.getboolean("Date"), ytcc_core.config.table_format.getboolean("Channel"), ytcc_core.config.table_format.getboolean("Title"), ytcc_core.config.table_format.getboolean("URL"), ytcc_core.config.table_format.getboolean("Watched")] except BadConfigException: print(_("The configuration file has errors!")) exit(1) INTERACTIVE_ENABLED = True DESCRIPTION_ENABLED = True NO_VIDEO = False DOWNLOAD_PATH = "" HEADER_ENABLED = True TABLE_HEADER = [_("ID"), _("Date"), _("Channel"), _("Title"), _("URL"), _("Watched")] _REGISTERED_OPTIONS: Dict[str, "Option"] = dict() def register_option(option_name, exit=False, is_action=True): # pylint: disable=redefined-builtin def decorator(func): nargs = len(inspect.signature(func).parameters) _REGISTERED_OPTIONS[option_name] = Option(
def handler(signum: Any, frame: Any) -> None: ytcc_core.close() print() print(_("Bye...")) exit(1)
def cleanup() -> None: print(_("Cleaning up database...")) ytcc_core.cleanup()
def run(self) -> None: alphabet = ytcc_core.config.quickselect_alphabet tags = self._prefix_codes(alphabet, len(self.videos)) index = OrderedDict(zip(tags, self.videos)) while index: remaining_tags = list(index.keys()) remaining_videos = index.values() # Clear display and set cursor to (1,1). Allows scrolling back in some terminals print("\033[2J\033[1;1H", end="") print_videos(remaining_videos, quickselect_column=remaining_tags) tag, hook_triggered = self.command_line(remaining_tags, alphabet) video = index.get(tag) if video is None and not hook_triggered: break if video is not None: if self.action == Action.MARK_WATCHED: video.watched = True del index[tag] elif self.action == Action.DOWNLOAD_AUDIO: print() download_video(video, True) del index[tag] elif self.action == Action.DOWNLOAD_VIDEO: print() download_video(video, False) del index[tag] elif self.action == Action.PLAY_AUDIO: print() play(video, True) del index[tag] elif self.action == Action.PLAY_VIDEO: print() play(video, False) del index[tag] elif self.action == Action.SHOW_HELP: self.action = self.previous_action print("\033[2J\033[1;1H", end="") print(_( " <F1> Display this help text.\n" " <F2> Set action: Play video.\n" " <F3> Set action: Play audio.\n" " <F4> Set action: Mark as watched.\n" " <F5> Refresh video list.\n" " <F6> Set action: Download video.\n" " <F7> Set action: Download audio.\n" " <Enter> Accept first video.\n" "<CTRL+D> Exit.\n" )) input(_("Press Enter to continue")) elif self.action == Action.REFRESH: self.action = self.previous_action print("\033[2J\033[1;1H", end="") update_all() self.videos = ytcc_core.list_videos() self.run() break
def is_date(string: str) -> datetime: try: return datetime.strptime(string, "%Y-%m-%d") except ValueError: msg = _("{!r} is not a valid date").format(string) raise argparse.ArgumentTypeError(msg)
def list_videos() -> None: videos = ytcc_core.list_videos() if not videos: print(_("No videos to list. No videos match the given criteria.")) else: print_videos(videos)
def update_all() -> None: print(_("Updating channels...")) ytcc_core.update_all()
def get_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=_("ytcc is a commandline YouTube client that keeps track of your favorite " "channels. The --list, --watch, --download, --mark-watched options can be " "combined with filter options --channel-filter, --include-watched, --since," " --to")) parser.add_argument("-a", "--add-channel", help=_("add a new channel. NAME is the name displayed by ytcc. URL is the " "url of the channel's front page or the URL of any video published " "by the channel"), nargs=2, metavar=("NAME", "URL")) parser.add_argument("-c", "--list-channels", help=_("print a list of all subscribed channels"), action="store_true") parser.add_argument("-r", "--delete-channel", help=_("unsubscribe from the channel identified by 'ID'"), metavar="ID", nargs='+', type=str) parser.add_argument("-u", "--update", help=_("update the video list"), action="store_true") parser.add_argument("-l", "--list", help=_("print a list of videos that match the criteria given by the " "filter options"), action="store_true") parser.add_argument("-w", "--watch", help=_("play the videos identified by 'ID'. Omitting the ID will play all " "videos specified by the filter options"), nargs='*', type=int, metavar="ID") parser.add_argument("-d", "--download", help=_("download the videos identified by 'ID'. The videos are saved " "in $HOME/Downloads by default. Omitting the ID will download " "all videos that match the criteria given by the filter options"), nargs="*", type=int, metavar="ID") parser.add_argument("-m", "--mark-watched", help=_("mark videos identified by ID as watched. Omitting the ID will mark" " all videos that match the criteria given by the filter options as " "watched"), nargs='*', type=int, metavar="ID") parser.add_argument("-f", "--channel-filter", help=_("plays, lists, marks, downloads only videos from channels defined " "in the filter"), nargs='+', type=str, metavar="NAME") parser.add_argument("-n", "--include-watched", help=_("include already watched videos to filter rules"), action="store_true") parser.add_argument("-s", "--since", help=_("includes only videos published after the given date"), metavar="YYYY-MM-DD", type=is_date) parser.add_argument("-t", "--to", help=_("includes only videos published before the given date"), metavar="YYYY-MM-DD", type=is_date) parser.add_argument("-p", "--path", help=_("set the download path to PATH"), metavar="PATH", type=is_directory) parser.add_argument("-g", "--no-description", help=_("do not print the video description before playing the video"), action="store_true") parser.add_argument("-o", "--columns", help=_("specifies which columns will be printed when listing videos. COL " "can be any of {columns}. All columns can be enabled with " "'all'").format(columns=ytcc.cli.TABLE_HEADER), nargs='+', metavar="COL", choices=["all", *ytcc.cli.TABLE_HEADER]) parser.add_argument("--no-header", help=_("do not print the header of the table when listing videos"), action="store_true") parser.add_argument("-x", "--no-video", help=_("plays or downloads only the audio part of a video"), action="store_true") parser.add_argument("-y", "--disable-interactive", help=_("disables the interactive mode"), action="store_true") parser.add_argument("--import-from", help=_("import YouTube channels from YouTube's subscription export " "(available at https://www.youtube.com/subscription_manager)"), metavar="PATH", type=argparse.FileType("r")) parser.add_argument("--cleanup", help=_("removes old videos from the database and shrinks the size of the " "database file"), action="store_true") parser.add_argument("-v", "--version", help=_("output version information and exit"), action="store_true") parser.add_argument("--bug-report-info", help=_("print info to include in a bug report"), action="store_true") return parser.parse_args()
def download_video(video: Video, audio_only: bool = False) -> None: print(_('Downloading "{video.title}" by "{video.channel.displayname}"...').format(video=video)) success = ytcc_core.download_video(video=video, path=DOWNLOAD_PATH, audio_only=audio_only) if not success: print(_("An Error occured while downloading the video"))