#!/usr/bin/perl 

# Copyright (C) 2010-2026 Trizen <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d>.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of either: the GNU General Public License as published
# by the Free Software Foundation; or the Artistic License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See https://dev.perl.org/licenses/ for more information.
#
#-------------------------------------------------------
#  pipe-viewer
#  Fork: 30 October 2020
#  Edit: 13 March 2026
#  https://github.com/trizen/pipe-viewer
#-------------------------------------------------------

# pipe-viewer is a command line utility for streaming YouTube videos in mpv/vlc.

# This is a fork of straw-viewer:
#   https://github.com/trizen/straw-viewer

=encoding utf8

=head1 NAME

pipe-viewer - YouTube from command line.

     pipe-viewer --help
     pipe-viewer --tricks
     pipe-viewer --examples
     pipe-viewer --stdin-help

=cut

use utf8;
use 5.016;

use warnings;
no warnings 'once';

use Term::ReadLine        qw();
use File::Spec::Functions qw(
  catdir
  catfile
  curdir
  path
  rel2abs
  splitdir
  file_name_is_absolute
);

my $DEVEL;
BEGIN { $DEVEL = -w __FILE__ }

sub devel_path {
    require FindBin;
    my @dirs = splitdir($FindBin::RealBin);
    pop(@dirs);
    return @dirs;
}

use if $DEVEL, lib => $DEVEL && catdir(devel_path(), 'lib');

use WWW::PipeViewer v0.5.7;
use WWW::PipeViewer::ParseJSON;
use WWW::PipeViewer::RegularExpressions;

binmode(STDOUT, ':utf8');

my $appname  = 'CLI Pipe Viewer';
my $version  = $WWW::PipeViewer::VERSION;
my $execname = 'pipe-viewer';

# A better <STDIN> support:
my $term = Term::ReadLine->new("$appname $version");

# Options (key=>value) goes here
my %opt;
my $term_width = 80;

# Keep track of watched videos by their ID
my %WATCHED_VIDEOS;

# Unchangeable data goes here
my %constant = (
                win32   => (lc($^O) eq 'mswin32' ? 1 : 0),
                android => (lc($^O) eq 'android' ? 1 : 0),
               );

my $home_dir;
my $xdg_config_home = $ENV{XDG_CONFIG_HOME};

if ($xdg_config_home and -d $xdg_config_home) {
    require File::Basename;
    $home_dir = File::Basename::dirname($xdg_config_home);

    if (not -d $home_dir) {
        $home_dir = $ENV{HOME} || curdir();
    }
}
else {
    $home_dir =
         $ENV{HOME}
      || $ENV{LOGDIR}
      || ($constant{win32} ? '\Local Settings\Application Data' : ((getpwuid($<))[7] || `echo -n ~`));

    if (not -d $home_dir) {
        $home_dir = curdir();
    }

    $xdg_config_home = catdir($home_dir, '.config');
}

# Configuration dirs
my $config_dir          = catdir($xdg_config_home, $execname);
my $local_playlists_dir = catdir($config_dir,      'playlists');

# Configuration files
my $config_file              = catfile($config_dir, "$execname.conf");
my $saved_channels_file      = catfile($config_dir, 'users.txt');
my $subscribed_channels_file = catfile($config_dir, 'subscribed_channels.txt');
my $history_file             = catfile($config_dir, 'cli-history.txt');
my $watched_file             = catfile($config_dir, 'watched.txt');

# Special local playlists
my $watch_history_data_file       = catfile($local_playlists_dir, 'watched_videos.dat');
my $liked_videos_data_file        = catfile($local_playlists_dir, 'liked_videos.dat');
my $disliked_videos_data_file     = catfile($local_playlists_dir, 'disliked_videos.dat');
my $favorite_videos_data_file     = catfile($local_playlists_dir, 'favorite_videos.dat');
my $subscription_videos_data_file = catfile($local_playlists_dir, "subscriptions.dat");

# Create the config and playlist dirs
foreach my $dir ($config_dir, $local_playlists_dir) {
    if (not -d $dir) {
        require File::Path;
        eval { File::Path::make_path($dir) }
          or warn "[!] Can't create dir <<$dir>>: $!";
    }
}

# Create the special playlist files
foreach my $file ($watch_history_data_file, $liked_videos_data_file, $disliked_videos_data_file, $favorite_videos_data_file,) {
    if (not -s $file) {
        require Storable;
        Storable::store([], $file);
    }
}

sub which_command {
    my ($cmd) = @_;

    if (file_name_is_absolute($cmd)) {
        return $cmd;
    }

    state $paths = [path()];
    foreach my $path (@{$paths}) {
        my $cmd_path = catfile($path, $cmd);
        if (-f -x $cmd_path) {
            return $cmd_path;
        }
    }

    return;
}

# Main configuration
my %CONFIG = (

    video_players => {

        (
         $constant{android}
         ? (
            vlc => {
                    arg     => "start -n org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity -a android.intent.action.VIEW -d *VIDEO*",
                    cmd     => "am",
                    fs      => "",
                    novideo => "",
                    srt     => "",
                   }
           )
         : (
            vlc => {
                    cmd     => q{vlc},
                    srt     => q{--sub-file=*SUB*},
                    audio   => q{--input-slave=*AUDIO*},
                    fs      => q{--fullscreen},
                    arg     => q{--quiet --play-and-exit --no-video-title-show --input-title-format=*TITLE* --start-time=*START* *VIDEO*},
                    novideo => q{--intf=dummy --novideo},
                   }
           )
        ),

        mpv => {
                cmd     => q{mpv},
                srt     => q{--sub-file=*SUB*},
                audio   => q{--audio-file=*AUDIO*},
                fs      => q{--fullscreen},
                arg     => q{--really-quiet --force-media-title=*TITLE* --start=*START* --no-ytdl *VIDEO*},
                novideo => q{--no-video},
               },
    },

    video_player_selected => (
        ($constant{win32} || $constant{android})
        ? 'vlc'
        : undef    # auto-defined
    ),

    split_videos => $constant{android} ^ 1,

    # YouTube options
    dash          => 1,        # may load slow
    maxResults    => 20,
    hfr           => 1,        # true to prefer high frame rate (HFR) videos
    resolution    => 'best',
    audio_quality => 'best',
    videoDuration => undef,

    features => [],
    order    => undef,
    date     => undef,
    region   => undef,

    # Comments order
    comments_order => 'top',    # valid values: top, new

    # URI options
    youtube_video_url    => 'https://www.youtube.com/watch?v=%s',
    youtube_playlist_url => 'https://www.youtube.com/playlist?list=%s',
    youtube_channel_url  => 'https://www.youtube.com/channel/%s',

    # Subtitle options
    srt_languages => ['en', 'es'],
    get_captions  => 1,
    auto_captions => 0,
    copy_caption  => 0,
    cache_dir     => undef,

    # API
    api_host => "auto",

    # Misc options
    autoplay_mode      => 0,
    http_proxy         => undef,
    cookie_file        => undef,
    user_agent         => undef,
    timeout            => undef,
    max_retries        => 3,
    retry_delay        => 1,
    env_proxy          => 1,
    confirm            => 0,
    debug              => 0,
    page               => 1,
    colors             => $constant{win32} ^ 1,
    skip_if_exists     => 1,
    prefer_mp4         => 0,
    prefer_m4a         => 0,
    prefer_av1         => 0,
    ignore_av1         => 0,
    prefer_invidious   => 0,
    force_fallback     => 0,
    fat32safe          => $constant{win32},
    fullscreen         => 0,
    show_video_info    => 1,
    interactive        => 1,
    get_term_width     => $constant{win32} ^ 1,
    download_with_wget => undef,                  # auto-defined
    download_with_ytdl => undef,                  # auto-defined
    downloads_dir      => curdir(),
    download_and_play  => 0,
    remove_played_file => 0,

    download_in_subdir        => 0,               # true to download in a subfolder
    download_in_subdir_format => '*AUTHOR*',

    bypass_age_gate_native     => 0,
    bypass_age_gate_with_proxy => 0,
    ignored_projections        => [],

    # Parallel options
    get_subscriptions_in_parallel => 0,

    # Conversion options
    convert_cmd         => 'ffmpeg -i *IN* *OUT*',
    convert_to          => undef,
    keep_original_video => 0,

    # Search history
    history       => undef,           # auto-defined
    history_limit => 100_000,
    history_file  => $history_file,

    # Watch history
    watch_history      => 1,
    watch_history_file => $watched_file,

    # Subscribed channels
    subscription_results     => 'uploads',                   # valid values: uploads, streams, shorts (comma-separated)
    subscriptions_limit      => 10_000,
    subscriptions_lifetime   => 600,
    saved_channels_file      => $saved_channels_file,
    subscribed_channels_file => $subscribed_channels_file,

    local_playlist_limit => -1,

    # Options for watched videos
    highlight_watched => 1,
    highlight_color   => 'bold',
    skip_watched      => 0,

    # yt-dlp / youtube-dl support
    ytdl     => 1,
    ytdl_cmd => undef,    # auto-defined

    # yt-dlp comment options
    ytdlp_comments     => 1,
    ytdlp_max_comments => 10,
    ytdlp_max_replies  => 3,

    # Custom layout
    custom_layout_format => [{width => 3,     align => "right", color => "bold",      text => "*NO*.",},
                             {width => "55%", align => "left",  color => "bold blue", text => "*TITLE*",},
                             {width => "15%", align => "left",  color => "magenta",   text => "*AUTHOR*",},
                             {width => 3,     align => "right", color => "green",     text => "*AGE_SHORT*",},
                             {width => 5,     align => "right", color => "green",     text => "*VIEWS_SHORT*",},
                             {width => 8,     align => "right", color => "blue",      text => "*TIME*",},
                            ],

    custom_channel_layout_format => [{width => 3,     align => "right", color => "bold",      text => "*NO*.",},
                                     {width => "55%", align => "left",  color => "bold blue", text => "*AUTHOR*",},
                                     {width => 14,    align => "right", color => "green",     text => "*VIDEOS* videos",},
                                     {width => 10,    align => "right", color => "green",     text => "*SUBS_SHORT* subs",},
                                    ],

    custom_playlist_layout_format => [{align => "right", color => "bold",      text => "*NO*.",          width => 3},
                                      {align => "left",  color => "bold blue", text => "*TITLE*",        width => "55%"},
                                      {align => "right", color => "green",     text => "*ITEMS* videos", width => 14},
                                      {align => "left",  color => "magenta",   text => "*AUTHOR*",       width => "20%"},
                                     ],

    ffmpeg_cmd => 'ffmpeg',
    wget_cmd   => 'wget',

    merge_into_mkv      => undef,                                                                    # auto-defined later
    merge_into_mkv_args => '-loglevel warning -c:s srt -c:v copy -c:a copy -disposition:s forced',
    merge_with_captions => 1,
    set_mtime           => $constant{win32} ^ 1,

    video_filename_format => '*TITLE* - *ID*.*FORMAT*',

    dislikes_api            => 0,
    skip_youtube_extraction => 0,
);

local $SIG{__WARN__} = sub { warn @_; ++$opt{_error} };

my %PLAYER_ARGS;    # will store video player arguments

my $base_options = <<'BASE';
# Base
[keywords]        : search for YouTube videos
[youtube-url]     : play a video by YouTube URL
:v(ideoid)=ID     : play videos by YouTube video IDs
[playlist-url]    : display videos from a playlistURL
:playlist=ID      : display videos from a playlistID
BASE

my $control_options = <<'CONTROL';
# Control
:n(ext)           : display the next page of results
:r(eturn)         : return to the previous page of results
CONTROL

my $other_options = <<'OTHER';
# Others
:refresh          : refresh the current list of results
:dv=i             : display the data structure of result i
-argv -argv2=v    : apply some arguments (e.g.: -u=google)
:q, :quit, :exit  : close the application
OTHER

my $notes_options = <<'NOTES';
NOTES:
 1. You can specify more options in a row, separated by spaces.
 2. A stdin option is valid only if it begins with '=', ';' or ':'.
 3. Quoting a group of space separated keywords or option-values,
    the group will be considered a single keyword or a single value.
NOTES

my $general_help = <<"HELP";

$control_options
$other_options
$notes_options
Examples:
     3                  : select the 3rd result
    -sv funny cats      : search for videos
    -sc mathematics     : search for channels
    -sp classical music : search for playlists
HELP

my $playlists_help = <<"PLAYLISTS_HELP" . $general_help;

# Select a playlist
<number>          : list videos from the selected playlist
:p=i              : list playlists from the selected author
:pp=i,i           : play videos from the selected playlists
PLAYLISTS_HELP

my $channels_help = <<"CHANNELS_HELP" . $general_help;

# Select a channel
<number>           : latest uploads from channel
:streams=i :us=i   : latest streams from channel
:shorts=i          : latest shorts from channel
:ps=i              : popular streams from channel
:pv=i :popular=i   : popular uploads from channel
:p=i  :playlists=i : playlists from channel

# Save and remove channels
      :save=i      : save channel
:s=i  :subscribe=i : subscribe to the channel
      :unsub=i     : unsubscribe from the channel
:r=i  :remove=i    : remove the channel
CHANNELS_HELP

my $comments_help = <<"COMMENTS_HELP" . $general_help;

# Comments
COMMENTS_HELP

my $complete_help = <<"STDIN_HELP";

$base_options
$control_options
# YouTube
:i(nfo)=i,i       : display more information
:d(ownload)=i,i   : download the selected videos
:c(omments)=i     : display video comments
:r(elated)=i      : display related videos
:u(ploads)=i      : display author's latest uploads
:streams=i :us=i  : display author's latest streams
:shorts=i         : display author's latest shorts
:pv=i :popular=i  : display author's popular uploads
:ps=i             : display author's popular streams
:p(laylists)=i    : display author's playlists
:subscribe=i      : subscribe to author's channel
:w=i :mark=i      : add video to watched history
:(dis)like=i      : like or dislike a video
:fav(orite)=i     : favorite a video
:autoplay=i       : autoplay mode, starting from video i

# Playing
<number>          : play the corresponding video
3-8, 3..8         : same as 3 4 5 6 7 8
8-3, 8..3         : same as 8 7 6 5 4 3
8 2 12 4 6 5 1    : play the videos in a specific order
10..              : play all the videos onwards from 10
:q(ueue)=i,i,...  : enqueue videos for playing them later
:pq, :play-queue  : play the enqueued videos (if any)
:anp, :nnp        : auto-next-page, no-next-page
:play=i,i,...     : play a group of selected videos
:regex='RE'       : play videos matched by a regex (/i)
:kregex=KEY,RE    : play videos if the value of KEY matches the RE

$other_options
$notes_options
** Examples:
:regex='\\w \\d' -> play videos matched by a regular expression.
:info=1        -> show extra information for the first video.
:d18-20,1,2    -> download the selected videos: 18, 19, 20, 1 and 2.
3 4 :next 9    -> play the 3rd and 4th videos from the current
                  page, go to the next page and play the 9th video.
STDIN_HELP

{
    my $config_documentation = <<"EOD";
#!/usr/bin/perl

# $appname $version - configuration file

use utf8;

EOD

    sub dump_configuration {
        my ($config_file) = @_;

        require Data::Dump;
        open my $config_fh, '>', $config_file
          or do { warn "[!] Can't open '${config_file}' for write: $!"; return };

        my $dumped_config = q{our $CONFIG = } . Data::Dump::pp(\%CONFIG) . "\n";

        if (defined($ENV{HOME}) and $home_dir eq $ENV{HOME}) {
            $dumped_config =~ s/\Q$home_dir\E/\$ENV{HOME}/g;
        }

        print $config_fh $config_documentation, $dumped_config;
        close $config_fh;
    }
}

our $CONFIG;
our @FEATURES;

sub toggle_features {
    my ($enabled, @list) = @_;

    if ($enabled) {
        @FEATURES = sort(do {
                my %seen;
                grep { !$seen{$_}++ } (@FEATURES, @list);
            }
        );
    }
    else {
        my %enabled;
        @enabled{@FEATURES} = ();
        foreach my $feature (@list) {
            delete($enabled{$feature});
        }
        @FEATURES = sort(keys %enabled);
    }
}

sub load_config {
    my ($config_file) = @_;

    if (not -e $config_file or -z _ or $opt{reconfigure}) {
        dump_configuration($config_file);
    }

    require $config_file;    # Load the configuration file

    if (ref $CONFIG ne 'HASH') {
        die "[ERROR] Invalid configuration file!\n\t\$CONFIG is not an HASH ref!";
    }

    my $update_config = 0;

    # Rename `watched_file` to `watch_history_file`
    if (exists $CONFIG->{watched_file}) {
        $CONFIG->{watch_history_file} = delete $CONFIG->{watched_file};
        $update_config = 1;
    }

    # Rename `youtube_users_file` to `saved_channels_file`
    if (exists $CONFIG->{youtube_users_file}) {
        $CONFIG->{saved_channels_file} = delete $CONFIG->{youtube_users_file};
        $update_config = 1;
    }

    toggle_features(1, @{$CONFIG->{features} // []});

    do {
        my @feature_options = qw(
          videoCaption     1                 subtitles
          videoCaption     true              subtitles
          videoDefinition  high              hd
          videoDimension   3d                3d
          videoLicense     creative_commons  creative_commons
        );

        while (scalar @feature_options) {
            my ($option_name, $option_value, $feature) = splice(@feature_options, 0, 3);
            if (($CONFIG->{$option_name} // '') eq $option_value) {
                toggle_features(1, $feature);
            }
        }
    };

    $CONFIG->{features} = \@FEATURES;

    # Get valid config keys
    my @valid_keys = grep { exists $CONFIG{$_} } keys %{$CONFIG};
    @CONFIG{@valid_keys} = @{$CONFIG}{@valid_keys};

    # Define the cache directory
    if (not defined $CONFIG{cache_dir}) {

        my $cache_dir =
          ($ENV{XDG_CACHE_HOME} and -d $ENV{XDG_CACHE_HOME})
          ? $ENV{XDG_CACHE_HOME}
          : catdir($home_dir, '.cache');

        if (not -d $cache_dir) {
            $cache_dir = catdir(curdir(), '.cache');
        }

        $CONFIG{cache_dir} = catdir($cache_dir, 'pipe-viewer');
        $update_config = 1;
    }

    # Locate video player
    if (not $CONFIG{video_player_selected}) {

        foreach my $key (sort keys %{$CONFIG{video_players}}) {
            if (defined(my $abs_player_path = which_command($CONFIG{video_players}{$key}{cmd}))) {
                $CONFIG{video_players}{$key}{cmd} = $abs_player_path;
                $CONFIG{video_player_selected}    = $key;
                $update_config                    = 1;
                last;
            }
        }

        if (not $CONFIG{video_player_selected}) {
            warn "\n[!] Please install a supported video player! (e.g.: mpv)\n\n";
            $CONFIG{video_player_selected} = 'mpv';
        }
    }

    # Locate yt-dlp and youtube-dl
    if (   not defined($CONFIG{ytdl_cmd})
        or not defined($CONFIG{download_with_ytdl})) {

        foreach my $ytdl (qw(yt-dlp youtube-dl)) {
            my $ytdl_path = which_command($ytdl);

            if (defined($ytdl_path)) {
                $CONFIG{ytdl_cmd}           //= $ytdl_path;
                $CONFIG{download_with_ytdl} //= 1;
                last;
            }
        }

        $CONFIG{ytdl_cmd}           //= 'yt-dlp';
        $CONFIG{download_with_ytdl} //= 0;
        $update_config = 1;
    }

    # Download with wget if it is installed
    if (not defined $CONFIG{download_with_wget}) {

        my $wget_path = which_command('wget');

        if (defined($wget_path)) {
            $CONFIG{wget_cmd}           = $wget_path;
            $CONFIG{download_with_wget} = 1;
        }
        else {
            $CONFIG{download_with_wget} = 0;
        }

        $update_config = 1;
    }

    # Merge into MKV if ffmpeg is installed
    if (not defined $CONFIG{merge_into_mkv}) {

        my $ffmpeg_path = which_command('ffmpeg');

        if (defined($ffmpeg_path)) {
            $CONFIG{ffmpeg_cmd}     = $ffmpeg_path;
            $CONFIG{merge_into_mkv} = 1;
        }
        else {
            $CONFIG{merge_into_mkv} = 0;
        }

        $update_config = 1;
    }

    # Enable history if Term::ReadLine::Gnu::XS is installed
    if (not defined $CONFIG{history}) {

        if (eval { $term->can('ReadHistory') }) {
            $CONFIG{history} = 1;
        }
        else {
            $CONFIG{history} = 0;
        }

        $update_config = 1;
    }

    foreach my $key (keys %CONFIG) {
        if (not exists $CONFIG->{$key}) {
            $update_config = 1;
            last;
        }
    }

    dump_configuration($config_file) if $update_config;

    # Create the cache directory (if needed)
    foreach my $path ($CONFIG{cache_dir}) {
        next if -d $path;
        require File::Path;
        eval { File::Path::make_path($path) }
          or warn "[!] Can't create path <<$path>>: $!";
    }

    @opt{keys %CONFIG} = values(%CONFIG);
}

load_config($config_file);

if ($opt{watch_history}) {
    if (-f $opt{watch_history_file}) {
        if (open my $fh, '<', $opt{watch_history_file}) {
            chomp(my @ids = <$fh>);
            @WATCHED_VIDEOS{@ids} = ();
            close $fh;
        }
        else {
            warn "[!] Can't open the watched file `$opt{watch_history_file}' for reading: $!";
        }
    }
}

if ($opt{history}) {

    # Create the history file.
    if (not -e $opt{history_file}) {

        require File::Basename;
        my $dir = File::Basename::dirname($opt{history_file});

        if (not -d $dir) {
            require File::Path;
            eval { File::Path::make_path($dir) }
              or warn "[!] Can't create path <<$dir>>: $!";
        }

        open my $fh, '>', $opt{history_file}
          or warn "[!] Can't create the history file `$opt{history_file}': $!";
    }

    # Add history to Term::ReadLine
    eval { $term->ReadHistory($opt{history_file}) };

    # All history entries
    my @history;
    eval { @history = $term->history_list };

    # Rewrite the history file, when the history_limit has been reached.
    if ($opt{history_limit} > 0 and @history > $opt{history_limit}) {

        # Try to create a backup, first
        require File::Copy;
        File::Copy::cp($opt{history_file}, "$opt{history_file}.bak");

        if (open my $fh, '>', $opt{history_file}) {

            # Keep only the most recent half part of the history file
            say {$fh} join("\n", @history[($opt{history_limit} >> 1) .. $#history]);
            close $fh;
        }
    }
}

my $yv_obj = WWW::PipeViewer->new(
                                  escape_utf8             => 1,
                                  config_dir              => $config_dir,
                                  ytdl                    => $opt{ytdl},
                                  ytdl_cmd                => $opt{ytdl_cmd},
                                  cache_dir               => $opt{cache_dir},
                                  env_proxy               => $opt{env_proxy},
                                  cookie_file             => $opt{cookie_file},
                                  http_proxy              => $opt{http_proxy},
                                  user_agent              => $opt{user_agent},
                                  timeout                 => $opt{timeout},
                                  skip_youtube_extraction => $opt{skip_youtube_extraction},
                                 );

require WWW::PipeViewer::Utils;
my $yv_utils = WWW::PipeViewer::Utils->new(
                                           youtube_video_url_format    => $opt{youtube_video_url},
                                           youtube_channel_url_format  => $opt{youtube_channel_url},
                                           youtube_playlist_url_format => $opt{youtube_playlist_url},
                                          );

{    # Apply the configuration file
    my %temp = %CONFIG;
    apply_configuration(\%temp);
}

#---------------------- PIPE-VIEWER USAGE ----------------------#
sub help {
    my $eqs = q{=} x 30;

    local $" = ', ';
    print <<"HELP";
\n  $eqs \U$appname\E $eqs

usage: $execname [options] ([url] | [keywords])

== Base ==
   [URL]             : play an YouTube video by URL
   [keywords]        : search for YouTube videos
   [playlist URL]    : display a playlist of YouTube videos


== YouTube Options ==

 * Categories
   -c  --categories     : display the available YouTube categories

 * Region
   --region=s           : set the region code (default: US)

 * Videos
   -uv --uploads=s      : list videos uploaded by a specific channel or user
   -us --streams=s      : list livestream videos by a specific channel or user
       --shorts=s       : list short videos by a specific channel or user
   -pv --popular=s      : list the most popular videos from a specific channel
   -ps --pstreams=s     : list the most popular streams from a specific channel
       --pshorts=s      : list the most popular shorts from a specific channel
   -id --videoids=s,s   : play YouTube videos by their IDs
   -rv --related=s      : show related videos for a video ID or URL
   -sv --search-videos  : search for YouTube videos (default mode)
   -wv --watched-videos : list the most recent watched videos
   -ls --local-subs     : display subscription videos from local channels

 * Playlists
   -up --playlists=s     : list playlists created by a specific channel or user
   -lp --local-playlists : display the list of local playlists
   -lp=s                 : display a local playlist by name
   -sp --search-pl       : search for playlists of videos
       --pid=s           : list a playlist of videos by playlist ID
       --pp=s,s          : play the videos from the given playlist IDs

 * Trending
   --trending:s       : show trending videos in a given category
                        valid categories: music gaming news movies popular

 * Channels
   -sc --channels     : search for YouTube channels

 * Movies
   -sm --movies       : search for YouTube movies

 * Comments
   --comments=s         : display comments for a video by ID or URL
   --comments-order=s   : change the order of YouTube comments
                          valid values: relevance, time
   --ytdlp-comments!    : use `yt-dlp` to extract comments
   --max-comments=i     : maximum number of comments (with --ytdlp-comments)
   --max-replies=i      : maximum number of replies per thread

 * Filtering
   --author=s           : search in videos uploaded by a specific user
   --duration=s         : filter search results based on video length
                          valid values: short long
   --captions!          : only videos with or without closed captions
   --order=s            : order the results using a specific sorting method
                          valid values: relevance rating upload_date view_count
   --time=s             : short videos published in a time period
                          valid values: hour today week month year
   --360!               : search only for 360° videos
   --hdr!               : search only for HDR videos
   --live!              : search only for live videos
   --vr180!             : search only for VR180 videos
   --vd=s               : set the video definition (any, hd, or 4k)
   --4k                 : shortcut for --video-definition=hd
   --hd                 : shortcut for --video-definition=4k
   --dimension=s        : set video dimension (any or 3d)
   --license=s          : set video license (any or creative_commons)
   --page=i             : get results starting with a specific page number
   --results=i          : how many results to display per page (max: 50)
   --hfr!               : prefer high frame rate (HFR) videos
   -2  -3  -4  -7  -1   : resolutions: 240p, 360p, 480p, 720p and 1080p
   -a  --audio          : prefer audio part only (implied by --novideo)
   --best               : prefer best resolution available
   --resolution=s       : supported resolutions: best, 2160p, 1440p,
                          1080p, 720p, 480p, 360p, 240p, 144p, audio.

 * Display local
   -P  --playlists       : show the local playlists
   -F  --favorites       : show the local favorite videos
   -lc --saved-channels  : show the saved channels
   -S  --subscriptions   : show the subscribed channels
   -L  --likes           : show the videos that you liked
   -D  --dislikes        : show the videos that you disliked

 * Save local
   --save=s             : save a given channel ID or username in -lc
   --subscribe=s        : subscribe to a given channel ID or username
   --favorite=s         : favorite a video by URL or ID
   --like=s             : like a video by URL or ID (see: -L)
   --dislike=s          : dislike a video by URL or ID (see: -D)


== Player Options ==

 * Arguments
   -f  --fullscreen!  : play videos in fullscreen mode
   -n  --novideo      : play audio only, without displaying video
   --append-arg=s     : append some command-line parameters to the media player
   --player=s         : select a player to stream videos
                        available players: @{[keys %{$CONFIG->{video_players}}]}


== Download Options ==

 * Download
   -d  --download!       : activate the download mode
   -dp --dl-play!        : play the video after download (with -d)
   -rp --rem-played!     : delete a local video after played (with -dp)
       --dl-in-subdir!   : download videos in subdirectories (with -d)
       --wget-dl!        : download videos with wget
       --skip-if-exists! : don't download videos which already exist (with -d)
       --copy-caption!   : copy and rename the caption for downloaded videos
       --downloads-dir=s : downloads directory (set: '$opt{downloads_dir}')
       --filename=s      : set a custom format for the video filename (see: -T)
       --fat32safe!      : makes filenames FAT32 safe
       --mkv-merge!      : merge audio and video into an MKV container
       --merge-captions! : include closed-captions in the MKV container
       --set-mtime!      : set the original file modification time

 * Convert
   --convert-cmd=s      : command for converting videos after download
                          which include the *IN* and *OUT* tokens
   --convert-to=s       : convert video to a specific format (with -d)
   --keep-original!     : keep the original video after converting


== Other Options ==

 * Behavior
   -A  --all!            : play the video results in order
   -B  --backwards!      : play the video results in reverse order
   -s  --shuffle!        : shuffle the results of videos
   -I  --interactive!    : interactive mode, prompting for user input
       --autoplay!       : autoplay mode, automatically playing related videos
       --std-input=s     : use this value as the first standard input
       --max-seconds=i   : ignore videos longer than i seconds
       --min-seconds=i   : ignore videos shorter than i seconds
       --get-term-width! : allow $execname to read your terminal width
       --skip-watched!   : don't play already watched videos
       --highlight!      : remember and highlight selected videos
       --confirm!        : show a confirmation message after each play
       --prefer-mp4!     : prefer videos in MP4 format, instead of VP9
       --prefer-av1!     : prefer videos in AV1 format, instead of VP9
       --prefer-m4a!     : prefer audios in AAC format, instead of OPUS
       --ignore-av1!     : ignore videos in AV1 format
       --audio-quality=s : audio quality: best, medium or low
       --force-fallback! : extract streaming URLs using the fallback method

 * Closed-captions
   --get-captions!      : download closed-captions for videos
   --auto-captions!     : include or exclude auto-generated captions
   --srt-languages=s    : comma separated list of preferred languages

 * Config
   --config=s           : configuration file
   --update-config!     : update the configuration file

 * Output
   -i  --info=s         : show information for a video ID or URL
   -e  --extract=s      : extract information from videos (see: -T)
       --extract-file=s : extract information from videos in this file
       --dump=format    : dump metadata information in `videoID.format` files
                          valid formats: json, perl
   -q  --quiet          : do not display any warning
       --really-quiet   : do not display any warning or output
       --video-info!    : show video information before playing
       --escape-info!   : quotemeta() the fields of the `--extract`
       --use-colors!    : enable or disable the ANSI colors for text

 * Formatting
    --custom-layout=s          : custom layout format for videos
    --custom-channel-layout=s  : custom layout format for channels
    --custom-playlist-layout=s : custom layout format for playlists

 * Other
   --invidious!         : prefer invidious instances over parsing YouTube
   --api=s              : set an API host from https://api.invidious.io/
   --api=auto           : use a random instance of invidious
   --skip-youtube-extraction! : Skip video/audio URL extraction, pass YouTube URL directly to player
   --dislikes-api!      : Enable dislikes from https://returnyoutubedislike.com
   --cookies=s          : file to read cookies from and dump cookie
   --user-agent=s       : specify a custom user agent
   --proxy=s            : set HTTP(S)/SOCKS proxy: 'proto://domain.tld:port/'
                          If authentication is required,
                          use 'proto://user:pass\@domain.tld:port/'
   --split-videos!      : include or exclude the itags for split videos
   --dash!              : include or exclude segmented DASH videos
   --ytdl!              : use youtube-dl for videos with encrypted signatures
                          `--no-ytdl` will use invidious instances
   --ytdl-cmd=s         : yt-dlp / youtube-dl command (default: $CONFIG{ytdl_cmd})

Help options:
   -T  --tricks         : show more 'hidden' features of $execname
   -E  --examples       : show several usage examples of $execname
   -H  --stdin-help     : show the valid stdin options for $execname
   -v  --version        : print version and exit
   -h  --help           : print help and exit
       --debug:1..3     : see behind the scenes

NOTES:
    !    -> the argument can be negated with '--no-'
    =i   -> requires an integer argument
    =s   -> requires an argument
    :s   -> can take an optional argument
    =s,s -> can take more arguments separated by commas

HELP
    main_quit(0);
}

sub wrap_text {
    my (%args) = @_;

    require Text::Wrap;
    local $Text::Wrap::columns = ($args{columns} || $term_width) - 8;

    my $text = "@{$args{text}}";
    $text =~ tr{\r}{}d;

    return eval { Text::Wrap::wrap($args{i_tab}, $args{s_tab}, $text) } // $text;
}

sub tricks {
    print <<"TRICKS";

                == pipe-viewer -- tips and tricks ==

-> Playing videos
 > To stream the videos in other players, you need to change the
   configuration file. Where it says "video_player_selected", change it
   to any player which is defined inside the "video_players" hash.

-> Arguments
 > Almost all boolean arguments can be negated with a "--no-" prefix.
 > Arguments that require an ID/URL, you can specify more than one,
   separated by whitespace (quoted), or separated by commas.

-> More STDIN help:
 > ":r", ":return" will return to the previous page of results.
   For example, if you search for playlists, then select a playlist
   of videos, inserting ":r" will return back to the playlist results.

 > "6" (quoted) will search for videos with the keyword '6'.

 > If a stdin option is followed by one or more digits, the equal sign,
   which separates the option from value, can be omitted.

   Example:
        :i2,4  is equivalent with :i=2,4
        :d1-5  is equivalent with :d=1,2,3,4,5
        :c10   is equivalent with :c=10

 > When more videos are selected to play, you can stop them by
   pressing CTRL+C. $execname will return to the previous section.

 > Space inside the values of STDIN options, can be either quoted
   or backslashed.

   Example:
        :re=video\\ title     ==     :re="video title"

 > ":anp" stands for "Auto Next Page". How do we use it?
   Well, let's search for some videos. Now, if we want to play
   only the videos matched by a regex, we'd say :re="REGEX".
   But, what if we want to play the videos from the next pages too?
   In this case, ":anp" is your friend. Use it wisely!

-> Special tokens:

  *ID*           : the YouTube video ID
  *AUTHOR*       : the author name of the video
  *CHANNELID*    : the channel ID of the video
  *RESOLUTION*   : the resolution of the video
  *VIEWS*        : the number of views
  *VIEWS_SHORT*  : the number of views in abbreviated notation
  *VIDEOS*       : the number of channel videos
  *VIDEOS_SHORT* : the number of channel videos in abbreviated notation
  *SUBS*         : the number of channel subscriptions
  *SUBS_SHORT*   : the number of channel subscriptions in abbreviated notation
  *ITEMS*        : the number of playlist items
  *ITEMS_SHORT*  : the number of playlist items in abbreviated notation
  *LIKES*        : the number of likes
  *DISLIKES*     : the number of dislikes (from Return YouTube Dislike)
  *RATING*       : the rating of the video (5-star scale from Return YouTube Dislike)
  *DURATION*     : the duration of the video in seconds
  *PUBLISHED*    : the publication date as "DD MMM YYYY"
  *AGE*          : the age of a video (N days, N months, N years)
  *AGE_SHORT*    : the abbreviated age of a video (Nd, Nm, Ny)
  *TIME*         : the duration of the video as "HH::MM::SS"
  *TITLE*        : the title of the video
  *FTITLE*       : the title of the video (filename safe)
  *DESCRIPTION*  : the description of the video

  *URL*      : the YouTube URL of the video
  *ITAG*     : the itag value of the video
  *FORMAT*   : the extension of the video (without the dot)

  *SUB*      : the local subtitle file (if any)
  *AUDIO*    : the audio URL of the video (only in DASH mode)
  *VIDEO*    : the video URL of the video (it might not contain audio)
  *AOV*      : audio URL (if any) or video URL (in this order)

-> Special escapes:
    \\t                  tab
    \\n                  newline
    \\r                  return
    \\f                  form feed
    \\b                  backspace
    \\a                  alarm (bell)
    \\e                  escape

-> Extracting information from videos:
 > Extracting information can be achieved by using the "--extract" command-line
   option which takes a given format as its argument, which is defined by using
   special tokens, special escapes or literals.

   Example:
     $execname --no-interactive --extract '*TITLE* (*ID*)' [URL]

-> Configuration file: $config_file

-> Donations gladly accepted:
    https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=75FUVBE6Q73T8

TRICKS
    main_quit(0);
}

sub examples {
    print <<"EXAMPLES";
==== COMMAND LINE EXAMPLES ====

Command: $execname -A -n russian music
Results: play all the video results (-A)
         only audio, no video (-n)
         search for "russian music"
Note:    -A will include the videos from the next pages as well.

Command: $execname --comments 'https://www.youtube.com/watch?v=U6_8oIPFREY'
Results: show video comments for a specific video URL or videoID.

Command: $execname --results=5 -up=khanacademy
Results: the most recent 5 playlists by a specific author (-up).

Command: $execname --author=MIT atom
Results: search only in videos by a specific author.

Command: $execname -S=vsauce
Results: get the subscriptions for a username.

Command: $execname cats --order=view_count --duration=short
Results: search for 'cats' videos, ordered by ViewCount and short duration.

Command: $execname -sc math lessons
Results: search for YouTube channels.


==== USER INPUT EXAMPLES ====

A STDIN option can begin with ':', ';' or '='.

Command: <ENTER>, :n, :next
Results: get the next page of results.

Command: :r, :return
Results: return to the previous page of results.

Command: :i4..6, :i7-9, :i20-4, :i2, :i=4, :info=4
Results: show extra information for the selected videos.

Command: :d5,2, :d=3, :download=8
Results: download the selected videos.

Command: :c2, :comments=4
Results: show comments for a selected video.

Command: :r4, :related=6
Results: show related videos for a selected video.

Command: :a14, :author=12
Results: show videos uploaded by the author who uploaded the selected video.

Command: :p9, :playlists=14
Results: show playlists created by the author who uploaded the selected video.

Command: :subscribe=7
Results: subscribe to the author's channel who uploaded the selected video.

Command: :like=2, :dislike=4,5
Results: like or dislike the selected videos.

Command: :fav=4, :favorite=3..5
Results: favorite the selected videos.

Command: 3, 5..7, 12-1, 9..4, 2 3 9
Results: play the selected videos.

Command: :q3,5, :q=4, :queue=3-9
Results: enqueue the selected videos to play them later.

Command: :pq, :play-queue
Results: play the videos enqueued by the :queue option.

Command: :re="^Linux"
Results: play videos matched by a regex.
Example: matches title: "Linux video"

Command: :regex='linux.*part \\d+/\\d+'
Example: matches title: "Introduction to Linux (part 1/4)"

Command: :anp 1 2 3
Results: play the first three videos from every page.

Command: :r, :return
Results: return to the previous section.
EXAMPLES
    main_quit(0);
}

sub stdin_help {
    print $complete_help;
    main_quit(0);
}

# Print version
sub version {
    print "$appname $version\n";
    main_quit(0);
}

sub apply_configuration {
    my ($opt, $keywords) = @_;

    if ($yv_obj->get_debug >= 2 or (defined($opt->{debug}) && $opt->{debug} >= 2)) {
        require Data::Dump;
        say "=>> Options with keywords: <@{$keywords}>";
        Data::Dump::pp($opt);
    }

    # ... BASIC OPTIONS ... #
    if (delete $opt->{quiet}) {
        close STDERR;
    }

    if (delete $opt->{really_quiet}) {
        close STDERR;
        close STDOUT;
    }

    # ... YOUTUBE OPTIONS ... #
    foreach my $option_name (
                             qw(
                             videoDuration maxResults
                             api_host date order
                             channelId region debug
                             http_proxy page comments_order
                             user_agent force_fallback
                             cookie_file timeout ytdl ytdl_cmd
                             prefer_mp4 prefer_av1 prefer_invidious
                             ytdlp_comments ytdlp_max_comments
                             ytdlp_max_replies
                             bypass_age_gate_native bypass_age_gate_with_proxy
                             dislikes_api
                             skip_youtube_extraction
                             )
      ) {

        if (defined $opt->{$option_name}) {
            my $code      = \&{"WWW::PipeViewer::set_$option_name"};
            my $value     = $opt->{$option_name};                      # don't delete the value!
            my $set_value = $yv_obj->$code($value);

            if (not defined($set_value) or $set_value ne $value) {
                warn "\n[!] Invalid value <$value> for option <$option_name>\n";
            }
        }
    }

    my @feature_options = qw(
      360              1                 360
      hdr              1                 hdr
      live             1                 live
      vr180            1                 vr180
      videoCaption     1                 subtitles
      videoDimension   3d                3d
      videoLicense     creative_commons  creative_commons
    );

    while (scalar @feature_options) {
        my ($option_name, $option_value, $feature) = splice(@feature_options, 0, 3);
        if (defined $opt->{$option_name}) {
            toggle_features($opt->{$option_name} eq $option_value, $feature);
        }
    }

    if (defined $opt->{videoDefinition}) {
        my $vd = $opt->{videoDefinition};
        toggle_features($vd eq 'hd', 'hd');
        toggle_features($vd eq '4k', '4k');
    }

    if (scalar @FEATURES) {
        $yv_obj->set_features(\@FEATURES);
    }

    if (defined $opt->{author}) {
        my $name = delete $opt->{author};
        if (my $id = extract_channel_id($name)) {

            if (not $yv_utils->is_channelID($id)) {
                $id = $yv_obj->channel_id_from_username($id) // do {
                    warn_invalid("username or channel ID", $id);
                    undef;
                };
            }

            $yv_obj->set_channelId($id);
        }
        else {
            warn_invalid("username or channel ID", $name);
        }
    }

    # ... OTHER OPTIONS ... #
    if (defined $opt->{extract_info_file}) {
        open my $fh, '>:utf8', delete($opt->{extract_info_file});
        $opt{extract_info_fh} = $fh;
    }

    if (defined $opt->{colors}) {
        $opt{_colors} = $opt->{colors};
        if (delete $opt->{colors}) {
            require Term::ANSIColor;
            no warnings 'redefine';
            *colored    = \&Term::ANSIColor::colored;
            *colorstrip = \&Term::ANSIColor::colorstrip;
        }
        else {
            no warnings 'redefine';
            *colored    = sub { $_[0] };
            *colorstrip = sub { $_[0] };
        }
    }

    # ... SUBROUTINE CALLS ... #
    if (defined $opt->{subscribe}) {
        foreach my $channel_id (split(/[,\s]+/, delete $opt->{subscribe})) {
            subscribe_channel($channel_id);
        }
    }

    if (defined $opt->{save_channel}) {
        foreach my $channel_id (split(/[,\s]+/, delete $opt->{save_channel})) {
            save_channel($channel_id);
        }
    }

    if (defined $opt->{favorite_video}) {
        favorite_videos(split(/[,\s]+/, delete $opt->{favorite_video}));
    }

    if (defined $opt->{like_video}) {
        rate_videos('like', split(/[,\s]+/, delete $opt->{like_video}));
    }

    if (defined $opt->{dislike_video}) {
        rate_videos('dislike', split(/[,\s]+/, delete $opt->{dislike_video}));
    }

    if (defined $opt->{play_video_ids}) {
        get_and_play_video_ids(split(/[,\s]+/, delete $opt->{play_video_ids}));
    }

    if (defined $opt->{play_playlists}) {
        get_and_play_playlists(split(/[,\s]+/, delete $opt->{play_playlists}));
    }

    if (defined $opt->{saved_channels}) {
        print_saved_channels(delete $opt->{saved_channels});
    }

    if (defined $opt->{subscribed_channels}) {
        print_subscribed_channels(delete $opt->{subscribed_channels});
    }

    if (defined $opt->{local_playlist}) {
        print_local_playlist(delete $opt->{local_playlist});
    }

    if (defined $opt->{playlist_id}) {
        my $playlistID = get_valid_playlist_id(delete($opt->{playlist_id})) // return;
        get_and_print_videos_from_playlist($playlistID);
    }

    if (delete $opt->{search_videos}) {
        print_videos($yv_obj->search_videos([@{$keywords}]));
    }

    if (delete $opt->{search_channels}) {
        print_channels($yv_obj->search_channels([@{$keywords}]));
    }

    if (delete $opt->{search_playlists}) {
        print_playlists($yv_obj->search_playlists([@{$keywords}]));
    }

    if (delete $opt->{search_movies}) {
        print_videos($yv_obj->search_movies([@{$keywords}]));
    }

    if (delete $opt->{categories}) {
        print_categories($yv_obj->video_categories);
    }

    if (delete $opt->{watched_videos}) {
        print_watched_videos();
    }

    if (delete $opt->{local_subscriptions}) {
        print_local_subscription_videos();
    }

    if (defined $opt->{uploads}) {
        my $str = delete $opt->{uploads};

        if ($str) {
            if (my $id = extract_channel_id($str)) {
                print_videos($yv_obj->uploads($id));
            }
            else {
                warn_invalid("username or channel ID", $str);
            }
        }
        else {
            warn_invalid("username or channel ID", $str);
        }
    }

    if (defined $opt->{streams}) {
        my $str = delete $opt->{streams};

        if ($str) {
            if (my $id = extract_channel_id($str)) {
                print_videos($yv_obj->streams($id));
            }
            else {
                warn_invalid("username or channel ID", $str);
            }
        }
        else {
            warn_invalid("username or channel ID", $str);
        }
    }

    if (defined $opt->{shorts}) {
        my $str = delete $opt->{shorts};

        if ($str) {
            if (my $id = extract_channel_id($str)) {
                print_videos($yv_obj->shorts($id));
            }
            else {
                warn_invalid("username or channel ID", $str);
            }
        }
        else {
            warn_invalid("username or channel ID", $str);
        }
    }

    if (defined $opt->{popular_videos}) {
        my $str = delete $opt->{popular_videos};

        if ($str eq '') {
            print_videos($yv_obj->trending_videos_from_category('popular'));
        }
        elsif (my $id = extract_channel_id($str)) {

            if (not $yv_utils->is_channelID($id)) {
                $id = $yv_obj->channel_id_from_username($id) // do {
                    warn_invalid("username or channel ID", $id);
                    undef;
                };
            }

            print_videos($yv_obj->popular_videos($id));
        }
        else {
            warn_invalid("username or channel ID", $str);
        }
    }

    if (defined $opt->{popular_streams}) {
        my $str = delete $opt->{popular_streams};

        if (my $id = extract_channel_id($str)) {

            if (not $yv_utils->is_channelID($id)) {
                $id = $yv_obj->channel_id_from_username($id) // do {
                    warn_invalid("username or channel ID", $id);
                    undef;
                };
            }

            print_videos($yv_obj->popular_streams($id));
        }
        else {
            warn_invalid("username or channel ID", $str);
        }
    }

    if (defined $opt->{popular_shorts}) {
        my $str = delete $opt->{popular_shorts};

        if (my $id = extract_channel_id($str)) {

            if (not $yv_utils->is_channelID($id)) {
                $id = $yv_obj->channel_id_from_username($id) // do {
                    warn_invalid("username or channel ID", $id);
                    undef;
                };
            }

            print_videos($yv_obj->popular_shorts($id));
        }
        else {
            warn_invalid("username or channel ID", $str);
        }
    }

    if (defined $opt->{trending}) {
        my $cat_id = delete $opt->{trending};
        print_videos($yv_obj->trending_videos_from_category($cat_id));
    }

    if (defined $opt->{related_videos}) {
        get_and_print_related_videos(split(/[,\s]+/, delete($opt->{related_videos})));
    }

    if (defined $opt->{playlists}) {
        my $str = delete($opt->{playlists});

        if ($str) {
            if (my $id = extract_channel_id($str)) {
                print_playlists($yv_obj->playlists($id));
            }
            else {
                warn_invalid("username or channel ID", $str);
                warn colored("[+] To search for playlists, try: $0 -sp $str", 'bold yellow') . "\n";
            }
        }
        else {
            print_local_playlist();
        }
    }

    if (defined delete $opt->{favorites}) {
        print_favorite_videos();
    }

    if (defined delete $opt->{likes}) {
        print_liked_videos();
    }

    if (defined delete $opt->{dislikes}) {
        print_disliked_videos();
    }

    if (defined $opt->{get_comments}) {
        get_and_print_comments(split(/[,\s]+/, delete($opt->{get_comments})));
    }

    if (defined $opt->{print_video_info}) {
        get_and_print_video_info(split(/[,\s]+/, delete $opt->{print_video_info}));
    }
}

sub parse_arguments {
    my ($keywords) = @_;

    state $x = do {
        require Getopt::Long;
        Getopt::Long::Configure('no_ignore_case');
    };

    my %orig_opt         = %opt;
    my $orig_config_file = "$config_file";

    Getopt::Long::GetOptions(

        # Main options
        'help|usage|h|?'        => \&help,
        'examples|E'            => \&examples,
        'stdin-help|shelp|sh|H' => \&stdin_help,
        'tricks|tips|T'         => \&tricks,
        'version|v'             => \&version,

        'config=s'       => \$config_file,
        'update-config!' => sub { dump_configuration($config_file) },

        # Resolutions
        'audio|a' => sub { $opt{resolution} = 'audio' },
        '144p'    => sub { $opt{resolution} = 144 },
        '240p|2'  => sub { $opt{resolution} = 240 },
        '360p|3'  => sub { $opt{resolution} = 360 },
        '480p|4'  => sub { $opt{resolution} = 480 },
        '720p|7'  => sub { $opt{resolution} = 720 },
        '1080p|1' => sub { $opt{resolution} = 1080 },
        '1440p'   => sub { $opt{resolution} = 1440 },
        '2160p'   => sub { $opt{resolution} = 2160 },
        'best'    => sub { $opt{resolution} = 'best' },

        'hfr!'             => \$opt{hfr},
        'res|resolution=s' => \$opt{resolution},

        'comments=s'       => \$opt{get_comments},
        'comments-order=s' => \$opt{comments_order},

        'yt-dlp-comments|ytdlp-comments!' => \$opt{ytdlp_comments},
        'max-comments=i'                  => \$opt{ytdlp_max_comments},
        'max-replies=s'                   => \$opt{ytdlp_max_replies},    # also supports "all"

        'c|categories'                => \$opt{categories},
        'video-ids|videoids|id|ids=s' => \$opt{play_video_ids},

        'lc|fc|local-channels|saved-channels:s' => \$opt{saved_channels},
        'subscriptions|sub-channels|S:s'        => \$opt{subscribed_channels},
        'lp|local-playlists:s'                  => \$opt{local_playlist},
        'wv|watched-videos'                     => \$opt{watched_videos},
        'ls|local-subs|sub-videos|SV'           => \$opt{local_subscriptions},
        'subscription-results=s'                => \$opt{subscription_results},

        #'save-video|save=s' => \$opt{save_video},
        'save|save-channel=s' => \$opt{save_channel},

        #'save-playlist=s'   => \$opt{save_playlist},

        'search-videos|search|sv!'     => \$opt{search_videos},
        'search-channels|channels|sc!' => \$opt{search_channels},
        'search-playlists|sp|p!'       => \$opt{search_playlists},
        'search-movie|movies|sm!'      => \$opt{search_movies},

        'uploads|U|user|user-videos|uv|u=s' => \$opt{uploads},
        'streams|user-streams|us=s'         => \$opt{streams},
        'shorts|user-shorts=s'              => \$opt{shorts},
        'favorites|F'                       => \$opt{favorites},
        'playlists|P|user-playlists|up:s'   => \$opt{playlists},
        'likes|L|user-likes'                => \$opt{likes},
        'dislikes|D'                        => \$opt{dislikes},
        'subscribe=s'                       => \$opt{subscribe},

        'trending|trends:s' => \$opt{trending},
        'playlist-id|pid=s' => \$opt{playlist_id},

        # English-UK friendly
        'favorite|favourite|favorite-video|favourite-video|fav=s' => \$opt{favorite_video},

        'related-videos|rv=s'           => \$opt{related_videos},
        'popular-videos|popular|pv:s'   => \$opt{popular_videos},
        'popular-streams|pstreams|ps=s' => \$opt{popular_streams},
        'popular-shorts|pshorts=s'      => \$opt{popular_shorts},

        'cookie-file|cookies=s'          => \$opt{cookie_file},
        'user-agent|agent=s'             => \$opt{user_agent},
        'http-proxy|https-proxy|proxy=s' => \$opt{http_proxy},

        'r|region|region-code=s' => \$opt{region},

        'order|order-by|sort|sort-by=s' => \$opt{order},
        'time|date=s'                   => \$opt{date},

        'duration=s' => \$opt{videoDuration},

        'max-seconds|max_seconds=i' => \$opt{max_seconds},
        'min-seconds|min_seconds=i' => \$opt{min_seconds},

        'like=s'                     => \$opt{like_video},
        'dislike=s'                  => \$opt{dislike_video},
        'author=s'                   => \$opt{author},
        'all|A|play-all!'            => \$opt{play_all},
        'backwards|B!'               => \$opt{play_backwards},
        'input|std-input=s'          => \$opt{std_input},
        'use-colors|colors|colored!' => \$opt{colors},

        'autoplay!' => \$opt{autoplay_mode},

        'play-playlists|pp=s'      => \$opt{play_playlists},
        'debug:1'                  => \$opt{debug},
        'download|dl|d!'           => \$opt{download_video},
        'dimension=s'              => \$opt{videoDimension},
        'license=s'                => \$opt{videoLicense},
        'vd|video-definition=s'    => \$opt{videoDefinition},
        'hd'                       => sub { $opt{videoDefinition} = 'hd' },
        '4k'                       => sub { $opt{videoDefinition} = '4k' },
        '360!'                     => \$opt{360},
        'hdr!'                     => \$opt{hdr},
        'live!'                    => \$opt{live},
        'vr180!'                   => \$opt{vr180},
        'I|interactive!'           => \$opt{interactive},
        'convert-to|convert_to=s'  => \$opt{convert_to},
        'keep-original-video!'     => \$opt{keep_original_video},
        'e|extract|extract-info=s' => \$opt{extract_info},
        'extract-file=s'           => \$opt{extract_info_file},
        'escape-info!'             => \$opt{escape_info},

        'dump=s' => sub {
            my (undef, $format) = @_;
            $opt{dump} = (
                ($format =~ /json/i) ? 'json' : ($format =~ /perl/i) ? 'perl' : do {
                    warn "[!] Invalid format <<$format>> for option --dump\n";
                    undef;
                }
            );
        },

        # Set a video player
        'player|vplayer|video-player|video_player=s' => sub {

            if (not exists $opt{video_players}{$_[1]}) {
                die "[!] Unknown video player selected: <<$_[1]>>\n";
            }

            $opt{video_player_selected} = $_[1];
        },

        'append-arg|append-args=s' => \$PLAYER_ARGS{user_defined_arguments},

        # Others
        'captions!'        => \$opt{videoCaption},
        'fullscreen|fs|f!' => \$opt{fullscreen},
        'split-videos!'    => \$opt{split_videos},
        'confirm!'         => \$opt{confirm},

        'prefer-mp4!' => \$opt{prefer_mp4},
        'prefer-av1!' => \$opt{prefer_av1},
        'ignore-av1!' => \$opt{ignore_av1},

        'invidious|prefer-invidious!' => \$opt{prefer_invidious},
        'fallback|force-fallback!'    => \$opt{force_fallback},

        'custom-layout-format=s'          => \$opt{custom_layout_format},
        'custom-channel-layout-format=s'  => \$opt{custom_channel_layout_format},
        'custom-playlist-layout-format=s' => \$opt{custom_playlist_layout_format},

        'merge-into-mkv|mkv-merge!'           => \$opt{merge_into_mkv},
        'merge-with-captions|merge-captions!' => \$opt{merge_with_captions},
        'set-mtime|mtime!'                    => \$opt{set_mtime},

        'api-host|instance=s' => \$opt{api_host},

        'convert-command|convert-cmd=s'     => \$opt{convert_cmd},
        'prefer-m4a!'                       => \$opt{prefer_m4a},
        'audio-quality=s'                   => \$opt{audio_quality},
        'dash|dash-segmented!'              => \$opt{dash},
        'wget-dl|wget-download!'            => \$opt{download_with_wget},
        'filename|filename-format=s'        => \$opt{video_filename_format},
        'rp|rem-played|remove-played-file!' => \$opt{remove_played_file},
        'info|i=s'                          => \$opt{print_video_info},
        'get-term-width!'                   => \$opt{get_term_width},
        'page=i'                            => \$opt{page},
        'novideo|no-video|n!'               => \$opt{novideo},
        'highlight!'                        => \$opt{highlight_watched},
        'skip-watched!'                     => \$opt{skip_watched},
        'results=i'                         => \$opt{maxResults},
        'shuffle|s!'                        => \$opt{shuffle},
        'pos|position=i'                    => \$opt{position},

        'ytdl!'      => \$opt{ytdl},
        'ytdl-cmd=s' => \$opt{ytdl_cmd},

        'quiet|q!'      => \$opt{quiet},
        'really-quiet!' => \$opt{really_quiet},
        'video-info!'   => \$opt{show_video_info},

        'dp|downl-play|download-and-play|dl-play!' => \$opt{download_and_play},
        'download-in-subdir|dl-in-subdir!'         => \$opt{download_in_subdir},

        'get-captions|get_captions!'     => \$opt{get_captions},
        'auto-captions|auto_captions!'   => \$opt{auto_captions},
        'srt-languages=s'                => \$opt{srt_languages},
        'copy-caption|copy_caption!'     => \$opt{copy_caption},
        'skip-if-exists|skip_if_exists!' => \$opt{skip_if_exists},
        'downloads-dir|download-dir=s'   => \$opt{downloads_dir},
        'fat32safe!'                     => \$opt{fat32safe},

        "skip-youtube-extraction!" => \$opt{skip_youtube_extraction},
        'dislikes-api!'            => \$opt{dislikes_api},
      )
      or warn "[!] Error in command-line arguments!\n";

    if ($config_file ne $orig_config_file) {    # load the config file specified with `--config=s`
        ##say ":: Loading config: $config_file";
        $config_file = rel2abs($config_file);

        my %new_opt = %opt;
        load_config($config_file);

        foreach my $key (keys %new_opt) {
            if (    defined($new_opt{$key})
                and defined($orig_opt{$key})
                and $new_opt{$key} ne $orig_opt{$key}) {
                $opt{$key} = $new_opt{$key};
            }
        }
    }

    apply_configuration(\%opt, $keywords);
}

# Parse the arguments
if (@ARGV) {
    require Encode;
    @ARGV = map { Encode::decode_utf8($_) } @ARGV;
    parse_arguments(\@ARGV);
}

for (my $i = 0 ; $i <= $#ARGV ; $i++) {
    my $arg = $ARGV[$i];

    next if (substr($arg, 0, 1) eq q{-});

    if (youtube_urls($arg)) {
        splice(@ARGV, $i--, 1);
    }
}

if (my @keywords = grep { substr($_, 0, 1) ne q{-} } @ARGV) {
    print_videos($yv_obj->search_videos(\@keywords));
}
elsif ($opt{interactive} and -t) {
    first_user_input();
}
elsif ($opt{interactive} and -t STDOUT and not -t) {
    print_videos($yv_obj->search_videos(scalar <STDIN>));
}
else {
    main_quit($opt{_error} || 0);
}

sub get_valid_video_id {
    my ($value) = @_;

    my $id =
        $value =~ /$get_video_id_re/   ? $+{video_id}
      : $value =~ /$valid_video_id_re/ ? $value
      :                                  undef;

    if (not defined $id) {
        warn_invalid('videoID', $value);
        return;
    }

    return $id;
}

sub get_valid_playlist_id {
    my ($value) = @_;

    my $id =
        $value =~ /$get_playlist_id_re/   ? $+{playlist_id}
      : $value =~ /$valid_playlist_id_re/ ? $value
      :                                     undef;

    if (not defined $id) {
        warn_invalid('playlistID', $value);
        return;
    }

    return $id;
}

sub extract_channel_id {
    my ($str) = @_;

    if ($str =~ /$get_channel_videos_id_re/) {
        return $+{channel_id};
    }

    if ($str =~ /$get_username_videos_re/) {
        return $+{username};
    }

    if ($str =~ /$valid_channel_id_re/) {
        return $+{channel_id};
    }

    if ($str =~ /^[-a-zA-Z0-9_]+\z/) {
        return $str;
    }

    return undef;
}

sub apply_input_arguments {
    my ($args, $keywords) = @_;

    if (@{$args}) {
        local @ARGV = @{$args};
        parse_arguments($keywords);
    }

    return 1;
}

# Get term width
sub get_term_width {
    return $term_width if $constant{win32};
    $term_width = (-t STDOUT) ? ((split(q{ }, `stty size`))[1] || $term_width) : $term_width;
}

sub first_user_input {
    my @keys = get_input_for_first_time();

    state $first_input_help = <<"HELP";

$base_options
$other_options
$notes_options
** Example:
    To search for playlists, insert: -p keywords
HELP

    if (scalar(@keys)) {
        my @for_search;
        foreach my $key (@keys) {
            if ($key =~ /$valid_opt_re/) {

                my $opt = $1;

                if (general_options(opt => $opt)) {
                    ## ok
                }
                elsif ($opt =~ /^(?:h|help)\z/) {
                    print $first_input_help;
                    press_enter_to_continue();
                }
                elsif ($opt =~ /^(?:r|return)\z/) {
                    return;
                }
                else {
                    warn_invalid('option', $opt);
                    print "\n";
                    exit 1;
                }
            }
            elsif (youtube_urls($key)) {
                ## ok
            }
            else {
                push @for_search, $key;
            }
        }

        if (scalar(@for_search) > 0) {
            print_videos($yv_obj->search_videos(\@for_search));
        }
        else {
            __SUB__->();
        }
    }
    else {
        __SUB__->();
    }
}

sub get_quotewords {
    require Text::ParseWords;
    Text::ParseWords::quotewords(@_);
}

sub clear_title {
    my ($title) = @_;

    $title //= "";
    $title =~ s/[^\w\s[:punct:]]//g;
    $title = join(' ', split(' ', $title));

    return $title;
}

# Straight copy of parse_options() from Term::UI
sub _parse_options {
    my ($input) = @_;

    my $return = {};
    while (   $input =~ s/(?:^|\s+)--?([-\w]+=(["']).+?\2)(?=\Z|\s+)//
           or $input =~ s/(?:^|\s+)--?([-\w]+=\S+)(?=\Z|\s+)//
           or $input =~ s/(?:^|\s+)--?([-\w]+)(?=\Z|\s+)//) {
        my $match = $1;

        if ($match =~ /^([-\w]+)=(["'])(.+?)\2$/) {
            $return->{$1} = $3;

        }
        elsif ($match =~ /^([-\w]+)=(\S+)$/) {
            $return->{$1} = $2;

        }
        elsif ($match =~ /^no-?([-\w]+)$/i) {
            $return->{$1} = 0;

        }
        elsif ($match =~ /^([-\w]+)$/) {
            $return->{$1} = 1;
        }
    }

    return wantarray ? ($return, $input) : $return;
}

sub parse_options2 {
    my ($input) = @_;

    warn(colored("\n[!] Input with an odd number of quotes: <$input>", 'bold red') . "\n\n")
      if $yv_obj->get_debug;

    my ($args, $keywords) = _parse_options($input);

    my @args =
      map { $args->{$_} eq '0' ? "--no-$_" : $args->{$_} eq '1' ? "--$_" : "--$_=$args->{$_}" } keys %{$args};

    return wantarray ? (\@args, [split q{ }, $keywords]) : \@args;
}

sub parse_options {
    my ($input) = @_;
    my (@args, @keywords);

    if (not defined($input) or $input eq q{}) {
        return \@args, \@keywords;
    }

    foreach my $word (get_quotewords(qr/\s+/, 0, $input)) {
        if (substr($word, 0, 1) eq q{-}) {
            push @args, $word;
        }
        else {
            push @keywords, $word;
        }
    }

    if (not @args and not @keywords) {
        return parse_options2($input);
    }

    return wantarray ? (\@args, \@keywords) : \@args;
}

sub get_user_input {
    my ($text) = @_;

    if (not $opt{interactive}) {
        if (not defined $opt{std_input}) {
            return ':return';
        }
    }

    my $input = unpack(
        'A*',
        defined($opt{std_input})
        ? delete($opt{std_input})
        : (
           do {
               my @lines = split(/\R/, $text);
               say for @lines[0 .. $#lines - 1];
               $term->readline($lines[-1]);
             }
             // return ':return'
          )
    ) =~ s/^\s+//r;

    return q{:next} if $input eq q{};    # <ENTER> for the next page

    require Encode;
    $input = Encode::decode_utf8($input);

    my ($args, $keywords) = parse_options($input);

    if ($opt{history}) {
        my $str = join(' ', grep { /\w/ } @{$args}, @{$keywords});
        if ($str ne '' and $str !~ /^[0-9]{1,3}\z/) {
            eval { $term->append_history(1, $opt{history_file}) };
        }
    }

    apply_input_arguments($args, $keywords);
    return @{$keywords};
}

sub favorite_videos {
    my (@videos) = @_;

    foreach my $video_data (@videos) {
        prepend_video_data_to_file($video_data, $favorite_videos_data_file);
    }

    return 1;
}

sub rate_videos {
    my $rating = shift;

    my $file = ($rating eq 'like') ? $liked_videos_data_file : $disliked_videos_data_file;

    foreach my $video_data (@_) {
        prepend_video_data_to_file($video_data, $file);
    }

    return 1;
}

sub get_and_play_video_ids {
    (my @ids = grep { defined($_) } map { get_valid_video_id($_) } @_) || return;
    my @starts = map { $yv_utils->get_start_time_from_url($_) } @_;

    foreach my $i (0 .. $#ids) {
        my ($id, $start) = ($ids[$i], $starts[$i]);
        my $info = $yv_obj->video_details($id);

        if (ref($info) eq 'HASH' and keys %$info) {
            ## OK
        }
        else {
            $info->{title}         = "unknown";
            $info->{lengthSeconds} = 0;
            $info->{videoId}       = $id;
            warn_cant_do('get info for', $id);
        }
        $info->{startTime} = $start // 0;

        play_videos([$info]) || return;
    }

    return 1;
}

sub get_and_play_playlists {
    foreach my $id (@_) {
        my $videos = $yv_obj->videos_from_playlist_id(get_valid_playlist_id($id) // next);
        local $opt{play_all} = length($opt{std_input}) ? 0 : 1;
        print_videos($videos, auto => $opt{play_all});
    }
    return 1;
}

sub get_and_print_video_info {
    foreach my $id (@_) {

        my $videoID = get_valid_video_id($id) // next;
        my $info    = $yv_obj->video_details($videoID);

        if (ref($info) eq 'HASH' and keys %$info) {
            local $opt{show_video_info} = 1;
            print_video_info($info);
        }
        else {
            warn_cant_do('get info for', $videoID);
        }
    }
    return 1;
}

sub get_and_print_related_videos {
    foreach my $id (@_) {
        my $videoID = get_valid_video_id($id) // next;
        my $results = $yv_obj->related_to_videoID($videoID);
        print_videos($results);
    }
    return 1;
}

sub get_and_print_comments {
    foreach my $id (@_) {
        my $videoID  = get_valid_video_id($id) // next;
        my $comments = $yv_obj->comments_from_video_id($videoID);
        print_comments($comments, $videoID);
    }
    return 1;
}

sub get_and_print_videos_from_playlist {
    my ($playlistID) = @_;

    if ($playlistID =~ /$valid_playlist_id_re/) {
        my $info = $yv_obj->videos_from_playlist_id($playlistID);
        if ($yv_utils->has_entries($info)) {
            print_videos($info);
        }
        else {
            warn colored("\n[!] Inexistent playlist...", 'bold red') . "\n";
            return;
        }
    }
    else {
        warn_invalid('playlistID', $playlistID);
        return;
    }

    return 1;
}

sub _bold_color {
    my ($text) = @_;
    return colored($text, 'bold');
}

sub youtube_urls {
    my ($arg) = @_;

    if ($yv_utils->is_channelID($arg)) {
        print_videos($yv_obj->uploads($arg));
    }
    elsif ($yv_utils->is_playlistID($arg)) {
        get_and_print_videos_from_playlist($arg);
    }
    elsif ($arg =~ /$get_video_id_re/) {
        get_and_play_video_ids($arg);
    }
    elsif ($arg =~ /$get_playlist_id_re/) {
        get_and_print_videos_from_playlist($+{playlist_id});
    }
    elsif ($arg =~ /$get_channel_playlists_id_re/) {
        print_playlists($yv_obj->playlists($+{channel_id}));
    }
    elsif ($arg =~ /$get_channel_videos_id_re/) {
        print_videos($yv_obj->uploads($+{channel_id}));
    }
    elsif ($arg =~ /$get_username_playlists_re/) {
        print_playlists($yv_obj->playlists($+{username}));
    }
    elsif ($arg =~ /$get_username_videos_re/) {
        print_videos($yv_obj->uploads($+{username}));
    }
    else {
        return;
    }

    return 1;
}

sub general_options {
    my %args = @_;

    my $url      = $args{url};
    my $option   = $args{opt};
    my $callback = $args{sub};
    my $results  = $args{res};
    my $info     = $args{info};

    my $token     = undef;
    my $has_token = 0;

    if (ref($info->{results}) eq 'HASH' and exists $info->{results}{continuation}) {
        $has_token = 1;
        $token     = $info->{results}{continuation};
    }

    if (not defined($option)) {
        return;
    }

    if ($option =~ /^(?:q|quit|exit)\z/) {
        main_quit(0);
    }
    elsif ($option =~ /^(?:n|next)\z/ and (defined($url) or ref($token) eq 'CODE')) {
        if ($has_token) {
            if (defined $token) {
                my $request = $yv_obj->next_page($url, $token);
                $callback->($request);
            }
            else {
                warn_last_page();
            }
        }
        else {
            my $request = $yv_obj->next_page($url);
            $callback->($request);
        }
    }
    elsif ($option =~ /^(?:R|refresh)\z/ and defined($url)) {
        ##@{$results} = @{$yv_obj->_get_results($url)->{results}};
    }
    elsif ($option =~ /^dv${digit_or_equal_re}(.*)/ and ref($results) eq 'ARRAY') {
        if (my @nums = get_valid_numbers($#{$results}, $1)) {
            print "\n";
            foreach my $num (@nums) {
                require Data::Dump;
                say Data::Dump::pp($results->[$num]);
            }
            press_enter_to_continue();
        }
        else {
            warn_no_thing_selected('result');
        }
    }
    elsif ($option =~ /^v(?:ideoids?)?=(.*)/) {
        if (my @ids = split(/[,\s]+/, $1)) {
            get_and_play_video_ids(@ids);
        }
        else {
            warn colored("\n[!] No video ID specified!", 'bold red') . "\n";
        }
    }
    elsif ($option =~ /^playlist(?:ID)?=(.*)/) {
        get_and_print_videos_from_playlist($1);
    }
    else {
        return;
    }

    return 1;
}

sub warn_no_results {
    warn colored("\n[!] No $_[0] results!", 'bold red') . "\n";
}

sub warn_invalid {
    my ($name, $option) = @_;
    warn colored("\n[!] Invalid $name: <$option>", 'bold red') . "\n";
}

sub warn_cant_do {
    my ($action, @ids) = @_;

    foreach my $videoID (@ids) {
        warn colored("\n[!] Can't $action video: " . sprintf($opt{youtube_video_url}, $videoID), 'bold red') . "\n";

        my %info = $yv_obj->_get_video_info($videoID);
        my $resp = parse_json_string($info{player_response} // next);

        if (eval { exists($resp->{playabilityStatus}) and $resp->{playabilityStatus}{status} =~ /error/i }) {
            warn colored("[+] Reason: $resp->{playabilityStatus}{reason}.", 'bold yellow') . "\n";
        }
    }
}

sub warn_last_page {
    warn colored("\n[!] This is the last page!", "bold red") . "\n";
}

sub warn_first_page {
    warn colored("\n[!] This is the first page!", 'bold red') . "\n";
}

sub warn_no_thing_selected {
    warn colored("\n[!] No $_[0] selected!", 'bold red') . "\n";
}

# ... GET INPUT SUBS ... #
sub get_input_for_first_time {
    return get_user_input(_bold_color("\n=>> Search for YouTube videos (:h for help)") . "\n> ");
}

sub get_input_for_channels {
    return get_user_input(_bold_color("\n=>> Select a channel (:h for help)") . "\n> ");
}

sub get_input_for_search {
    return get_user_input(_bold_color("\n=>> Select one or more videos to play (:h for help)") . "\n> ");
}

sub get_input_for_playlists {
    return get_user_input(_bold_color("\n=>> Select a playlist (:h for help)") . "\n> ");
}

sub get_input_for_comments {
    return get_user_input(_bold_color("\n=>> Press <ENTER> for the next page of comments (:h for help)") . "\n> ");
}

sub get_input_for_categories {
    return get_user_input(_bold_color("\n=>> Select a category (:h for help)") . "\n> ");
}

sub ask_yn {
    my (%opt) = @_;
    my $c = join('/', map { $_ eq $opt{default} ? ucfirst($_) : $_ } qw(y n));

    my $answ;
    do {
        $answ = lc($term->readline($opt{prompt} . " [$c]: "));
        $answ = $opt{default} unless $answ =~ /\S/;
    } while ($answ !~ /^y(?:es)?$/ and $answ !~ /^no?$/);

    return chr(ord($answ)) eq 'y';
}

sub get_reply {
    my (%opt) = @_;

    my $default = 1;
    while (my ($i, $choice) = each @{$opt{choices}}) {
        print "\n" if $i == 0;
        printf("%3d> %s\n", $i + 1, $choice);
        if ($choice eq $opt{default}) {
            $default = $i + 1;
        }
    }
    print "\n";

    my $answ;
    do {
        $answ = $term->readline($opt{prompt} . " [$default]: ");
        $answ = $default unless $answ =~ /\S/;
    } while ($answ !~ /^[0-9]+\z/ or $answ < 1 or $answ > @{$opt{choices}});

    return $opt{choices}[$answ - 1];
}

sub valid_num {
    my ($num, $array_ref) = @_;
    return $num =~ /^[0-9]{1,3}\z/ && $num != 0 && $num <= @{$array_ref};
}

sub adjust_width {
    my ($str, $len, $prepend) = @_;

    if ($len <= 0) {
        return $str;
    }

    state $pkg = (
        eval {
            require Unicode::GCString;
            Unicode::GCString->new('test');
            'Unicode::GCString';
        } // eval {
            require Text::CharWidth;
            'Text::CharWidth';
        } // do {
            warn "[WARN] Please install Unicode::GCString or Text::CharWidth in order to use this functionality.\n";
            '';
        }
    );

    my $adjust_str = sub {

        # Unicode::GCString
        if ($pkg eq 'Unicode::GCString') {

            my $gcstr     = Unicode::GCString->new($str);
            my $str_width = $gcstr->columns;

            while ($str_width > $len) {
                $gcstr     = $gcstr->substr(0, -1);
                $str_width = $gcstr->columns;
            }

            $str = $gcstr->as_string;
            return ($str, $str_width);
        }

        # Text::CharWidth
        if ($pkg eq 'Text::CharWidth') {

            my $str_width = Text::CharWidth::mbswidth($str);

            while ($str_width > $len) {
                chop $str;
                $str_width = Text::CharWidth::mbswidth($str);
            }

            return ($str, $str_width);
        }

        # Fallback to counting graphemes
        my @graphemes = $str =~ /(\X)/g;

        while (scalar(@graphemes) > $len) {
            pop @graphemes;
        }

        $str = join('', @graphemes);
        return ($str, scalar(@graphemes));
    };

    my ($new_str, $str_width) = $adjust_str->();

    my $spaces = ' ' x ($len - $str_width);
    my $result = $prepend ? join('', $spaces, $new_str) : join('', $new_str, $spaces);

    return $result;
}

sub format_line_result {
    my ($i, $entry, $info, %args) = @_;

    if (ref($entry) eq '') {
        $entry =~ s/\*NO\*/sprintf('%2d', $i+1)/ge;
        $entry = $yv_utils->format_text(
                                        info   => $info,
                                        text   => $entry,
                                        escape => 0,
                                       );
        return "$entry\n";
    }

    if (ref($entry) eq 'ARRAY') {

        my @columns;

        foreach my $slot (@$entry) {

            my $text  = $slot->{text};
            my $width = $slot->{width} // 10;
            my $color = $slot->{color};
            my $align = $slot->{align} // 'left';

            if ($width =~ /^(\d+)%\z/) {
                $width = int(($term_width * $1) / 100);
            }

            $text =~ s/\*NO\*/$i+1/ge;

            $text = $yv_utils->format_text(
                                           info   => $info,
                                           text   => $text,
                                           escape => 0,
                                          );

            $text = clear_title($text);
            $text = adjust_width($text, $width, ($align eq 'right'));

            if (defined($color)) {
                $text = colored($text, $color);
            }

            push @columns, $text;
        }

        return (join(' ', @columns) . "\n");
    }

    die "ERROR: invalid custom layout format <<$entry>>\n";
}

# ... PRINT SUBROUTINES ... #

sub print_channels {
    my ($results) = @_;

    if (not $yv_utils->has_entries($results)) {
        warn_no_results("channel");
    }

    if ($opt{get_term_width}) {
        get_term_width();
    }

    my $url      = $results->{url};
    my $channels = $results->{results} // [];

    if (ref($channels) eq 'HASH') {
        if (exists $channels->{channels}) {
            $channels = $channels->{channels};
        }
        elsif (exists $channels->{entries}) {
            $channels = $channels->{entries};
        }
        else {
            warn "\n[!] No channels...\n";
            $channels = [];
        }
    }

    my @formatted;

    foreach my $i (0 .. $#{$channels}) {

        my $channel = $channels->[$i];
        my $entry   = $opt{custom_channel_layout_format};

        push @formatted, format_line_result($i, $entry, $channel);
    }

    if (@formatted) {
        print "\n" . join("", @formatted);
    }

    my @keywords = get_input_for_channels();

    my @for_search;
    foreach my $key (@keywords) {
        if ($key =~ /$valid_opt_re/) {

            my $opt = $1;

            if (
                general_options(
                                opt  => $opt,
                                sub  => __SUB__,
                                url  => $url,
                                res  => $channels,
                                info => $results,
                               )
              ) {
                ## ok
            }

            # :h, :help
            elsif ($opt =~ /^(?:h|help)\z/) {
                print $channels_help;
                press_enter_to_continue();
            }

            # :r, :return
            elsif ($opt =~ /^(?:r|return)\z/) {
                return;
            }

            # :i=i, :info=i
            elsif ($opt =~ /^(?:i|info)${digit_or_equal_re}(.*)/) {
                if (my @ids = get_valid_numbers($#{$channels}, $1)) {
                    foreach my $id (@ids) {
                        print_channel_info($channels->[$id]);
                    }
                    press_enter_to_continue();
                }
                else {
                    warn_no_thing_selected('playlist');
                }
            }

            # :us=i, :streams=i
            # :shorts=i
            elsif ($opt =~ /^(us|streams|shorts)${digit_or_equal_re}(.*)/) {
                my $type = $1;
                if (my @nums = get_valid_numbers($#{$channels}, $2)) {

                    foreach my $id (@nums) {

                        my $channel_id = $yv_utils->get_channel_id($channels->[$id]);
                        my $request    = ($type =~ /shorts/) ? $yv_obj->shorts($channel_id) : $yv_obj->streams($channel_id);

                        if ($yv_utils->has_entries($request)) {
                            print_videos($request);
                        }
                        else {
                            warn_no_results($type =~ /shorts/ ? 'shorts' : 'streams');
                        }
                    }
                }
                else {
                    warn_no_thing_selected('channel');
                }
            }

            # :pv=i, :popular=i
            elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$channels}, $1)) {

                    foreach my $id (@nums) {

                        my $channel_id = $yv_utils->get_channel_id($channels->[$id]);
                        my $request    = $yv_obj->popular_videos($channel_id);

                        if ($yv_utils->has_entries($request)) {
                            print_videos($request);
                        }
                        else {
                            warn_no_results('popular video');
                        }
                    }
                }
                else {
                    warn_no_thing_selected('channel');
                }
            }

            # :ps=i, :pstreams=i, :popular-streams=i
            # :pshorts=i, :popular-shorts=i
            elsif ($opt =~ /^(ps|pstreams|popular-streams|pshorts|popular-shorts)${digit_or_equal_re}(.*)/) {
                my $type = $1;
                if (my @nums = get_valid_numbers($#{$channels}, $2)) {

                    foreach my $id (@nums) {

                        my $channel_id = $yv_utils->get_channel_id($channels->[$id]);
                        my $request =
                          ($type =~ /shorts/) ? $yv_obj->popular_shorts($channel_id) : $yv_obj->popular_streams($channel_id);

                        if ($yv_utils->has_entries($request)) {
                            print_videos($request);
                        }
                        else {
                            warn_no_results('popular ' . ($type =~ /shorts/ ? 'shorts' : 'streams'));
                        }
                    }
                }
                else {
                    warn_no_thing_selected('channel');
                }
            }

            # :p=i, :playlist=i, :up=i
            elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$channels}, $1)) {

                    foreach my $id (@nums) {

                        my $channel_id = $yv_utils->get_channel_id($channels->[$id]);
                        my $request    = $yv_obj->playlists($channel_id);

                        if ($yv_utils->has_entries($request)) {
                            print_playlists($request);
                        }
                        else {
                            warn_no_results('playlist');
                        }
                    }
                }
                else {
                    warn_no_thing_selected('channel');
                }
            }

            # :s=i, :subscribe=i
            elsif ($opt =~ /^(?:s|sub(?:scribe)?)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$channels}, $1)) {
                    foreach my $id (@nums) {
                        my $channel_id    = $yv_utils->get_channel_id($channels->[$id]);
                        my $channel_title = $yv_utils->get_channel_title($channels->[$id]);
                        subscribe_channel($channel_id, $channel_title);
                    }
                }
                else {
                    warn_no_thing_selected('channel');
                }
            }

            # :save=i
            elsif ($opt =~ /^(?:save)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$channels}, $1)) {
                    foreach my $id (@nums) {
                        my $channel_id    = $yv_utils->get_channel_id($channels->[$id]);
                        my $channel_title = $yv_utils->get_channel_title($channels->[$id]);
                        save_channel($channel_id, $channel_title);
                    }
                }
                else {
                    warn_no_thing_selected('channel');
                }
            }

            # :r=i, :rm=i, :remove=i
            elsif ($opt =~ /^(?:r|rm|remove)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$channels}, $1)) {
                    remove_saved_channels(map { $yv_utils->get_channel_id($channels->[$_]) } @nums);
                }
                else {
                    warn_no_thing_selected('channel');
                }
            }

            # :unsub=i, :unsubscribe=i
            elsif ($opt =~ /^(?:unsub(?:scribe)?)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$channels}, $1)) {
                    unsubscribe_from_channels(map { $yv_utils->get_channel_id($channels->[$_]) } @nums);
                }
                else {
                    warn_no_thing_selected('channel');
                }
            }
            else {
                warn_invalid('option', $opt);
            }
        }
        elsif (youtube_urls($key)) {
            ## ok
        }
        elsif (valid_num($key, $channels)) {
            print_videos($yv_obj->uploads($yv_utils->get_channel_id($channels->[$key - 1])));
        }
        else {
            push @for_search, $key;
        }
    }

    if (@for_search) {
        __SUB__->($yv_obj->search_channels(\@for_search));
    }

    __SUB__->(@_);
}

sub print_comments {
    my ($results, $videoID) = @_;

    if (not $yv_utils->has_entries($results)) {
        warn_no_results("comments");
    }

    my $url      = $results->{url};
    my $comments = $results->{results}{comments} // [];

    foreach my $comment (@{$comments}) {
        my $comment_age = $yv_utils->get_publication_age($comment);

        if ($comment_age) {
            if ($comment_age !~ / ago\b/) {
                $comment_age = "$comment_age ago";
            }
        }
        else {
            $comment_age = $yv_utils->get_publication_date($comment) // 'unknown';
        }

        printf(
               "\n%s (%s) commented:\n%s\n",
               colored($yv_utils->get_author($comment), 'bold'),
               $comment_age,
               wrap_text(
                         i_tab => q{ } x 3,
                         s_tab => q{ } x 3,
                         text  => [$yv_utils->get_comment_content($comment) // 'Empty comment...']
                        ),
              )
          if not $comment->{_hidden};

        if (exists($comment->{replies}) and ref($comment->{replies}) eq 'ARRAY') {
            foreach my $reply (@{$comment->{replies}}) {
                my $reply_age = $yv_utils->get_publication_age($reply);

                if ($reply_age) {
                    $reply_age = "$reply_age ago";
                }
                else {
                    $reply_age = $yv_utils->get_publication_date($reply) // 'unknown';
                }

                printf(
                       "\n   %s (%s) replied:\n%s\n",
                       colored($yv_utils->get_author($reply), 'bold'),
                       $reply_age,
                       wrap_text(
                                 i_tab => q{ } x 6,
                                 s_tab => q{ } x 6,
                                 text  => [$yv_utils->get_comment_content($reply) // 'Empty reply...']
                                ),
                      );
            }
        }
    }

    my @keywords = get_input_for_comments();

    foreach my $key (@keywords) {
        if ($key =~ /$valid_opt_re/) {

            my $opt = $1;

            if (
                general_options(
                                opt  => $opt,
                                sub  => __SUB__,
                                url  => $url,
                                res  => $comments,
                                info => $results,
                                mode => 'comments',
                                args => [$videoID],
                               )
              ) {
                ## ok
            }
            elsif ($opt =~ /^(?:h|help)\z/) {
                print $comments_help;
                press_enter_to_continue();
            }
            elsif ($opt =~ /^(?:r|return)\z/) {
                return;
            }
            else {
                warn_invalid('option', $opt);
            }
        }
        elsif (youtube_urls($key)) {
            ## ok
        }
        elsif (valid_num($key, $comments)) {
            print_videos($yv_obj->uploads($comments->[$key - 1]{authorId}));
        }
        else {
            warn_invalid('keyword', $key);
        }
    }

    __SUB__->(@_);
}

sub _add_channel_to_file {
    my ($channel_id, $channel_title, $file) = @_;

    $channel_id // return;

    if ($channel_id = extract_channel_id($channel_id)) {
        if (not $yv_utils->is_channelID($channel_id)) {
            $channel_id = $yv_obj->channel_id_from_username($channel_id) // do {
                warn_invalid("username or channel ID", $channel_id);
                undef;
            };
        }
    }

    $channel_id // return;
    $channel_title //= $yv_obj->channel_title_from_id($channel_id) // $channel_id;

    if (not defined($channel_title)) {
        warn "[!] Could not determine the channel name...\n";
        return;
    }

    say ":: Saving channel <<$channel_title>> (id: $channel_id) to file..." if $yv_obj->get_debug;

    open(my $fh, '>>:utf8', $file) or do {
        warn "[!] Can't open file <<$file>> for appending: $!\n";
        return;
    };

    say $fh "$channel_id $channel_title";

    close $fh;
}

sub save_channel {
    my ($channel_id, $channel_title) = @_;
    _add_channel_to_file($channel_id, $channel_title, $opt{saved_channels_file});
}

sub subscribe_channel {
    my ($channel_id, $channel_title) = @_;
    _add_channel_to_file($channel_id, $channel_title, $opt{saved_channels_file});
    _add_channel_to_file($channel_id, $channel_title, $opt{subscribed_channels_file});
}

sub update_channel_file {
    my ($channels, $file) = @_;

    open(my $fh, '>:utf8', $file) or do {
        warn "[!] Can't open file <<$file>> for writing: $!\n";
        return;
    };

    foreach my $key (sort { CORE::fc($channels->{$a}) cmp CORE::fc($channels->{$b}) } keys %$channels) {
        say $fh "$key $channels->{$key}";
    }

    close $fh;
}

sub _remove_channels_from_file {
    my ($channel_ids, $file) = @_;

    my %channels = map { @$_ } $yv_utils->read_channels_from_file($file);

    my $removed = 0;
    foreach my $channel_id (@$channel_ids) {
        if (exists $channels{$channel_id}) {
            say ":: Removing: $channel_id" if $yv_obj->get_debug;
            delete $channels{$channel_id};
            ++$removed;
        }
        else {
            say ":: $channel_id is not a saved channel..." if $yv_obj->get_debug;
        }
    }

    if ($removed > 0) {
        update_channel_file(\%channels, $file);
    }
}

sub remove_saved_channels {
    my (@channel_ids) = @_;
    _remove_channels_from_file(\@channel_ids, $opt{saved_channels_file});
    _remove_channels_from_file(\@channel_ids, $opt{subscribed_channels_file});
}

sub unsubscribe_from_channels {
    my (@channel_ids) = @_;
    _remove_channels_from_file(\@channel_ids, $opt{subscribed_channels_file});
}

sub get_results_from_list {
    my ($results, %args) = @_;

    $args{page} //= $yv_obj->get_page;

    if (ref($results) ne 'ARRAY') {
        return;
    }

    my @results = @$results;

    my $maxResults   = $yv_obj->get_maxResults;
    my $totalResults = scalar(@results);

    if ($args{page} >= 1 and scalar(@results) >= $maxResults) {

        @results = grep { defined } @results[($args{page} - 1) * $maxResults .. $args{page} * $maxResults - 1];

        if (!@results) {
            warn_last_page()                                           if ($args{page} == 1 + sprintf('%0.f', 0.5 + $totalResults / $maxResults));
            return __SUB__->($results, %args, page => $args{page} - 1) if ($args{page} > 1);
        }
    }

    my %results;
    my @entries;

    foreach my $entry (@results) {
        if (defined($args{callback})) {
            push @entries, $args{callback}($entry);
        }
        else {
            push @entries, $entry;
        }
    }

#<<<
    $results{entries}         = \@entries;
    #$results{pageInfo}      = {resultsPerPage => scalar(@entries), totalResults => $totalResults};
    #$results{fromPage}      = sub { get_results_from_list($results, %args, page => $_[0]) };
    $results{continuation} = sub { get_results_from_list($results, %args, page => ($args{page} + 1)) };
    #$results{prevPageToken} = sub { get_results_from_list($results, %args, page => (($args{page} > 1) ? ($args{page} - 1) : do { warn_first_page(); 1 })) };
#>>>

    scalar {results => \%results, url => undef};
}

sub print_local_playlist {
    my ($name) = @_;

    $name //= '';

    require File::Basename;

    my @playlist_files = reverse $yv_utils->get_local_playlist_filenames($local_playlists_dir);
    my $regex          = qr/\Q$name\E/i;

    if ($name eq '') {
        my $results = get_results_from_list(
            \@playlist_files,
            callback => sub {
                my ($id) = @_;
                $yv_utils->local_playlist_snippet($id);
            }
        );
        return print_playlists($results);
    }

    foreach my $file (@playlist_files) {
        if (File::Basename::basename($file) =~ $regex or $file eq $name) {
            return print_videos_from_data_file($file);
        }
    }

    warn_no_thing_selected('playlist');
    return 0;
}

sub print_videos_from_data_file {
    my ($file) = @_;
    require Storable;
    my $videos = eval { Storable::retrieve($file) } // [];
    print_videos(get_results_from_list($videos));
}

sub print_watched_videos {
    print_videos_from_data_file($watch_history_data_file);
}

sub print_liked_videos {
    print_videos_from_data_file($liked_videos_data_file);
}

sub print_disliked_videos {
    print_videos_from_data_file($disliked_videos_data_file);
}

sub print_favorite_videos {
    print_videos_from_data_file($favorite_videos_data_file);
}

sub print_subscription_videos {
    print_videos_from_data_file($subscription_videos_data_file);
}

sub fetch_channel_latest_videos {
    my ($channel_id) = @_;

    my @results;
    foreach my $method (grep { /^\w+\z/ } map { split(/,/, $_) } split(' ', $opt{subscription_results})) {

        my $uploads = $yv_obj->$method($channel_id) // next;
        my $videos  = $uploads->{results}           // [];

        if (ref($videos) eq 'HASH' and exists $videos->{videos}) {
            $videos = $videos->{videos};
        }

        if (ref($videos) eq 'HASH' and exists $videos->{entries}) {
            $videos = $videos->{entries};
        }

        if (ref($videos) ne 'ARRAY') {
            next;
        }

        push @results, @$videos;
    }

    @results ? \@results : undef;
}

sub print_local_subscription_videos {

    state $t0 = time;
    state $d0 = $t0;

    # Reuse the subscription file if it's less than 10 minutes old
    if (    $d0 != $t0
        and (time - $t0 <= $opt{subscriptions_lifetime})
        and (-f $subscription_videos_data_file)
        and (-M $subscription_videos_data_file) < ((-M $opt{subscribed_channels_file}) // 0)) {
        return print_subscription_videos();
    }

    $t0 = time + 1;

    my @channels = $yv_utils->read_channels_from_file($opt{subscribed_channels_file});

    if (not @channels) {
        warn "\n[!] No subscribed channels...\n";
        return;
    }

    print "\n" if @channels;

    require Time::Piece;
    my $time = Time::Piece->new();

    my @items;

    if ($opt{get_subscriptions_in_parallel} and eval { require Parallel::ForkManager; 1 }) {

        # Disable connection cache, as it fails when parallel requests are made
        my $lwp = $yv_obj->{lwp} // $yv_obj->set_lwp_useragent();
        $lwp->conn_cache(undef);

        say ":: Retrieving subscription videos...";

        # Max number of processes
        my $pm = Parallel::ForkManager->new(30);

        $pm->set_waitpid_blocking_sleep(0.1);

        # Data structure retrieval and handling
        $pm->run_on_finish(
            sub {
                my ($pid, $exit_code, $ident, $exit_signal, $core_dump, $videos) = @_;
                if ($exit_code == 0 and defined($videos) and ref($videos) eq 'ARRAY') {
                    push @items, @$videos;
                }
            }
        );

        foreach my $i (0 .. $#channels) {

            my $id  = $channels[$i][0] // next;
            my $pid = $pm->start($id)  // next;
            next if ($pid != 0);

            my $videos = fetch_channel_latest_videos($id) // do {
                $pm->finish(1, []);
                next;
            };

            foreach my $video (@$videos) {
                $video->{timestamp} = [@$time];
            }

            $pm->finish(0, $videos);
        }

        $pm->wait_all_children;
    }
    else {
        foreach my $i (0 .. $#channels) {

            local $| = 1;
            printf("[%d/%d] Retrieving info for $channels[$i][1]...\r", $i + 1, $#channels + 1);

            my $id     = $channels[$i][0]                 // next;
            my $videos = fetch_channel_latest_videos($id) // next;

            foreach my $video (@$videos) {
                $video->{timestamp} = [@$time];
            }

            push @items, @$videos;
        }

        print "\n" if @items;
    }

    my $subscriptions_data = [];

    if (-f $subscription_videos_data_file) {
        require Storable;
        $subscriptions_data = eval { Storable::retrieve($subscription_videos_data_file) } // [];
    }

    unshift(@$subscriptions_data, @items);

    # Remove duplicates
    @$subscriptions_data = do {
        my %seen;
        grep { !$seen{$yv_utils->get_video_id($_)}++ } @$subscriptions_data;
    };

    my %subscriptions = ((map { @$_ } @channels), (map { lc($_->[0]) => 1 } @channels));

    # Remove videos from unsubscribed channels
    @$subscriptions_data =
      grep { exists($subscriptions{$yv_utils->get_channel_id($_)}) or exists($subscriptions{lc($yv_utils->get_channel_title($_) // '')}) } @$subscriptions_data;

    # Order videos by newest first
    @$subscriptions_data =
      map  { $_->[0] }
      sort { $b->[1] <=> $a->[1] }
      map  { [$_, $yv_utils->get_publication_time($_)] } @$subscriptions_data;

    # Remove results from the end when the list becomes too large
    my $subscriptions_limit = $opt{subscriptions_limit} // 1e4;
    if ($subscriptions_limit > 0 and scalar(@$subscriptions_data) > $subscriptions_limit) {
        $#$subscriptions_data = $subscriptions_limit;
    }

    if (@$subscriptions_data) {
        require Storable;
        Storable::store([grep { $yv_utils->get_time($_) ne 'LIVE' } @$subscriptions_data], $subscription_videos_data_file);
    }

    print_videos(get_results_from_list($subscriptions_data));
}

sub _print_local_channel_from_file {
    my ($name, $file) = @_;

    $name //= '';

    my @users;

    if (-e $file) {
        @users = $yv_utils->read_channels_from_file($file);
    }
    else {
        @users = $yv_utils->default_channels;
    }

    my $regex = qr/\Q$name\E/i;

    if ($name eq '') {

        my $results = get_results_from_list(
            \@users,
            callback => sub {
                my ($entry) = @_;
                my ($id, $name) = @$entry;
                $yv_utils->local_channel_snippet($id, $name);
            }
        );

        return print_channels($results);
    }

    foreach my $user (@users) {
        my ($channel_id, $channel_name) = @$user;

        if ($channel_id eq $name or $channel_name =~ $regex) {
            return print_videos($yv_obj->uploads($channel_id));
        }
    }

    warn_no_thing_selected('channel');
    return 0;
}

sub print_saved_channels {
    my ($name) = @_;
    _print_local_channel_from_file($name, $opt{saved_channels_file});
}

sub print_subscribed_channels {
    my ($name) = @_;
    _print_local_channel_from_file($name, $opt{subscribed_channels_file});
}

sub print_categories {
    my ($results) = @_;

    my $categories = $results;
    return if ref($categories) ne 'ARRAY';

    my $i = 0;
    print "\n" if @{$categories};

    foreach my $category (@{$categories}) {
        printf "%s. %-40s\n", colored(sprintf('%2d', ++$i), 'bold'), $category->{title};
    }

    my @keywords = get_input_for_categories();

    foreach my $key (@keywords) {
        if ($key =~ /$valid_opt_re/) {

            my $opt = $1;

            if (
                general_options(
                                opt => $opt,
                                sub => __SUB__,
                                res => $results,
                               )
              ) {
                ## ok
            }
            elsif ($opt =~ /^(?:h|help)\z/) {
                print $general_help;
                press_enter_to_continue();
            }
            elsif ($opt =~ /^(?:r|return)\z/) {
                return;
            }
            else {
                warn_invalid('option', $opt);
            }
        }
        elsif (youtube_urls($key)) {
            ## ok
        }
        elsif (valid_num($key, $categories)) {
            my $category = $categories->[$key - 1];
            my $cat_id   = $category->{id};
            my $videos   = $yv_obj->trending_videos_from_category($cat_id);

            print_videos($videos);
        }
        else {
            warn_invalid('keyword', $key);
        }
    }

    __SUB__->(@_);
}

sub print_playlists {
    my ($results, %args) = @_;

    if (not $yv_utils->has_entries($results)) {
        warn_no_results("playlist");
    }

    if ($opt{get_term_width}) {
        get_term_width();
    }

    my $url       = $results->{url};
    my $playlists = $results->{results} // [];

    if (ref($playlists) eq 'HASH') {
        if (exists $playlists->{playlists}) {
            $playlists = $playlists->{playlists};
        }
        elsif (exists $playlists->{entries}) {
            $playlists = $playlists->{entries};
        }
        else {
            warn "\n[!] No playlists...\n";
            $playlists = [];
        }
    }

    my @formatted;

    foreach my $i (0 .. $#{$playlists}) {

        my $playlist = $playlists->[$i];
        my $entry    = $opt{custom_playlist_layout_format};

        push @formatted, format_line_result($i, $entry, $playlist);
    }

    if (@formatted) {
        print "\n" . join("", @formatted);
    }

    state @keywords;
    if ($args{auto}) { }    # do nothing...
    else {
        @keywords = get_input_for_playlists();
        if (scalar(@keywords) == 0) {
            __SUB__->(@_);
        }
    }

    my $contains_keywords = grep { /$non_digit_or_opt_re/ } @keywords;

    my @for_search;
    foreach my $key (@keywords) {
        if ($key =~ /$valid_opt_re/) {

            my $opt = $1;

            if (
                general_options(
                                opt  => $opt,
                                sub  => __SUB__,
                                url  => $url,
                                res  => $playlists,
                                info => $results,
                                mode => 'playlists',
                               )
              ) {
                ## ok
            }
            elsif ($opt =~ /^(?:h|help)\z/) {
                print $playlists_help;
                press_enter_to_continue();
            }
            elsif ($opt =~ /^(?:r|return)\z/) {
                return;
            }

            # :i=i, :info=i
            elsif ($opt =~ /^(?:i|info)${digit_or_equal_re}(.*)/) {
                if (my @ids = get_valid_numbers($#{$playlists}, $1)) {
                    foreach my $id (@ids) {
                        print_playlist_info($playlists->[$id]);
                    }
                    press_enter_to_continue();
                }
                else {
                    warn_no_thing_selected('playlist');
                }
            }

            # :p=i, :playlist=i, :up=i
            elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$playlists}, $1)) {
                    foreach my $id (@nums) {
                        my $request = $yv_obj->playlists($yv_utils->get_channel_id($playlists->[$id]));
                        if ($yv_utils->has_entries($request)) {
                            print_playlists($request);
                        }
                        else {
                            warn_no_results('playlist');
                        }
                    }
                }
                else {
                    warn_no_thing_selected('playlist');
                }
            }

            # :pp=i
            elsif ($opt =~ /^pp${digit_or_equal_re}(.*)/) {
                if (my @ids = get_valid_numbers($#{$playlists}, $1)) {
                    my $arg = "--pp=" . join(q{,}, map { $yv_utils->get_playlist_id($_) } @{$playlists}[@ids]);
                    apply_input_arguments([$arg]);
                }
                else {
                    warn_no_thing_selected('playlist');
                }
            }
            else {
                warn_invalid('option', $opt);
            }
        }
        elsif (youtube_urls($key)) {
            ## ok
        }
        elsif (valid_num($key, $playlists) and not $contains_keywords) {

            my $id = $yv_utils->get_playlist_id($playlists->[$key - 1]);

            if ($args{return_playlist_id}) {
                return $id;
            }

            if ($id =~ m{^/}) {    # local playlist
                print_local_playlist($id);
            }
            else {
                get_and_print_videos_from_playlist($id);
            }
        }
        else {
            push @for_search, $key;
        }
    }

    if (@for_search) {
        __SUB__->($yv_obj->search_playlists(\@for_search));
    }

    __SUB__->(@_);
}

sub compile_regex {
    my ($value) = @_;

    #~ $value =~ s{^(?<quote>['"])(?<regex>.+)\g{quote}$}{$+{regex}}s;

    my $re = eval { use re qw(eval); qr/$value/i };

    if ($@) {
        warn_invalid("regex", $@);
        return;
    }

    return $re;
}

sub get_range_numbers {
    my ($first, $second) = @_;

    return (
            $first > $second
            ? (reverse($second .. $first))
            : ($first .. $second)
           );
}

sub get_valid_numbers {
    my ($max, $input) = @_;

    my @output;
    foreach my $id (split(/[,\s]+/, $input)) {
        push @output,
            $id =~ /$range_num_re/ ? get_range_numbers($1, $2)
          : $id =~ /^[0-9]{1,3}\z/ ? $id
          :                          next;
    }

    return grep { $_ >= 0 and $_ <= $max } map { $_ - 1 } @output;
}

sub get_streaming_url {
    my ($video_id) = @_;

    my ($urls, $captions, $info) = $yv_obj->get_streaming_urls($video_id);

    if (not defined $urls) {
        return scalar {};
    }

    # Download the closed-captions
    my $srt_file;
    if (ref($captions) eq 'ARRAY' and @$captions and $opt{get_captions} and not $opt{novideo}) {
        require WWW::PipeViewer::GetCaption;

        my $languages = $opt{srt_languages};

        if (ref($languages) ne 'ARRAY') {
            $languages = [grep { /[a-z]/i } split(/\s*,\s*/, $languages)];
        }

        my $yv_cap = WWW::PipeViewer::GetCaption->new(
                                                      auto_captions => $opt{auto_captions},
                                                      captions_dir  => $opt{cache_dir},
                                                      captions      => $captions,
                                                      languages     => $languages,
                                                      yv_obj        => $yv_obj,
                                                     );
        $srt_file = $yv_cap->save_caption($video_id);
    }

    require WWW::PipeViewer::Itags;
    state $yv_itags = WWW::PipeViewer::Itags->new();

    # Include split-videos
    my $split_videos = 1;

    # Exclude split-videos in download-mode or when no video output is required
    if ($opt{novideo} or not $opt{split_videos}) {
        $split_videos = 0;
    }
    elsif ($opt{download_video}) {
        $split_videos = $opt{merge_into_mkv} ? 1 : 0;
    }

    my ($streaming, $resolution) = $yv_itags->find_streaming_url(
        urls       => $urls,
        resolution => ($opt{novideo} ? 'audio' : $opt{resolution}),

        hfr        => $opt{hfr},
        ignore_av1 => $opt{ignore_av1},

        split         => $split_videos,
        prefer_m4a    => $opt{prefer_m4a},
        audio_quality => $opt{audio_quality},
        dash          => ($opt{download_video} ? 0 : $opt{dash}),

        ignored_projections => $opt{ignored_projections},
    );

    return {
            streaming  => $streaming,
            srt_file   => $srt_file,
            info       => $info,
            resolution => $resolution,
           };
}

sub download_from_url {
    my ($info, $output_filename) = @_;

    my $url = $info->{url};

    # Download with yt-dlp / youtube-dl
    if ($opt{ytdl} and $opt{download_with_ytdl} and defined($info->{_youtube_url}) and defined($info->{itag})) {

        # TODO: take into account the preferred audio quality and format and video resolution
        # and format when falling back (i.e. when yt-dlp cannot find the itag that we want)
        my $cmd = join(' ',
                       $opt{ytdl_cmd},
                       ($info->{wkad} ? () : ('-f', $info->{itag} . ($info->{type} =~ m{^audio/} ? '/bestaudio' : '/bestvideo'))),
                       quotemeta($info->{_youtube_url}),
                       '-o', quotemeta("$output_filename.part"));

        if ($yv_obj->get_debug) {
            say "-> Command: $cmd";
        }

        $yv_obj->proxy_system($cmd);
        return if $?;
        rename("$output_filename.part", $output_filename) or return undef;
        return $output_filename;
    }

    # Download with wget
    if ($opt{download_with_wget}) {
        my $cmd = join(' ', $opt{wget_cmd}, '-c', '-t', '10', '--waitretry=3', quotemeta($url), '-O', quotemeta("$output_filename.part"));
        $yv_obj->proxy_system($cmd);
        return if $?;
        rename("$output_filename.part", $output_filename) or return undef;
        return $output_filename;
    }

    state $lwp_dl = which_command('lwp-download');

    # Download with lwp-download
    if (defined($lwp_dl)) {
        my @cmd = ($lwp_dl, $url, "$output_filename.part");
        $yv_obj->proxy_system(@cmd);
        if ($? == 256 and !defined(fileno(STDOUT))) {    # lwp-download bug
            ## ok
        }
        else {
            return if $?;
        }
        rename("$output_filename.part", $output_filename) or return undef;
        return $output_filename;
    }

    # Download with LWP::UserAgent
    require LWP::UserAgent;

    my $lwp = LWP::UserAgent->new(show_progress => 1,
                                  agent         => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',);

    $lwp->proxy(['http', 'https'], $yv_obj->get_http_proxy)
      if defined($yv_obj->get_http_proxy);

    my $resp = eval { $lwp->mirror($url, "$output_filename.part") };

    if ($@ =~ /\bread timeout\b/i or not defined($resp) or not $resp->is_success) {
        warn colored("\n[!] Encountered an error while downloading... Trying again...", 'bold red') . "\n\n";

        if (defined(my $wget_path = which_command('wget'))) {
            $CONFIG{wget_cmd}           = $wget_path;
            $CONFIG{download_with_wget} = 1;
            dump_configuration($config_file);
        }
        else {
            warn colored("[!] Please install `wget` and try again...", 'bold red') . "\n\n";
        }

        unlink("$output_filename.part");
        return download_from_url($info, $output_filename);
    }

    rename("$output_filename.part", $output_filename) or return undef;
    return $output_filename;
}

sub download_video {
    my ($streaming, $info) = @_;

    my $video_filename = $yv_utils->normalize_filename(
                                                       $yv_utils->format_text(
                                                                              streaming => $streaming,
                                                                              info      => $info,
                                                                              text      => $opt{video_filename_format},
                                                                              escape    => 0,
                                                                             ),
                                                       $opt{fat32safe}
                                                      );

    $video_filename =~ s/\h*:+\h*/ - /g;    # replace colons (":") with dashes ("-")

    my $naked_filename = $video_filename =~ s/\.\w+\z//r;

    my $mkv_filename   = "$naked_filename.mkv";
    my $srt_filename   = "$naked_filename.srt";
    my $audio_filename = "$naked_filename - audio";

    my $video_info = $streaming->{streaming};
    my $audio_info = $streaming->{streaming}{__AUDIO__};

    my $video_id = $yv_utils->format_text(
                                          streaming => $streaming,
                                          info      => $info,
                                          text      => '*ID*',
                                          escape    => 0
                                         );

    $video_info->{_youtube_url} = sprintf("https://youtube.com/watch?v=%s", $video_id);

    if ($audio_info) {
        $audio_filename .= "." . $yv_utils->extension($audio_info->{type});
        $audio_info->{_youtube_url} = $video_info->{_youtube_url};
    }

    my $downloads_dir = $opt{downloads_dir};

    # Download in subdirectory
    if ($opt{download_in_subdir}) {
        my $downloads_subdir = $yv_utils->normalize_filename(
                                                             $yv_utils->format_text(
                                                                                    streaming => $streaming,
                                                                                    info      => $info,
                                                                                    text      => $opt{download_in_subdir_format},
                                                                                    escape    => 0,
                                                                                   ),
                                                             $opt{fat32safe}
                                                            );

        $downloads_dir = catdir($downloads_dir, $downloads_subdir);
    }

    # Create the downloads directory, when it doesn't exist
    if (not -d $downloads_dir) {
        require File::Path;
        if (not eval { File::Path::make_path($downloads_dir) }) {
            warn colored("\n[!] Can't create directory <<$downloads_dir>>: $1", 'bold red') . "\n";
        }
    }

    if (not -d $downloads_dir) {
        warn colored("\n[!] Can't write into directory <<$downloads_dir>>: $!", 'bold red') . "\n";
        $downloads_dir = (-d curdir()) ? curdir() : (-d $ENV{HOME}) ? $ENV{HOME} : return;
        warn colored("[!] Video will be downloaded into directory: $downloads_dir", 'bold red') . "\n";
    }

    $mkv_filename   = catfile($downloads_dir, $mkv_filename);
    $srt_filename   = catfile($downloads_dir, $srt_filename);
    $audio_filename = catfile($downloads_dir, $audio_filename);
    $video_filename = catfile($downloads_dir, $video_filename);

    if ($opt{skip_if_exists} and -e $mkv_filename) {
        $video_filename = $mkv_filename;
        say ":: File `$mkv_filename` already exists. Skipping...";
    }
    else {
        if ($opt{skip_if_exists} and -e $video_filename) {
            say ":: File `$video_filename` already exists. Skipping...";
        }
        else {
            $video_filename = download_from_url($video_info, $video_filename) // return;
        }

        if ($opt{skip_if_exists} and -e $audio_filename) {
            say ":: File `$audio_filename` already exists. Skipping...";
        }
        elsif ($audio_info) {
            $audio_filename = download_from_url($audio_info, $audio_filename) // return;
        }
    }

    my @merge_files = ($video_filename);

    if ($audio_info) {
        push @merge_files, $audio_filename;
    }

    if (    $opt{merge_with_captions}
        and defined($streaming->{srt_file})
        and -f $streaming->{srt_file}) {
        push @merge_files, $streaming->{srt_file};
    }

    if (    $opt{merge_into_mkv}
        and scalar(@merge_files) > 1
        and scalar(grep { -f $_ } @merge_files) == scalar(@merge_files)
        and not -e $mkv_filename) {

        say ":: Merging into MKV...";

        my $ffmpeg_cmd  = $opt{ffmpeg_cmd};
        my $ffmpeg_args = $opt{merge_into_mkv_args};

        if (my @srt_files = grep { /\.srt\z/ } @merge_files) {
            my $srt_file = $srt_files[0];
            require File::Basename;
            if (File::Basename::basename($srt_file) =~ m{^.{11}_([a-z]{2,4})}i) {
                my $lang_code = $1;
                $ffmpeg_args .= " -metadata:s:s:0 language=$lang_code";
            }
        }

        my $merge_command =
          join(' ', $ffmpeg_cmd, (map { "-i \Q$_\E" } @merge_files), $ffmpeg_args, "\Q$mkv_filename\E");

        if ($yv_obj->get_debug) {
            say "-> Command: $merge_command";
        }

        $yv_obj->proxy_system($merge_command);

        if ($? == 0 and -e $mkv_filename) {
            unlink @merge_files;
            $video_filename = $mkv_filename;
        }
        elsif ($? != 0) {    # ffmpeg failed
            if (-e $mkv_filename) {    # probably due to not enough space
                unlink $mkv_filename;    # remove the mkv file
            }
            return;
        }
    }

    # Convert the downloaded video
    if (defined $opt{convert_to}) {

        my $convert_filename = catfile($downloads_dir, "$naked_filename.$opt{convert_to}");
        my $convert_cmd      = $opt{convert_cmd};

        my %table = (
                     'IN'  => $video_filename,
                     'OUT' => $convert_filename,
                    );

        my $regex = do {
            local $" = '|';
            qr/\*(@{[keys %table]})\*/;
        };

        $convert_cmd =~ s/$regex/\Q$table{$1}\E/g;
        say $convert_cmd if $yv_obj->get_debug;

        $yv_obj->proxy_system($convert_cmd);

        if ($? == 0) {

            if (not $opt{keep_original_video}) {
                unlink $video_filename
                  or warn colored("\n[!] Can't unlink file <<$video_filename>>: $!", 'bold red') . "\n\n";
            }

            $video_filename = $convert_filename if -e $convert_filename;
        }
    }

    # Play the download video
    if ($opt{download_and_play}) {

        local $streaming->{streaming}{url}       = '';
        local $streaming->{streaming}{__AUDIO__} = undef;
        local $streaming->{srt_file}             = undef if ($opt{merge_into_mkv} && $opt{merge_with_captions});

        my $command = get_player_command($streaming, $info);
        say "-> Command: ", $command if $yv_obj->get_debug;

        $yv_obj->proxy_system(join(q{ }, $command, quotemeta($video_filename)));

        # Remove it afterwards
        if ($? == 0 and $opt{remove_played_file}) {
            unlink $video_filename
              or warn colored("\n[!] Can't unlink file <<$video_filename>>: $!", 'bold red') . "\n\n";
        }
    }

    # Copy the .srt file to downloads-dir
    if (    $opt{copy_caption}
        and -e $video_filename
        and defined($streaming->{srt_file})
        and -e $streaming->{srt_file}) {

        my $from = $streaming->{srt_file};
        my $to   = $srt_filename;

        require File::Copy;
        File::Copy::cp($from, $to);
    }

    # Set original modification timestamp
    if ($opt{set_mtime} and defined($info->{publishedText}) and -f $video_filename) {

        require Time::Piece;
        my $published_time = eval { Time::Piece->strptime($info->{publishedText}, "%b %d, %Y") };

        if (defined($published_time)) {
            eval { utime(time, $published_time->epoch, $video_filename) }
              || warn colored("\n[!] Failed to set modification time of <<$video_filename>>: $!", 'bold red') . "\n\n";
        }
    }

    return 1;
}

sub prepend_video_data_to_file {
    my ($video_data, $file) = @_;

    require Storable;

    my $videos = eval { Storable::retrieve($file) } // [];

    if (ref($video_data) ne 'HASH') {
        my $videoID = get_valid_video_id($video_data) // return;
        $video_data = $yv_obj->video_details($videoID);
    }

    get_valid_video_id($yv_utils->get_video_id($video_data)) // return;

    unshift(@$videos, $video_data);

    my %seen;
    @$videos = grep { !$seen{$yv_utils->get_video_id($_)}++ } @$videos;

    if ($opt{local_playlist_limit} > 0 and scalar(@$videos) > $opt{local_playlist_limit}) {
        $#$videos = $opt{local_playlist_limit} - 1;
    }

    Storable::store($videos, $file);
    return 1;
}

sub save_watched_video {
    my ($video_id, $video_data) = @_;

    if ($opt{watch_history}) {

        if (not exists($WATCHED_VIDEOS{$video_id})) {

            $WATCHED_VIDEOS{$video_id} = 1;

            open my $fh, '>>', $opt{watch_history_file} or return;
            say {$fh} $video_id;
            close $fh;
        }

        prepend_video_data_to_file($video_data, $watch_history_data_file);
    }

    $WATCHED_VIDEOS{$video_id} = 1;
    return 1;
}

sub get_player_command {
    my ($streaming, $video) = @_;

    my %player_args = (%PLAYER_ARGS);
    my $player      = $opt{video_players}{$opt{video_player_selected}};

    if (ref($player) ne 'HASH') {
        die ":: The selected video player does not exist! Check the configuration file.";
    }

    $player_args{fullscreen} = $opt{fullscreen} ? $player->{fs}      : undef;
    $player_args{novideo}    = $opt{novideo}    ? $player->{novideo} : undef;
    $player_args{arguments}  = $player->{arg};

    my $cmd = join(
        q{ },
        (
            # Video player
            $player->{cmd},

            (    # Audio file (https://)
              (ref($streaming->{streaming}{__AUDIO__}) eq 'HASH' && defined($player->{audio}))
              ? $player->{audio}
              : ()
            ),

            (    # Subtitle file (.srt)
              (defined($streaming->{srt_file}) && defined($player->{srt}))
              ? $player->{srt}
              : ()
            ),

            # Rest of the arguments
            (grep { defined($_) and /\S/ } values %player_args)
        )
    );

    my $has_video = $cmd =~ /\*(?:VIDEO|URL)\*/;

    $cmd = $yv_utils->format_text(
                                  streaming => $streaming,
                                  info      => $video,
                                  text      => $cmd,
                                  escape    => 1,
                                 );

    if ($streaming->{streaming}{url} =~ m{^https://www\.youtube\.com/watch\?v=}) {
        $cmd =~ s{\s*--no-ytdl\b}{ }g;
    }

    $has_video ? $cmd : join(' ', $cmd, quotemeta($streaming->{streaming}{url}));
}

sub autoplay {
    my $video_id = get_valid_video_id(shift) // return;

    my %seen;    # make sure we don't get stuck in a loop
    local $yv_obj->{maxResults} = 10;

    while (1) {
        $seen{$video_id} = 1;
        get_and_play_video_ids($video_id) || return;
        my $related = $yv_obj->related_to_videoID($video_id);
        (my @video_ids = grep { not $seen{$_} } map { $yv_utils->get_video_id($_) } @{$related->{results}}) || return;
        $video_id = $opt{shuffle} ? $video_ids[rand @video_ids] : $video_ids[0];
    }

    return 1;
}

sub play_videos {
    my ($videos) = @_;

    foreach my $video (@{$videos}) {

        my $video_id = $yv_utils->get_video_id($video);
        if ($yv_obj->get_skip_youtube_extraction) {
            my $url     = sprintf($opt{youtube_video_url}, $video_id);
            my $command = get_player_command({streaming => {url => $url}, srt_file => undef, info => {videoId => $video_id}}, $video);
            print_video_info($video);
            if ($yv_obj->get_debug) {
                say "-> Command: $command";
            }
            $yv_obj->proxy_system($command);
            save_watched_video($video_id, $video);
            press_enter_to_continue() if $opt{confirm};
            next;
        }

        if ($opt{autoplay_mode}) {
            local $opt{autoplay_mode} = 0;
            autoplay($video_id);
            next;
        }

        # Ignore already watched videos
        if (exists($WATCHED_VIDEOS{$video_id}) and $opt{skip_watched}) {
            say ":: Already watched video (ID: $video_id)... Skipping...";
            next;
        }

        if (defined($opt{max_seconds}) and $opt{max_seconds} >= 0) {
            next if $yv_utils->get_duration($video) > $opt{max_seconds};
        }

        if (defined($opt{min_seconds}) and $opt{min_seconds} >= 0) {
            next if $yv_utils->get_duration($video) < $opt{min_seconds};
        }

        my $streaming = get_streaming_url($video_id);

        if (ref($streaming->{streaming}) ne 'HASH') {
            warn colored("[!] No streaming URL has been found...", 'bold red') . "\n";
            next;
        }

        if (   !defined($streaming->{streaming}{url})
            and defined($streaming->{info}{status})
            and $streaming->{info}{status} =~ /(?:error|fail)/i) {
            warn colored("[!] Error on: ", 'bold red') . sprintf($opt{youtube_video_url}, $video_id) . "\n";
            warn colored(":: Reason: ",    'bold red') . $streaming->{info}{reason} =~ tr/+/ /r . "\n\n";
        }

        # Dump metadata information
        if (defined($opt{dump})) {

            my $file = $video_id . '.' . $opt{dump};
            open(my $fh, '>:utf8', $file)
              or die "Can't open file `$file' for writing: $!";

            local $video->{streaming} = $streaming;

            if ($opt{dump} eq 'json') {
                require JSON;
                print {$fh} JSON->new->pretty(1)->encode($video);
            }
            elsif ($opt{dump} eq 'perl') {
                require Data::Dump;
                print {$fh} Data::Dump::pp($video);
            }

            close $fh;
        }

        if ($opt{download_video}) {
            print_video_info($video);
            if (not download_video($streaming, $video)) {
                return;
            }
            save_watched_video($video_id, $video);
        }
        elsif (length($opt{extract_info})) {
            my $fh = $opt{extract_info_fh} // \*STDOUT;
            say {$fh}
              $yv_utils->format_text(
                                     streaming => $streaming,
                                     info      => $video,
                                     text      => $opt{extract_info},
                                     escape    => $opt{escape_info},
                                     fat32safe => $opt{fat32safe},
                                    );
        }
        else {
            print_video_info($video);
            my $command = get_player_command($streaming, $video);

            if ($yv_obj->get_debug) {
                say "-> Resolution: $streaming->{resolution}";
                say "-> Video itag: $streaming->{streaming}{itag}";
                say "-> Audio itag: $streaming->{streaming}{__AUDIO__}{itag}" if exists $streaming->{streaming}{__AUDIO__};
                say "-> Video type: $streaming->{streaming}{type}";
                say "-> Audio type: $streaming->{streaming}{__AUDIO__}{type}" if exists $streaming->{streaming}{__AUDIO__};
                say "-> Command: $command";
            }

#<<<
            # Concept for playing videos with ffmpeg + ffplay
            # if (exists $streaming->{streaming}{__AUDIO__}) {
            #     system("ffmpeg -i \Q$streaming->{streaming}{url}\E -i \Q$streaming->{streaming}{__AUDIO__}{url}\E -c:a copy -c:v copy -f matroska - | ffplay -");
            # }
            # else {
            #     system("ffplay", $streaming->{streaming}{url});
            # }
#>>>

            $yv_obj->proxy_system($command);    # execute the video player

            if ($? and $? != 512) {
                $opt{auto_next_page} = 0;
                return;
            }

            save_watched_video($video_id, $video);
        }

        press_enter_to_continue() if $opt{confirm};
    }

    return 1;
}

sub play_videos_matched_by_regex {
    my %args = @_;

    my $key    = $args{key};
    my $regex  = $args{regex};
    my $videos = $args{videos};

    my $sub = \&{'WWW::PipeViewer::Utils' . '::' . 'get_' . $key};

    if (not defined &$sub) {
        warn colored("\n[!] Invalid key: <$key>.", 'bold red') . "\n";
        return;
    }

    if (defined(my $re = compile_regex($regex))) {
        if (my @nums = grep { $yv_utils->$sub($videos->[$_]) =~ /$re/ } 0 .. $#{$videos}) {
            if (not play_videos([@{$videos}[@nums]])) {
                return;
            }
        }
        else {
            warn colored("\n[!] No video <$key> matched by the regex: $re", 'bold red') . "\n";
            return;
        }
    }

    return 1;
}

sub print_playlist_info {
    my ($playlist) = @_;

    my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width);

    printf(
           "\n%s\n%s\n%s\n",
           _bold_color('=> Description'),
           $hr,
           wrap_text(
                     i_tab => q{},
                     s_tab => q{},
                     text  => [$yv_utils->get_description($playlist) || 'No description available...']
                    ),
          );

    my $id = $yv_utils->get_playlist_id($playlist);

    if ($id =~ m{^/}) {
        ## local playlist
    }
    else {
        say STDOUT $hr, "\n", _bold_color('=> URL: '), sprintf($opt{youtube_playlist_url}, $id);
    }

    my $title        = $yv_utils->get_title($playlist);
    my $title_length = length($title);
    my $rep          = ($term_width - $title_length) / 2 - 4;

    $rep = 0 if $rep < 0;

    print(
          "$hr\n",
          q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"),
          (
           map  { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 18 : 10, _bold_color($_->[0]), $_->[1]) }
           grep { defined($_->[1]) } (
                                      ['Title'      => $yv_utils->get_title($playlist)],
                                      ['Author'     => $yv_utils->get_channel_title($playlist)],
                                      ['ChannelID'  => $yv_utils->get_channel_id($playlist)],
                                      ['PlaylistID' => ($id =~ m{^/} ? undef : $id)],
                                      ['Videos'     => $yv_utils->set_thousands($yv_utils->get_playlist_item_count($playlist))],
                                      ['Published'  => $yv_utils->get_publication_date($playlist)],
                                     )
          ),
          "$hr\n"
         );

    return 1;
}

sub print_channel_info {
    my ($channel) = @_;

    my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width);

    printf(
           "\n%s\n%s\n%s\n%s\n%s",
           _bold_color('=> Description'),
           $hr,
           wrap_text(
                     i_tab => q{},
                     s_tab => q{},
                     text  => [$yv_utils->get_description($channel) || 'No description available...']
                    ),
           $hr,
           _bold_color('=> URL: ')
          );

    print STDOUT sprintf($opt{youtube_channel_url}, $yv_utils->get_channel_id($channel));

    my $title        = $yv_utils->get_channel_title($channel);
    my $title_length = length($title);
    my $rep          = ($term_width - $title_length) / 2 - 4;

    $rep = 0 if $rep < 0;

    print(
          "\n$hr\n",
          q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"),
          (
           map  { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 20 : 12, _bold_color($_->[0]), $_->[1]) }
           grep { defined($_->[1]) } (
                                      ['Channel'     => $title],
                                      ['ChannelID'   => $yv_utils->get_channel_id($channel)],
                                      ['Videos'      => $yv_utils->set_thousands($yv_utils->get_channel_video_count($channel))],
                                      ['Subscribers' => $yv_utils->set_thousands($yv_utils->get_channel_subscriber_count($channel))],
                                      ['Published'   => $yv_utils->get_publication_date($channel)],
                                     )
          ),
          "$hr\n"
         );

    return 1;
}

sub print_video_info {
    my ($video) = @_;

    $opt{show_video_info} || return 1;

    my $extra_info = $yv_obj->video_details($yv_utils->get_video_id($video) // return 1);

    foreach my $key (keys %$extra_info) {
        next if $key eq "startTime";
        $video->{$key} = $extra_info->{$key};
    }

    my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width);

    printf(
           "\n%s\n%s\n%s\n%s\n%s",
           _bold_color('=> Description'),
           $hr,
           wrap_text(
                     i_tab => q{},
                     s_tab => q{},
                     text  => [$yv_utils->get_description($video) || 'No description available...']
                    ),
           $hr,
           _bold_color('=> URL: ')
          );

    print STDOUT sprintf($opt{youtube_video_url}, $yv_utils->get_video_id($video));

    my $title        = $yv_utils->get_title($video);
    my $title_length = length($title);
    my $rep          = ($term_width - $title_length) / 2 - 4;

    $rep = 0 if $rep < 0;

    my $likes    = $yv_utils->get_likes($video);
    my $dislikes = $yv_utils->get_dislikes($video);
    my $views    = $yv_utils->get_views($video);
    my $rating   = $yv_utils->get_rating($video);

    print(
          "\n$hr\n",
          q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"),
          (
           map  { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 18 : 10, _bold_color($_->[0]), $_->[1]) }
           grep { defined($_->[1]) } (
                                      ['Channel'   => $yv_utils->get_channel_title($video)],
                                      ['ChannelID' => $yv_utils->get_channel_id($video)],
                                      ['VideoID'   => $yv_utils->get_video_id($video)],
                                      ['Category'  => $yv_utils->get_category_name($video)],
                                      ['Duration'  => $yv_utils->get_time($video)],
                                      ['Likes'     => $yv_utils->set_thousands($likes)],
                                      $opt{dislikes_api} ? (['Dislikes' => $yv_utils->set_thousands($dislikes)]) : (),
                                      $opt{dislikes_api} ? (['Rating'   => $yv_utils->get_rating($video)])       : (),
                                      ['Views'     => $yv_utils->set_thousands($views)],
                                      ['Published' => $yv_utils->get_publication_date($video)],
                                     )
          ),
          "$hr\n"
         );

    # Add attribution for Return YouTube Dislike API (only when enabled)
    if ($opt{dislikes_api}) {
        print("\n" . _bold_color("Note: ") . "Dislike data provided by Return YouTube Dislike (https://returnyoutubedislike.com/)\n");
    }

    return 1;
}

sub print_videos {
    my ($results, %args) = @_;

    if (not $yv_utils->has_entries($results)) {
        warn_no_results("video");
    }

    if ($opt{get_term_width}) {
        get_term_width();
    }

    my $url    = $results->{url};
    my $videos = $results->{results} // [];

    if (ref($videos) eq 'HASH' and exists $videos->{videos}) {
        $videos = $videos->{videos};
    }

    if (ref($videos) eq 'HASH' and exists $videos->{entries}) {
        $videos = $videos->{entries};
    }

    my $token = undef;

    if (ref($results->{results}) eq 'HASH' and exists $results->{results}{continuation}) {
        $token = $results->{results}{continuation};
    }

    if (ref($videos) ne 'ARRAY') {
        say "\n:: Probably the selected invidious instance is down. Try:";
        say "\n\t$0 --api=auto\n";
        say "See also: https://github.com/trizen/pipe-viewer#invidious-instances";
        return;
    }

    if ($opt{shuffle}) {
        require List::Util;
        $videos = [List::Util::shuffle(@{$videos})];
    }

    my @formatted;

    foreach my $i (0 .. $#{$videos}) {

        my $video = $videos->[$i];
        my $entry = $opt{custom_layout_format};

        push @formatted, format_line_result($i, $entry, $video);
    }

    if ($opt{highlight_watched}) {
        foreach my $i (0 .. $#{$videos}) {
            my $video = $videos->[$i];
            if (exists($WATCHED_VIDEOS{$yv_utils->get_video_id($video)})) {
                $formatted[$i] = colored(colorstrip($formatted[$i]), $opt{highlight_color});
            }
        }
    }

    if (@formatted) {
        print "\n" . join("", @formatted);
    }

    if ($opt{play_all} || $opt{play_backwards}) {
        if (@{$videos}) {
            if (
                play_videos(
                            $opt{play_backwards}
                            ? [reverse @{$videos}]
                            : $videos
                           )
              ) {
                if ($opt{play_backwards}) {
                    if (defined($url)) {
                        return;
                    }
                    else {
                        $opt{play_backwards} = 0;
                        warn_first_page();
                        return;
                    }
                }
                else {
                    if (defined($url) or ref($token) eq 'CODE') {
                        __SUB__->($yv_obj->next_page($url, $token), auto => 1);
                    }
                    else {
                        $opt{play_all} = 0;
                        warn_last_page();
                        return;
                    }
                }
            }
            else {
                $opt{play_all}       = 0;
                $opt{play_backwards} = 0;
                __SUB__->($results);
            }
        }
        else {
            $opt{play_all}       = 0;
            $opt{play_backwards} = 0;
        }
    }

    state @keywords;
    if ($args{auto}) { }    # do nothing...
    else {
        @keywords = get_input_for_search();

        if (scalar(@keywords) == 0) {    # only arguments
            __SUB__->($results);
        }
    }

    state @for_search;
    state @for_play;

    my @copy_of_keywords  = @keywords;
    my $contains_keywords = grep { /$non_digit_or_opt_re/ } @keywords;

    while (@keywords) {
        my $key = shift @keywords;
        if ($key =~ /$valid_opt_re/) {

            my $opt = $1;

            if (
                general_options(opt => $opt,
                                res => $videos,)
              ) {
                ## ok
            }
            elsif ($opt =~ /^(?:h|help)\z/) {
                print $complete_help;
                press_enter_to_continue();
            }
            elsif ($opt =~ /^(?:n|next)\z/) {
                if (defined($url) or ref($token) eq 'CODE') {
                    my $request = $yv_obj->next_page($url, $token);
                    __SUB__->($request, @keywords ? (auto => 1) : ());
                }
                else {
                    warn_last_page();
                    if ($opt{auto_next_page}) {
                        $opt{auto_next_page} = 0;
                        @copy_of_keywords = ();
                        last;
                    }
                }
            }

            # :refresh
            elsif ($opt =~ /^(?:R|refresh)\z/) {
                ##@{$videos} = @{$yv_obj->_get_results($url)->{results}};
            }

            # :r, :return
            elsif ($opt =~ /^(?:r|return)\z/) {
                return;
            }

            # :author=i, :u=i
            elsif ($opt =~ /^(?:a|author|u|uploads)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    foreach my $id (@nums) {
                        my $channel_id = $yv_utils->get_channel_id($videos->[$id]);
                        my $request    = $yv_obj->uploads($channel_id);
                        if ($yv_utils->has_entries($request)) {
                            __SUB__->($request);
                        }
                        else {
                            warn_no_results('video');
                        }
                    }
                }
                else {
                    warn_no_thing_selected('video');
                }
            }

            # :streams=i, :us=i
            # :shorts=i
            elsif ($opt =~ /^(streams|us|shorts)${digit_or_equal_re}(.*)/) {
                my $type = $1;
                if (my @nums = get_valid_numbers($#{$videos}, $2)) {
                    foreach my $id (@nums) {
                        my $channel_id = $yv_utils->get_channel_id($videos->[$id]);
                        my $request    = ($type =~ /shorts/) ? $yv_obj->shorts($channel_id) : $yv_obj->streams($channel_id);
                        if ($yv_utils->has_entries($request)) {
                            __SUB__->($request);
                        }
                        else {
                            warn_no_results($type =~ /shorts/ ? 'shorts' : 'streams');
                        }
                    }
                }
                else {
                    warn_no_thing_selected('video');
                }
            }

            # :s=i, :subscribe=i
            elsif ($opt =~ /^(?:s|sub(?:scribe)?)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    foreach my $id (@nums) {
                        my $channel_id    = $yv_utils->get_channel_id($videos->[$id]);
                        my $channel_title = $yv_utils->get_channel_title($videos->[$id]);
                        subscribe_channel($channel_id, $channel_title);
                    }
                }
                else {
                    warn_no_thing_selected('video');
                }
            }

            # :save=i
            elsif ($opt =~ /^(?:save)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    foreach my $id (@nums) {
                        my $channel_id    = $yv_utils->get_channel_id($videos->[$id]);
                        my $channel_title = $yv_utils->get_channel_title($videos->[$id]);
                        save_channel($channel_id, $channel_title);
                    }
                }
                else {
                    warn_no_thing_selected('video');
                }
            }

            # :pv=i, :popular=i
            elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    foreach my $id (@nums) {
                        my $channel_id = $yv_utils->get_channel_id($videos->[$id]);
                        my $request    = $yv_obj->popular_videos($channel_id);
                        if ($yv_utils->has_entries($request)) {
                            __SUB__->($request);
                        }
                        else {
                            warn_no_results('popular video');
                        }
                    }
                }
                else {
                    warn_no_thing_selected('video');
                }
            }

            # :ps=i, :pstreams=i, :popular-streams=i
            # :pshorts=i, :popular-shorts=i
            elsif ($opt =~ /^(ps|pstreams|popular-streams|pshorts|popular-shorts)${digit_or_equal_re}(.*)/) {
                my $type = $1;
                if (my @nums = get_valid_numbers($#{$videos}, $2)) {
                    foreach my $id (@nums) {
                        my $channel_id = $yv_utils->get_channel_id($videos->[$id]);
                        my $request =
                          ($type =~ /shorts/) ? $yv_obj->popular_shorts($channel_id) : $yv_obj->popular_streams($channel_id);
                        if ($yv_utils->has_entries($request)) {
                            __SUB__->($request);
                        }
                        else {
                            warn_no_results('popular ' . ($type =~ /shorts/ ? 'shorts' : 'streams'));
                        }
                    }
                }
                else {
                    warn_no_thing_selected('video');
                }
            }

            # :p=i, :playlist=i, :up=i
            elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    foreach my $id (@nums) {
                        my $request = $yv_obj->playlists($yv_utils->get_channel_id($videos->[$id]));
                        if ($yv_utils->has_entries($request)) {
                            print_playlists($request);
                        }
                        else {
                            warn_no_results('playlist');
                        }
                    }
                }
                else {
                    warn_no_thing_selected('video');
                }
            }

            # :like=i, :dislike=i
            elsif ($opt =~ /^((?:dis)?like)${digit_or_equal_re}(.*)/) {
                my $rating = $1;
                if (my @nums = get_valid_numbers($#{$videos}, $2)) {
                    rate_videos($rating, map { $videos->[$_] } @nums);
                }
                else {
                    warn_no_thing_selected('video');
                }
            }

            # :fav=i, :favorite=i
            elsif ($opt =~ /^(?:fav|favorite|F)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    favorite_videos(map { $videos->[$_] } @nums);
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt =~ /^(?:q|queue|enqueue)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    push @{$opt{_queue_play}}, map { $yv_utils->get_video_id($videos->[$_]) } @nums;
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt =~ /^(?:pq|qp|play-queue)\z/) {
                if (ref $opt{_queue_play} eq 'ARRAY' and @{$opt{_queue_play}}) {
                    my $ids = 'v=' . join(q{,}, splice @{$opt{_queue_play}});
                    general_options(opt => $ids);
                }
                else {
                    warn colored("\n[!] The playlist is empty!", 'bold red') . "\n";
                }
            }
            elsif ($opt =~ /^c(?:omments?)?${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    get_and_print_comments(map { $yv_utils->get_video_id($videos->[$_]) } @nums);
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt =~ /^r(?:elated)?${digit_or_equal_re}(.*)/) {
                if (my ($id) = get_valid_numbers($#{$videos}, $1)) {
                    get_and_print_related_videos($yv_utils->get_video_id($videos->[$id]));
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt =~ /^(?:w|mark)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    save_watched_video($yv_utils->get_video_id($videos->[$_]), $videos->[$_]) for @nums;
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt =~ /^(?:ap|autoplay)${digit_or_equal_re}(.*)/) {
                if (my ($id) = get_valid_numbers($#{$videos}, $1)) {
                    local $opt{autoplay_mode} = 1;
                    play_videos([$videos->[$id]]);
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt =~ /^d(?:ownload)?${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    local $opt{download_video} = 1;
                    play_videos([@{$videos}[@nums]]);
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt =~ /^(?:play|P)${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    local $opt{download_video} = 0;
                    local $opt{extract_info}   = undef;
                    play_videos([@{$videos}[@nums]]);
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt =~ /^i(?:nfo)?${digit_or_equal_re}(.*)/) {
                if (my @nums = get_valid_numbers($#{$videos}, $1)) {
                    foreach my $num (@nums) {
                        local $opt{show_video_info} = 1;
                        print_video_info($videos->[$num]);
                    }
                    press_enter_to_continue();
                }
                else {
                    warn_no_thing_selected('video');
                }
            }
            elsif ($opt eq 'anp') {    # auto-next-page
                $opt{auto_next_page} = 1;
            }
            elsif ($opt eq 'nnp') {    # no-next-page
                $opt{auto_next_page} = 0;
            }
            elsif ($opt =~ /^[ks]re(?:gex)?=(.*)/) {
                my $value = $1;
                if ($value =~ /^([a-zA-Z]++)(?>,|=>)(.+)/) {
                    play_videos_matched_by_regex(
                                                 key    => $1,
                                                 regex  => $2,
                                                 videos => $videos,
                                                )
                      or __SUB__->($results);
                }
                else {
                    warn_invalid("Special Regexp", $value);
                }
            }
            elsif ($opt =~ /^re(?:gex)?=(.*)/) {
                play_videos_matched_by_regex(
                                             key    => 'title',
                                             regex  => $1,
                                             videos => $videos,
                                            )
                  or __SUB__->($results);
            }
            else {
                warn_invalid('option', $opt);
            }
        }
        elsif (youtube_urls($key)) {
            ## ok
        }
        elsif (!$contains_keywords and (valid_num($key, $videos) or $key =~ /$range_num_re/)) {
            my @for_play;
            if ($key =~ /$range_num_re/) {
                my $from = $1;
                my $to   = $2 // do {
                    $opt{auto_next_page} ? do { $from = 1 } : do { $opt{auto_next_page} = 1 };
                    $#{$videos} + 1;
                };
                my @ids = get_valid_numbers($#{$videos}, "$from..$to");
                if (@ids) {
                    push @for_play, @ids;
                }
                else {
                    push @for_search, $key;
                }
            }
            else {
                push @for_play, $key - 1;
            }

            if (@for_play and not play_videos([@{$videos}[@for_play]])) {
                __SUB__->($results);
            }
        }
        else {
            push @for_search, $key;
        }
    }

    if (@for_search) {
        __SUB__->($yv_obj->search_videos([splice(@for_search)]));
    }
    elsif ($opt{auto_next_page}) {
        @keywords = (':next', grep { $_ !~ /^:(n|next|anp)\z/ } @copy_of_keywords);

        if (@keywords > 1) {
            my $timeout = 2;
            print colored("\n:: Press <ENTER> in $timeout seconds to stop the :anp option.", 'bold green');
            eval {
                local $SIG{ALRM} = sub {
                    die "alarm\n";
                };
                alarm $timeout;
                scalar <STDIN>;
                alarm 0;
            };

            if ($@) {
                if ($@ eq "alarm\n") {
                    __SUB__->($results, auto => 1);
                }
                else {
                    warn colored("\n[!] Unexpected error: <$@>.", 'bold red') . "\n";
                }
            }
            else {
                $opt{auto_next_page} = 0;
                __SUB__->($results);
            }
        }
        else {
            warn colored("\n[!] Option ':anp' works only combined with other options!", 'bold red') . "\n";
            $opt{auto_next_page} = 0;
            __SUB__->($results);
        }
    }

    __SUB__->($results) if not $args{auto};

    return 1;
}

sub press_enter_to_continue {
    say '';
    scalar $term->readline(colored("=>> Press ENTER to continue...", 'bold'));
}

sub main_quit {
    exit($_[0] // 0);
}

main_quit(0);

=head1 CONFIGURATION OPTIONS

=head2 api_host

Hostname of an invidious instance. When set to C<"auto">, a random invidious instance is selected on-demand.

List of public invidious instances:

    https://api.invidious.io/

Tor instances are also supported if the C<socks5://127.0.0.1:9050> Tor proxy is available and the Perl module L<LWP::Protocol::socks> is installed.

=head2 auto_captions

When set to C<1>, auto-generated captions will be retrieved. By default, auto-generated captions are ignored.

=head2 audio_quality

The preferred quality for the audio-track:

    best        # best audio quality available (<=192kbps)
    medium      # medium audio quality (<=128kbps)
    low         # low audio quality (<=50kbps)

The option can also be set to a numeric value N in order to select an audio-track with <= Nkbps.

=head2 autoplay_mode

Enable autoplay mode, which will continuously play related videos.

=head2 bypass_age_gate_native

Bypass age-restricted videos, using our internal method.

However, these streaming URLs are heavily throttled by YouTube.

When this option is disabled, a fallback method will be used instead (C<yt-dlp>/C<youtube-dl> or invidious instances).

=head2 bypass_age_gate_with_proxy

Bypass age-restricted videos using an YouTube Account Proxy.

=head2 cache_dir

Cache directory where to save temporary files.

=head2 colors

Use colors for text.

=head2 comments_order

The sorting order for comments. Valid values: "top", "new".

=head2 confirm

Display a confirmation message after each video played.

=head2 convert_cmd

Command to convert videos.

Default value:

    "ffmpeg -i *IN* *OUT*"

B<*IN*> gets replaced with the input file.

B<*OUT*> gets replaced with the output file.

=head2 convert_to

Format to convert each downloaded video into. (e.g.: C<"mp3">).

=head2 cookie_file

Load cookies from a file. Useful to overcome the "429: Too Many Requests" issue.

The file must be a C<# Netscape HTTP Cookie File>. Same format as C<youtube-dl> requires.

See also:

    https://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl

=head2 copy_caption

When downloading a video, copy the closed-caption (if any) into the same folder with the video.

If C<merge_into_mkv> and C<merge_with_captions> are both enabled, there is no need to enable this option.

=head2 custom_layout_format

An array of hash values specifying a custom layout for video results.

    align       # "left" or "right"
    color       # any color name supported by Term::ANSIColor
    text        # the actual text
    width       # width allocated for the text

The value for C<width> can be either a number of characters (e.g.: 20) or can be a percentage of the terminal width (e.g.: "15%").

The special tokens for C<text> are listed in:

    pipe-viewer --tricks

For better formatting, it's highly recommended to install L<Unicode::GCString> or L<Text::CharWidth>.

=head2 custom_channel_layout_format

An array of hash values specifying a custom layout for channel results.

=head2 custom_playlist_layout_format

An array of hash values specifying a custom layout for playlist results.

=head2 dash

Include or exclude streams in "Dynamic Adaptive Streaming over HTTP" (DASH) format.

=head2 date

Search for videos uploaded within a specific amount of time.

Valid values: "anytime", "hour", "today", "week", "month", "year".

=head2 debug

Enable debug/verbose mode, which will print some extra information.

Valid values: 0, 1, 2, 3.

=head2 dislikes_api

Enable the Return YouTube Dislike API to fetch dislike counts and 5-star ratings. These will be fetched from https://returnyoutubedislike.com.

=head2 downloads_dir

Directory where to download files and where to save converted files.

=head2 download_and_play

Play downloaded videos.

=head2 download_in_subdir

Download videos in a new subdirectory inside the C<downloads_dir> parent directory.

When enabled, videos will be saved inside a subdirectory specified by C<download_in_subdir_format>.

=head2 download_in_subdir_format

Format string used for creating the subdirectory where to download the files, when C<download_in_subdir> is enabled.

The available special tokens are listed in:

    pipe-viewer --tricks

=head2 download_with_wget

Download videos with C<wget>.

=head2 download_with_ytdl

Download videos with `yt-dlp` or `youtube-dl`.

By enabling this option and setting the value of C<ytdl_cmd> to C<"yt-dlp">, download speed will be greatly improved.

=head2 env_proxy

Load proxy settings from C<*_proxy> environment variables (if any).

=head2 fat32safe

When downloading a video, make the filename compatible with the FAT32 filesystem.

Additionally, if L<Text::Unidecode> is available, then Unicode characters are converted to ASCII equivalents.

=head2 features

A list of video features, return only videos with the specified features:

    live               # Live stream
    4k                 # 4K resolution
    hd                 # HD resolution (>=720p)
    subtitles          # Video has subtitles/closed-captions
    creative_commons   # Creative Commons license
    360                # 360° field of view
    vr180              # Stereoscopic widh a 180° field of view
    3d                 # 3D
    hdr                # High dynamic range

=head2 ffmpeg_cmd

Path to the C<ffmpeg> program.

=head2 force_fallback

Force the extraction of the streaming URLs to always use the fallback method (youtube-dl / invidious).

=head2 fullscreen

Play videos in fullscreen mode.

=head2 get_captions

Download closed-captions for videos (if any).

=head2 get_term_width

Read the terminal width (`stty size`).

=head2 hfr

Prefer or ignore High Frame Rate (HFR) video streams.

Try to disable this option if the videos are lagging or dropping frames.

=head2 highlight_color

Highlight color used to highlight watched videos.

Any color name supported by L<Term::ANSIColor> can be used.

=head2 highlight_watched

Highlight watched videos.

=head2 history

Enable or disable support for input history.

Requires L<Term::ReadLine::Gnu>.

=head2 history_file

File where to save the input history.

=head2 history_limit

Maximum number of entries in the history file.

When the limit is reached, the oldest half of the history file will be deleted.

For no limit, set the value to C<-1>.

=head2 http_proxy

Set HTTP(S)/SOCKS proxy, using the format:

    'proto://domain.tld:port/'

If authentication is required, use:

    'proto://user:pass@domain.tld:port/'

For example, to use Tor, install L<LWP::Protocol::socks> and set this value to:

    "socks://127.0.0.1:9050"

=head2 ignore_av1

Ignore videos in AV1 format.

=head2 ignored_projections

An array of video projections to ignore.

For example, to prefer rectangular projections of 360° videos, use:

    ignored_projections => ["mesh", "equirectangular"],

=head2 interactive

Interactive mode, prompting for user-input.

=head2 keep_original_video

Keep the original video after conversion. When set to C<0>, the original video will be deleted.

=head2 local_playlist_limit

When set to a positive value, will restrict the size of the local playlists to this many entries. When this limit has been reached, older entries will be discarded from the playlist.

A reasonable value would be between 500 and 1000.

For no limit, set the value to C<-1>.

=head2 maxResults

How many results to display per page.

Currently, this is not implemented.

=head2 merge_into_mkv

When downloading split videos, merge the audio+video files into an MKV container.

Requires C<ffmpeg>.

=head2 merge_into_mkv_args

Arguments for C<ffmpeg> how to merge the files.

=head2 merge_with_captions

Include closed-captions inside the MKV container (if any).

=head2 order

Search order for videos.

Valid values: "relevance", "rating", "upload_date", "view_count".

=head2 page

Page number of results.

=head2 prefer_av1

Prefer videos in AV1 format. (experimental)

=head2 prefer_mp4

Prefer videos in MP4 (AVC) format.

Try to enable this option if the videos are lagging or dropping frames.

=head2 prefer_m4a

Prefer audio streams in M4A (AAC) format.

By default, the OPUS format for audio is preferred.

=head2 prefer_invidious

Prefer invidious instances over parsing the YouTube website directly.

=head2 region

ISO 3166 country code (default: "US").

=head2 remove_played_file

When C<download_and_play> is enabled, remove the file after playing it.

=head2 resolution

Preferred resolution for videos.

Valid values: best, 2160p, 1440p, 1080p, 720p, 480p, 360p, 240p, 144p, audio.

=head2 show_video_info

Show extra info for videos when selected.

=head2 skip_if_exists

When downloading, skip if the file already exists locally.

=head2 skip_watched

Skip already watched/downloaded videos.

=head2 skip_youtube_extraction

If enabled, the application will not extract direct video/audio URLs from YouTube. Instead, it will pass the original YouTube page URL to the media player (e.g., mpv, VLC), letting the player handle extraction. This can be usefull for avoiding slow double extraction.

=head2 split_videos

Enable or disable support for split-videos. Split-videos are videos that do not include audio and video in the same file.

Disable this option if the videos are loading too slowly, as non-split videos are not throttled by YouTube. Although, the highest resolution of non-split videos is 360p.

=head2 srt_languages

List of SRT languages in the order of preference.

=head2 saved_channels_file

Absolute path to the file where to store saved channels (C<:save=i>).

=head2 set_mtime

When enabled, it will set the original modification date of a downloaded video, using the published date of the video.

=head2 subscribed_channels_file

Absolute path to the file where to store subscribed channels (C<:sub=i>).

=head2 subscription_results

Comma-separated list of methods used to retrieve videos from subscribed channels.

    subscription_results => "uploads,streams",   # include streams in feed

Valid values: uploads, streams, shorts.

=head2 subscriptions_lifetime

Amount of time, in seconds, before rescanning the subscribed channels for new videos (during C<-ls>).

=head2 subscriptions_limit

Maximum number of subscription videos to store in the local database. Set to C<0> for no limit.

=head2 timeout

HTTPS timeout value in seconds. The default value is 10 seconds.

=head2 max_retries

Maximum number of retry attempts for failed network requests (default: 3).
Applies to both GET and POST requests, including thumbnails, API calls, and video data fetching.

=head2 retry_delay

Initial delay in seconds between retry attempts (default: 1).
Uses exponential backoff: 1s → 2s → 4s → 8s (capped at 30s) with jitter.

=head2 user_agent

Token that is used to identify the user agent on the network. The agent value is sent as the C<User-Agent> header in the requests.

=head2 video_filename_format

Format string used for creating the filename of downloaded videos.

The available special tokens are listed in:

    pipe-viewer --tricks

=head2 video_player_selected

The selected video player defined in the C<video_players> table.

=head2 video_players

A table of video players.

The keys for each player are:

    arg        # any arguments for the video player
    audio      # option specifying the *AUDIO* file
    cmd        # the main player command
    fs         # the fullscreen option
    novideo    # the no-video mode option
    srt        # option specifying the *SUB* file

=head2 videoDuration

Retrieve only short or long videos in search results.

Valid values: "any", "short" (under 4 minutes), "average" (4-20 minutes), and "long" (over 20 minutes).

=head2 watch_history

Set to C<1> to remember and highlight watched videos across multiple sessions.

Watched videos can be listed with:

    pipe-viewer -wv

The video IDs are saved in the filename specified by C<watch_history_file>.

=head2 watch_history_file

File where to save the video IDs of watched/downloaded videos when C<watch_history> is set to a true value.

=head2 wget_cmd

Command for C<wget> when C<download_with_wget> is set to a true value.

=head2 youtube_video_url

Format for C<sprintf()> for constructing an YouTube video URL given the video ID.

=head2 youtube_channel_url

Format for C<sprintf()> for constructing an YouTube channel URL given the channel ID.

=head2 youtube_playlist_url

Format for C<sprintf()> for constructing an YouTube playlist URL given the playlist ID.

=head2 ytdl

Use C<youtube-dl> for videos with encrypted signatures.

When set to C<0>, invidious instances will be used instead.

=head2 ytdl_cmd

Command for C<yt-dlp> or C<youtube-dl> when C<ytdl> is set to C<1>.

Also command for C<yt-dlp> for extracting YouTube comments when C<ytdlp_comments> is set to C<1>.

=head2 ytdlp_comments

When set to C<1>, use C<yt-dlp> for extracting YouTube comments. (experimental)

When set to C<0>, invidious instances will be used instead.

=head2 ytdlp_max_comments

Maximum number of comments to extract with C<yt-dlp>.

Replies count as comments.

=head2 ytdlp_max_replies

Maximum number of replies per thread.

Use C<0> to disable replies.

Use C<"all"> to extract all replies.

=head1 CONFIGURATION FILES

The configuration files are:

    ~/.config/pipe-viewer/pipe-viewer.conf
    ~/.config/pipe-viewer/gtk-pipe-viewer.conf

=head1 INVIDIOUS API REFERENCE

https://github.com/iv-org/invidious/wiki/API

=head1 REPOSITORY

https://github.com/trizen/pipe-viewer

=head1 LICENSE AND COPYRIGHT

Copyright 2010-2026 Trizen.

This program is free software; you can redistribute it and/or modify it
under the terms of either: the GNU General Public License as published
by the Free Software Foundation; or the Artistic License.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

See L<https://dev.perl.org/licenses/> for more information.

=cut
