Subversion Repositories yt_downloader

Rev

Rev 19 | Rev 21 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

#!/usr/bin/php
<?php

// ViaThinkSoft YouTube Downloader Util 2.4
// Revision: 2022-12-27
// Author: Daniel Marschall <www.daniel-marschall.de>
// Licensed under the terms of the Apache 2.0 License
//
// For syntax and other documentation, please read the file README.

// ------------------------------------------------------------------------------------------------

error_reporting(E_ALL | E_NOTICE | E_STRICT | E_DEPRECATED);

define('AUTO_API_KEY', '~/.yt_api_key');
define('AUTO_COOKIE_FILE', '~/.yt_cookies');
define('DOWNLOAD_SIMULATION_MODE', false);
define('DEFAULT_SEARCH_ORDER', 'relevance');
define('DEFAULT_SEARCH_MAXRESULTS', 10);

putenv("LANG=de_DE.UTF-8"); // required if video titles contain non-ASCII symbols

require_once __DIR__ . '/youtube_functions.inc.phps';
require_once __DIR__ . '/checksum_functions.inc.phps';

// Check if we are running in command line

if (PHP_SAPI !== 'cli') {
        fwrite(STDERR, "Error: Can only run in CLI mode\n");
        exit(2);
}

// Global vars

$listFilenameStack = array();

// Default values

$allow_creation_outputdir = false;
$type = 'v:';
$outputDir = '';
$alreadyDownloaded = '';
$checksumMode = 'none';
$failList = '';
$failTreshold = 3;
$rest_args = array();
$verbose = false;
$mp3id_transfer = true;
$apikey = '';
$resultcache = '';
$extra_args =
//            '-k ' . // The additional "-k" option in the above makes youtube-dl keep downloaded videos.
              '-i ' . // continue upon download errors
              '-c ';  // resume partially downloaded video files
$default_template = '%(title)s-%(id)s.%(ext)s';
$cookie_file = AUTO_COOKIE_FILE;
$downloader = 'yt-dlp';

if (($downloader == 'yt-dlp') && file_exists(__DIR__.'/ffmpeg')) {
        // With yt-dlp, you might get the error "ERROR: Postprocessing: Conversion failed!" if
        // you have a tool old ffmpeg version.
        $extra_args .= ' --ffmpeg-location '.escapeshellarg(__DIR__.'/ffmpeg');
}

// Parse arguments
// We do not use getopt() at the moment, because the important functionality "optind" is only available in PHP 7.1, which is not yet distributed with most of the stable Linux distros

$init_extra_args = false;
$argv_bak = $_SERVER['argv'];
array_shift($argv_bak);
while (count($argv_bak) > 0) {
        $arg = array_shift($argv_bak);
        $arg2 = $arg . ' ' . (isset($argv_bak[0]) ? $argv_bak[0] : '');
        $m = null;
        if (preg_match('@^(/t|\-t|\-\-type)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $type = $m[3];
        } else if (preg_match('@^(/o|\-o|\-\-outputDir)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $outputDir = $m[3];
        } else if (preg_match('@^(/a|\-a|\-\-alreadyDownloaded)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $alreadyDownloaded = $m[3];
        } else if (preg_match('@^(/f|\-f|\-\-failList)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $failList = $m[3];
        } else if (preg_match('@^(/F|\-F|\-\-failTreshold)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $failTreshold = $m[3];
        } else if (preg_match('@^(/C|\-C|\-\-resultcache)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $resultcache = $m[3];
        } else if (preg_match('@^(/H|\-H|\-\-checksumMode)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $checksumMode = $m[3];
                if ((strtolower($checksumMode) != 'none')
                        && (strtolower($checksumMode) != 'sfv')
                        && (strtolower($checksumMode) != 'md5')
                        && (strtolower($checksumMode) != 'sfv,md5')
                        && (strtolower($checksumMode) != 'md5,sfv')) syntax_error("Checksum mode needs to be either 'None', 'MD5', 'SFV', or 'MD5,SFV'.");
        } else if (preg_match('@^(/T|\-T|\-\-default-template)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $default_template = $m[3];
        } else if (preg_match('@^(/A|\-A|\-\-api-key)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $apikey = file_exists($m[3]) ? trim(file_get_contents($m[3])) : $m[3];
        } else if (preg_match('@^(\-\-cookies)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $cookie_file = file_exists($m[3]) ? trim(file_get_contents($m[3])) : $m[3];
        } else if (preg_match('@^(\-\-downloader)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $downloader = $m[3];
        } else if (preg_match('@^(/X|\-X|\-\-extra-args)(\s+|=)(.*)$@s', $arg2, $m)) {
                array_shift($argv_bak);
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                if ($init_extra_args) {
                        $extra_args .= ' ' . $m[3]; // user has multiple "-X" arguments. append.
                } else {
                        $extra_args = $m[3]; // overwrite defaults
                        $init_extra_args = true;
                }
        } else if (preg_match('@^(/\?|/h|\-\?|\-h|\-\-help)$@s', $arg, $m)) {
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                help();
        } else if (preg_match('@^(/V|\-V|\-\-version)$@s', $arg, $m)) {
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                version();
        } else if (preg_match('@^(/v|\-v|\-\-verbose)$@s', $arg, $m)) {
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $verbose = true;
        } else if (preg_match('@^(/N|\-N|\-\-no-mp3-tagtransfer)$@s', $arg, $m)) {
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $mp3id_transfer = false;
        } else if (preg_match('@^(/O|\-O|\-\-create-outputdir)$@s', $arg, $m)) {
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $allow_creation_outputdir = true;
        } else if ($arg == '--') {
                if (count($rest_args) > 0) syntax_error("Invalid argument: ".$rest_args[0]);
                $rest_args = $argv_bak;
                break;
        } else {
                $rest_args[] = $arg;
        }
}
unset($arg);
unset($argv_bak);
unset($init_extra_args);

define('DOWNLOAD_YT_FORK', $downloader);

// Validity checks

if ((substr($type,0,2) != 'a:') && (substr($type,0,2) != 'v:')) syntax_error("Type must be either 'v:' or 'a:'. '$type' is not valid.");

if (count($rest_args) == 0) syntax_error("Please enter at least one desired video for downloading");

if ($failTreshold <= 0) syntax_error("Fail treshold has invalid value. Must be >0.");

$cookie_file = expand_tilde($cookie_file);
if (!file_exists($cookie_file)) $cookie_file = '';

// Try to download/update youtube-dl/yt-dlp into local directory
download_latest_downloader();

if (command_exists(__DIR__.'/'.DOWNLOAD_YT_FORK)) {
        echo "Will use '".DOWNLOAD_YT_FORK."' from local directory\n";
        define('YTDL_EXE', __DIR__.'/'.DOWNLOAD_YT_FORK);
} else {
        // Download failed. Is at least a package installed?
        if (command_exists(DOWNLOAD_YT_FORK)) {
                echo "Will use '".DOWNLOAD_YT_FORK."' from Linux package\n";
                define('YTDL_EXE', DOWNLOAD_YT_FORK);
        } else {
                fwrite(STDERR, "This script requires the tool/package '".DOWNLOAD_YT_FORK."'. Please install it first.\n");
                exit(1);
        }
}

// Now process the videos

yt_set_apikey_callback('_getApikey');

foreach ($rest_args as $resource) {
        if ($verbose) echo "Handle: $resource\n";
        if (strpos($resource, ':') === false) {
                fwrite(STDERR, "Invalid resource '$resource' (you are missing the prefix, e.g. vurl: or vid:). Skipping.\n");
        } else {
                list($resourceType, $resourceValue) = explode(':', $resource, 2);
                try {
                        ytdwn_handle_resource($resourceType, $resourceValue);
                } catch(Exception $e) {
                        fwrite(STDERR, "Error at '$resourceType:$resourceValue': ".$e->getMessage()."\n");
                }
        }
}

// ------------------------------------------------------------------------------------------------

function ytdwn_handle_resource($resourceType, $resourceValue) {
        if ($resourceType == 'vid') {
                $video_id = parse_quoting($resourceValue);
                ytdwn_video_id($video_id);
        } else if ($resourceType == 'vurl') {
                $video_url = parse_quoting($resourceValue);
                $video_id  = getVideoIDFromURL($video_url);
                if (!$video_id) {
                        fwrite(STDERR, "$video_url is not a valid YouTube video URL. Skipping.\n");
                } else {
                        ytdwn_video_id($video_id);
                }
        } else if ($resourceType == 'pid') {
                $playlist_id = parse_quoting($resourceValue);
                ytdwn_playlist_id($playlist_id);
        } else if ($resourceType == 'purl') {
                $playlist_url = parse_quoting($resourceValue);
                $playlist_id  = getPlaylistIDFromURL($playlist_url);
                if (!$playlist_id) {
                        fwrite(STDERR, "$playlist_url is not a valid YouTube playlist URL. Skipping\n");
                } else {
                        ytdwn_playlist_id($playlist_id);
                }
        } else if ($resourceType == 'cid') {
                $channel_id = parse_quoting($resourceValue);

                $m = null;
                if (preg_match('@\[search=(.+)\]@ismU', $channel_id, $m)) {
                        $search = $m[1];
                        $channel_id = preg_replace('@\[search=(.+)\]@ismU', '', $channel_id);
                } else {
                        $search = ''; // default
                }
                $search = parse_quoting($search);

                ytdwn_channel_id($channel_id, $search);
        } else if ($resourceType == 'cname') {
                $channel_name = parse_quoting($resourceValue);

                $m = null;
                if (preg_match('@\[search=(.+)\]@ismU', $channel_name, $m)) {
                        $search = $m[1];
                        $channel_name = preg_replace('@\[search=(.+)\]@ismU', '', $channel_name);
                } else {
                        $search = ''; // default
                }
                $search = parse_quoting($search);

                $channel_name = parse_quoting($channel_name);
                $channel_id = yt_get_channel_id($channel_name);
                if (!$channel_id) {
                        fwrite(STDERR, "URL $channel_name is a valid YouTube username. Will now try to interprete it as channel ID instead....\n");
                }
                ytdwn_channel_id($channel_id, $search);
        } else if ($resourceType == 'curl') {
                $channel_url = parse_quoting($resourceValue);

                $m = null;
                if (preg_match('@\[search=(.+)\]@ismU', $channel_url, $m)) {
                        $search = $m[1];
                        $channel_url = preg_replace('@\[search=(.+)\]@ismU', '', $channel_url);
                } else {
                        $search = ''; // default
                }
                $search = parse_quoting($search);

                $channel_url = parse_quoting($channel_url);
                $channel_id = curl_to_cid($channel_url);
                if (!$channel_id) {
                        fwrite(STDERR, "URL $channel_url is a valid YouTube channel or username URL. Skipping\n");
                } else {
                        ytdwn_channel_id($channel_id, $search);
                }
        } else if ($resourceType == 'search') {
                $searchterm = parse_quoting($resourceValue);

                $order = '';
                $m = null;
                if (preg_match('@\[order=(.+)\]@ismU', $searchterm, $m)) {
                        $order = $m[1];
                        $searchterm = preg_replace('@\[order=(.+)\]@ismU', '', $searchterm);
                } else {
                        $order = DEFAULT_SEARCH_ORDER; // default
                }
                $order = parse_quoting($order);

                $maxresults = '';
                if (preg_match('@\[maxresults=(.+)\]@ismU', $searchterm, $m)) {
                        $maxresults = $m[1];
                        $searchterm = preg_replace('@\[maxresults=(.+)\]@ismU', '', $searchterm);
                } else {
                        $maxresults = DEFAULT_SEARCH_MAXRESULTS; // default
                }
                $maxresults = parse_quoting($maxresults);

                $searchterm = parse_quoting($searchterm);

                ytdwn_search($searchterm, $order, $maxresults);
        } else if ($resourceType == 'list') {
                $list_files = glob(parse_quoting($resourceValue)); // in case the user entered a wildcard, e.g. *.list
                foreach ($list_files as $list_file) {
                        if (!file_exists($list_file)) {
                                fwrite(STDERR, "List file $list_file does not exist. Skipping\n");
                        } else {
                                ytdwn_list_file($list_file);
                        }
                }
        } else {
                fwrite(STDERR, "Resource type '$resourceType' is not valid. Skipping $resourceType:$resourceValue.\n");
        }
}

function ytdwn_list_file($list_file) {
        global $listFilenameStack, $verbose;

        if ($verbose) echo "Processing list file '$list_file' ...\n";

        $listFilenameStack[] = $list_file;
        $lines = file($list_file);
        foreach ($lines as $line) {
                $line = trim($line);
                if ($line == '') continue;
                if ($line[0] == '#') continue;
                if (strpos($line, ':') === false) {
                        fwrite(STDERR, "Invalid resource '$line' (you are missing the prefix, e.g. vurl: or vid:). Skipping.\n");
                } else {
                        list($resourceType, $resourceValue) = explode(':',$line,2);
                        try {
                                ytdwn_handle_resource($resourceType, $resourceValue);
                        } catch(Exception $e) {
                                fwrite(STDERR, "Error at line '$line': ".$e->getMessage()."\n");
                        }
                }
        }
        array_pop($listFilenameStack);
}

function ytdwn_channel_id($channel_id, $search='') {
        global $type;
        global $verbose;

        if ($verbose) echo "Processing channel ID '$channel_id' ...\n";

        // List the videos of the channel

        $use_cache = !empty(_getResultcache()) && file_exists(_getResultcache());
        $cont = $use_cache ? file_get_contents(_getResultcache()) : '';
        $out = json_decode($cont, true);
        if ($out == NULL) $out = array();

        if ($use_cache) {
                $stats = yt_get_channel_stats($channel_id);
                if (!$stats) {
                        fwrite(STDERR, "Cannot get stats for channel with ID '$channel_id'\n");
                        return;
                }
                $videocount = $stats['videoCount'];

                $key = (!empty($search)) ? 'cid:'.$channel_id.'/'.$search : 'cid:'.$channel_id;

                if (!isset($out[$key])) $out[$key] = array();
                $videocount_old = isset($out[$key]['count']) ? $out[$key]['count'] : -1;
        } else {
                $videocount = -1;
                $videocount_old = -2;
                $key = '';
        }

        if ($videocount_old != $videocount) { // Attention: This might not work if videos are deleted and added (= the count stays the same)
                if ($verbose && $use_cache) echo "Video count changed from $videocount_old to $videocount\n";
                $out[$key]['count'] = $videocount;
                if (!empty($search)) {
                        $out[$key]['results'] = yt_channel_items($channel_id, $search);
                } else {
                        $out[$key]['results'] = yt_channel_items($channel_id);
                }
                if (!$out[$key]['results']) {
                        // TODO: If a channel has deleted all videos, then this message comes.
                        //       However, technically this is not an error.
                        fwrite(STDERR, "Cannot get result for channel with ID '$channel_id'\n");
                        return;
                }
        } else {
                if ($verbose) echo "Video count for channel is still $videocount, keep ".count($out[$key]['results'])." results.\n";
        }

        // Save the cache

        try {
                if ($use_cache) file_put_contents(_getResultcache(), json_encode($out));
        } catch(Exception $e) {
                fwrite(STDERR, "Cannot write result cache\n");
        }

        // Now download

        if (!$out[$key]['results']) {
                // TODO: If a channel has deleted all videos, then this message comes.
                //       However, technically this is not an error.
                fwrite(STDERR, "Cannot get result for channel with ID '$channel_id'\n");
                return;
        }
        foreach ($out[$key]['results'] as list($id, $title)) {
                if ($verbose) echo "Downloading '$title' as ".hf_type($type)." ...\n";
                ytdwn_video_id($id);
        }
}

function ytdwn_playlist_id($playlist_id) {
        global $type;
        global $verbose;

        if ($verbose) echo "Processing playlist ID '$playlist_id' ...\n";

        // List the videos of the playlist

        $use_cache = !empty(_getResultcache()) && file_exists(_getResultcache());
        $cont = $use_cache ? file_get_contents(_getResultcache()) : '';
        $out = json_decode($cont, true);
        if ($out == NULL) $out = array();

        if ($use_cache) {
                $stats = yt_get_playlist_stats($playlist_id);
                if (!$stats) {
                        fwrite(STDERR, "Cannot get stats for playlist with ID '$playlist_id'\n");
                        return;
                }
                $videocount = $stats['itemCount'];

                $key = 'pid:'.$playlist_id;

                if (!isset($out[$key])) $out[$key] = array();
                $videocount_old = isset($out[$key]['count']) ? $out[$key]['count'] : -1;
        } else {
                $videocount = -1;
                $videocount_old = -2;
                $key = '';
        }

        if ($videocount_old != $videocount) { // Attention: This might not work if videos are deleted and added (= the count stays the same)
                if ($verbose && $use_cache) echo "Video count changed from $videocount_old to $videocount\n";
                $out[$key]['count'] = $videocount;
                $out[$key]['results'] = yt_playlist_items($playlist_id);
                if (!$out[$key]['results']) {
                        // TODO: If a playlist has deleted all videos, then this message comes.
                        //       However, technically this is not an error.
                        fwrite(STDERR, "Cannot get result for playlist with ID '$playlist_id'\n");
                        return;
                }
        } else {
                if ($verbose) echo "Video count for playlist is still $videocount, keep ".count($out[$key]['results'])." results.\n";
        }

        // Save the cache

        try {
                if ($use_cache) file_put_contents(_getResultcache(), json_encode($out));
        } catch(Exception $e) {
                fwrite(STDERR, "Cannot write result cache\n");
        }

        // Now download

        if (!$out[$key]['results']) {
                fwrite(STDERR, "Cannot get result for playlist with ID '$playlist_id'\n");
                return;
        }
        foreach ($out[$key]['results'] as list($id, $title)) {
                if ($verbose) echo "Downloading '$title' as ".hf_type($type)." ...\n";
                ytdwn_video_id($id);
        }
}

function ytdwn_search($search, $order='', $maxresults=-1) {
        global $type;
        global $verbose;

        if ($verbose) echo "Searching for '$search' ...\n";

        // Perform the search and list the videos

        $results = yt_search_items($search, $order, $maxresults);

        // Now download

        if (!$results) {
                fwrite(STDERR, "Cannot get data for search '$search'\n");
                return;
        }
        foreach ($results as list($id, $title)) {
                if ($verbose) echo "Downloading '$title' as ".hf_type($type)." ...\n";
                ytdwn_video_id($id);
        }
}

function template_to_wildcard($template, $video_id) {
        $x = $template;
        $x = str_replace('%(id)s', $video_id, $x);
        $x = preg_replace('@%\\(.+\\)s@ismU', '*', $x);
        $x = preg_replace('@\\*+@', '*', $x);
        return $x;
}

function ytdwn_get_downloaded_filename($outputTemplate, $video_id) {
        if (strpos($outputTemplate, '%(id)s') === false) {
                // TODO: There needs to be a better way to find out the written file name !!!
                return false;
        } else {
                $wildcard = template_to_wildcard($outputTemplate, $video_id);
                $test = glob($wildcard);
                if (count($test) == 0) return false;
                return $test[0];
        }
}

function ytdwn_video_id($video_id) {
        global $type;
        global $verbose;
        global $mp3id_transfer;
        global $extra_args;
        global $default_template;
        global $failTreshold;
        global $cookie_file;
        global $checksumMode;

        if (DOWNLOAD_SIMULATION_MODE) {
                echo "SIMULATE download of video id $video_id as ".hf_type($type)." to "._getOutputDir()."\n";
                return;
        }

        if (!empty(_getAlreadyDownloaded()) && in_alreadydownloaded_file($type, $video_id)) {
                if ($verbose) echo "Video $video_id has already been downloaded. Skip.\n";
                return true;
        }

        if (!empty(_getFailList()) && (ytdwn_fail_counter($type, $video_id) >= $failTreshold)) {
                if ($verbose) echo "Video $video_id has failed too often. Skip.\n";
                return true;
        }

        $out = '';
        $code = -1;

        $outputTemplate = rtrim(_getOutputDir(), '/').'/'.$default_template;

        if (substr($type,0,2) == 'v:') {
                $format = substr($type,2);
                if (!empty($format)) {
                        $cmd = YTDL_EXE.' -o '.escapeshellarg($outputTemplate).' '.$extra_args.(empty($cookie_file) ? '' : ' --cookies '.$cookie_file).' '.escapeshellarg(vid_to_vurl($video_id)).' --format '.escapeshellarg($format);
                        echo "$cmd\n";
                        exec($cmd, $out, $code);
                } else {
                        $cmd = YTDL_EXE.' -o '.escapeshellarg($outputTemplate).' '.$extra_args.(empty($cookie_file) ? '' : ' --cookies '.$cookie_file).' '.escapeshellarg(vid_to_vurl($video_id));
                        echo "$cmd\n";
                        exec($cmd, $out, $code);
                }

                $written_file = $code == 0 ? ytdwn_get_downloaded_filename($outputTemplate, $video_id) : false;

        } else if (substr($type,0,2) == 'a:') {
                $format = substr($type,2);
                if (!empty($format)) {
                        $cmd = YTDL_EXE.' -o '.escapeshellarg($outputTemplate).' '.$extra_args.(empty($cookie_file) ? '' : ' --cookies '.$cookie_file).' '.escapeshellarg(vid_to_vurl($video_id)).' --extract-audio --audio-format '.escapeshellarg($format);
                        echo "$cmd\n";
                        exec($cmd, $out, $code);
                } else {
                        $cmd = YTDL_EXE.' -o '.escapeshellarg($outputTemplate).' '.$extra_args.(empty($cookie_file) ? '' : ' --cookies '.$cookie_file).' '.escapeshellarg(vid_to_vurl($video_id)).' --extract-audio';
                        echo "$cmd\n";
                        exec($cmd, $out, $code);
                }

                $written_file = $code == 0 ? ytdwn_get_downloaded_filename($outputTemplate, $video_id) : false;

                if (($code == 0) && ($mp3id_transfer) && (strtolower($format) == 'mp3')) {
                        if (!$written_file) {
                                fwrite(STDERR, "Cannot include YouTube ID to MP3, because the default template does not contain '%(id)s', or the downloaded file could not be determined for another reason.\n");
                        } else {
                                mp3_transfer_vid_to_id($written_file, $video_id);
                        }
                }
        } else {
                assert(false);
                return false;
        }

        if ($code == 0) {
                if ($verbose) {
                        fwrite(STDOUT, "Successfully downloaded video ID $video_id as ".hf_type($type)."\n");
                        if ($written_file) fwrite(STDOUT, "Output file name: $written_file\n");
                }
                if (!empty(_getAlreadyDownloaded())) {
                        try {
                                addto_alreadydownloaded_file($type, $video_id);
                        } catch(Exception $e) {
                                fwrite(STDERR, "Cannot add to 'already downloaded' file\n");
                        }
                }

                // Now do the checksums
                foreach (explode(',',$checksumMode) as $mode) {
                        if (strtolower($mode) === 'none') continue;
                        if (!$written_file) {
                                fwrite(STDERR, "Cannot add to the '$mode' checksum file, because the default template does not contain '%(id)s', or the downloaded file could not be determined for another reason.\n");
                        } else if (!cs_add_automatically($written_file, $mode)) {
                                fwrite(STDERR, "Could not write to '$mode' checksum file!\n");
                        }
                }
        } else {
                fwrite(STDERR, "Error downloading $video_id! (Code $code)\n");
                if (!empty(_getFailList())) {
                        try {
                                ytdwn_register_fail($type, $video_id, $code);
                        } catch(Exception $e) {
                                fwrite(STDERR, "Cannot register fail\n");
                        }
                }
                return false;
        }

        return true;
}

function vid_to_vurl($video_id) {
        return "https://www.youtube.com/watch?v=$video_id";
}

function EndsWith($Haystack, $Needle){
        return strrpos($Haystack, $Needle) === strlen($Haystack)-strlen($Needle);
}

function mp3_transfer_vid_to_id(&$written_file, $video_id) {
        global $verbose;
        global $default_template;

        if (!command_exists('id3v2')) {
                fwrite(STDERR, "Tool id3v2 is not installed. Will not transfer the YouTube ID into the MP3 ID Tag. Use paramter '-N' to stop trying the transfer.\n");
                return false;
        }

        $orig_ts = filemtime($written_file);
        $ec = -1;
        system('id3v2 -c '.escapeshellarg($video_id).' '.escapeshellarg($written_file), $ec);
        touch($written_file, $orig_ts);
        if ($ec != 0) {
                fwrite(STDERR, "Cannot set ID tag for file $written_file\n");
                return false;
        }

        $target_filename = $written_file;

        // Things like '<title>-<id>.mp3' become '<title>.mp3' (our default template)
        // But templates like '<title> (<id>).mp3' could become '<title> ().mp3', which is not nice
        // So, we try our best to find the most common template types...
        $target_filename = str_replace('-'.$video_id, '', $target_filename);
        $target_filename = str_replace('_'.$video_id, '', $target_filename);
        $target_filename = str_replace(' '.$video_id, '', $target_filename);
        $target_filename = str_replace('('.$video_id.')', '', $target_filename);
        $target_filename = str_replace('['.$video_id.']', '', $target_filename);
        $target_filename = str_replace($video_id, '', $target_filename); // must be the last!
        if ($target_filename === $written_file) {
                fwrite(STDERR, "Could not remove VideoID from filename '$written_file'\n"); // should not happen
                return false;
        }

        if (!intelligent_rename($written_file, $target_filename)) {
                fwrite(STDERR, "Could not rename '$written_file' to '$target_filename'\n");
                return false;
        }

        $written_file = $target_filename; // was modified by intelligent_rename()
        return true;
}

function curl_to_cid($channel_url) {
        return yt_get_channel_id_from_url($channel_url);
}

function in_alreadydownloaded_file($type, $video_id) {
        $lines = file(_getAlreadyDownloaded());
        foreach ($lines as $line) {
                if (trim($line) == rtrim($type,':').':'.$video_id) {
                        return true;
                }
        }
        return false;
}

function addto_alreadydownloaded_file($type, $video_id) {
        file_put_contents(_getAlreadyDownloaded(), rtrim($type,':').':'.$video_id."\n", FILE_APPEND);
}

function syntax_error($msg) {
        fwrite(STDERR, "Syntax error: ".trim($msg)."\n");
        fwrite(STDERR, "Please use argument '--help' to show the syntax rules.\n");
        exit(2);
}

function _help() {
        global $argv;
        $out = '';
        $own = file_get_contents($argv[0]);
        $help = explode('// ----', $own, 2)[0];
        $m = null;
        $help = preg_match_all('@^//(.*)$@mU', $help, $m);
        foreach ($m[1] as $line) {
                $out .= substr($line,1)."\n";
        }
        return $out;
}

function help() {
        echo _help();
        exit(0);
}

function version() {
        echo explode("\n\n", _help(), 2)[0]."\n";
        exit(0);
}

function command_exists($command) {
        // https://stackoverflow.com/questions/592620/check-if-a-program-exists-from-a-bash-script

        $ec = -1;
        system('command -v '.escapeshellarg($command).' > /dev/null', $ec);
        return ($ec == 0);
}

function hf_type($type) {
        if (strpos($type, ':') === false) return $type; // invalid type (missing ':')
        list($av, $format) = explode(':', $type);

        if ($av == 'a') $av = 'audio';
        else if ($av == 'v') $av = 'video';
        else return $type; // invalid type

        return (!empty($format)) ? $format.'-'.$av : $av;
}

function expand_tilde($path) {
        // Source: http://jonathonhill.net/2013-09-03/tilde-expansion-in-php/

        if (function_exists('posix_getuid') && strpos($path, '~') !== false) {
                $info = posix_getpwuid(posix_getuid());
                $path = str_replace('~', $info['dir'], $path);
        }

        return $path;
}

function _getLastListname() {
        global $listFilenameStack;
        $listname = ''; // default
        if (count($listFilenameStack) > 0) {
                $listname = $listFilenameStack[count($listFilenameStack)-1];
                $listname = pathinfo($listname, PATHINFO_FILENAME); // remove file extension, e.g. ".list"
        }
        return $listname;
}

function _getApiKey() {
        global $apikey;

        $out = $apikey;
        if (empty($out)) {
                $auto_api_key = AUTO_API_KEY;
                $auto_api_key = expand_tilde($auto_api_key);
                $auto_api_key = str_replace('[listname]', _getLastListname(), $auto_api_key);

                if (file_exists($auto_api_key)) {
                        $out = trim(file_get_contents($auto_api_key));
                } else {
                        syntax_error("Please specify a YouTube API key with argument '-A'.");
                }
        } else {
                $out = str_replace('[listname]', _getLastListname(), $out);
                $out = expand_tilde($out);

                if (file_exists($out)) {
                        $out = trim(file_get_contents($out));
                } else {
                        // Assume, $out is a key, not a file
                }
        }

        if (!yt_check_apikey_syntax($out)) syntax_error("'$out' is not a valid API key, not an existing file containing an API key.\n");

        return $out;
}

function _getResultCache() {
        global $resultcache;
        if (empty($resultcache)) return '';

        $out = expand_tilde($resultcache);

        $out = str_replace('[listname]', _getLastListname(), $out);

        if (!file_exists($out)) {
                @touch($out);
                if (!file_exists($out)) {
                        fwrite(STDERR, "File '$out' cannot be created. Disable feature.\n");
                        return '';
                }
        }

        return $out;
}

function _getAlreadyDownloaded() {
        global $alreadyDownloaded;
        if (empty($alreadyDownloaded)) return '';

        $out = expand_tilde($alreadyDownloaded);

        $out = str_replace('[listname]', _getLastListname(), $out);

        if (!file_exists($out)) {
                @touch($out);
                if (!file_exists($out)) {
                        fwrite(STDERR, "File '$out' cannot be created. Disable feature.\n");
                        return '';
                }
        }

        return $out;
}

function _getFailList() {
        global $failList;
        if (empty($failList)) return '';

        $out = expand_tilde($failList);

        $out = str_replace('[listname]', _getLastListname(), $out);

        if (!file_exists($out)) {
                @touch($out);
                if (!file_exists($out)) {
                        fwrite(STDERR, "File '$out' cannot be created. Disable feature.\n");
                        return '';
                }
        }

        return $out;
}

function _getOutputDir() {
        global $outputDir, $allow_creation_outputdir;
        if (empty($outputDir)) return '.';

        $out = expand_tilde($outputDir);

        $out = str_replace('[listname]', _getLastListname(), $out);

        if ($allow_creation_outputdir) {
                if (!is_dir($out)) {
                        mkdir($out, 0777, true);
                        if (!is_dir($out)) {
                                fwrite(STDERR, "Output directory '$out' does not exist.\n");
                                exit(1);
                        }
                }
        } else {
                if (!is_dir($out)) {
                        fwrite(STDERR, "Output directory '$out' does not exist.\n");
                        exit(1);
                }
        }

        return $out;
}

function parse_quoting($str) {
        if ((substr($str,0,1) == '"') && (substr($str,-1,1) == '"')) {
                $str = substr($str,1,strlen($str)-2);

                $escape = false;
                $out = '';
                for ($i=0; $i<strlen($str); $i++) {
                        $char = $str[$i];

                        if ($char == '\\') {
                                if ($escape) {
                                        $out .= $char;
                                        $escape = false;
                                } else {
                                        $escape = true;
                                }
                        } else {
                                $out .= $char;
                        }

                }
                $str = $out;

        }

        return $str;
}

function ytdwn_register_fail($type, $video_id, $code) {
        // Note: Error code $code ist currently not used

        $file = _getFailList();
        $cont = file_get_contents($file);
        $m = null;
        if (preg_match("@Video ID ".preg_quote($video_id,'@')." failed (\d+) time\(s\) with type ".preg_quote($type,'@')."@ismU", $cont, $m)) {
                $cont = preg_replace("@Video ID ".preg_quote($video_id,'@')." failed (\d+) time\(s\) with type ".preg_quote($type,'@')."@ismU",
                                     "Video ID $video_id failed ".(((int)$m[1])+1)." time(s) with type $type", $cont);
                file_put_contents($file, $cont);
        } else {
                file_put_contents($file, "Video ID $video_id failed 1 time(s) with type $type\n", FILE_APPEND);
        }
}

function ytdwn_fail_counter($type, $video_id) {
        $file = _getFailList();
        $cont = file_get_contents($file);
        $m = null;
        if (preg_match("@Video ID ".preg_quote($video_id,'@')." failed (\d+) time\(s\) with type ".preg_quote($type,'@')."@ismU", $cont, $m)) {
                return $m[1];
        } else {
                return 0;
        }
}

function intelligent_rename($src, &$dest) {
        $pos = strrpos($dest, '.');
        $ext = substr($dest, $pos);
        $namewoext = substr($dest, 0, $pos);
        $failcnt = 1;
        $dest_neu = $dest;
        while (file_exists($dest_neu)) {
                $failcnt++;
                $dest_neu = "$namewoext ($failcnt)$ext";
        }
        $res = rename($src, $dest_neu);
        if ($res) $dest = $dest_neu;
        return $res;
}

function download_latest_downloader() {
        $download_url = false;

        if (DOWNLOAD_YT_FORK == 'yt-dlp') {
                $ch = curl_init();
                curl_setopt($ch, CURLOPT_URL, 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/SHA2-256SUMS');
                #curl_setopt($ch, CURLOPT_HEADER, false);
                curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                $cont = curl_exec($ch);
                $m = null;
                if (preg_match('@^(.+)\s+yt-dlp$@ismU', $cont, $m)) {
                        $newest_version_sha2 = $m[1];
                } else {
                        $newest_version_sha2 = false;
                }

                if (!$newest_version_sha2) {
                        fwrite(STDERR, "Failed to get SHA2 sum of latest version of '".DOWNLOAD_YT_FORK."' online. Will not try to download/update '".DOWNLOAD_YT_FORK."' into local directory.\n");
                } else {
                        if (!file_exists(__DIR__.'/'.DOWNLOAD_YT_FORK) || ($newest_version_sha2 != hash_file('sha256',__DIR__.'/'.DOWNLOAD_YT_FORK))) {
                                $download_url = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp';
                        }
                }
        }

        if (DOWNLOAD_YT_FORK == 'youtube-dlc') {
                // TODO: What is the difference between these two? Which one should be chosen?!
                // https://github.com/blackjack4494/yt-dlc (18372 commits, last commit 23 Aug 2021, last release 2020.11.11-3)
                // https://github.com/blackjack4494/youtube-dlc (18888 commits, last commit 26 Jul 2021, last release 2020.10.09)

                // TODO: yt-dlc The sha256 does not fit to the binary! Therefore, it is always downloaded!
                // TODO: youtube-dlc has no hashes online...
                /*
                $ch = curl_init();
                curl_setopt($ch, CURLOPT_URL, 'https://github.com/blackjack4494/yt-dlc/releases/latest/download/SHA2-256SUMS');
                #curl_setopt($ch, CURLOPT_HEADER, false);
                curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                $cont = curl_exec($ch);
                $m = null;
                if (preg_match('@^youtube\-dlc:(.+)$@ismU', $cont, $m)) {
                        $newest_version_sha2 = $m[1];
                } else {
                        $newest_version_sha2 = false;
                }

                if (!$newest_version_sha2) {
                        fwrite(STDERR, "Failed to get SHA2 sum of latest version of '".DOWNLOAD_YT_FORK."' online. Will not try to download/update '".DOWNLOAD_YT_FORK."' into local directory.\n");
                } else {
                        if (!file_exists(__DIR__.'/'.DOWNLOAD_YT_FORK) || ($newest_version_sha2 != hash_file('sha256',__DIR__.'/'.DOWNLOAD_YT_FORK))) {
                                $download_url = 'https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc';
                        }
                }
                */

                if (!file_exists(__DIR__.'/'.DOWNLOAD_YT_FORK) || (time()-filemtime(__DIR__.'/'.DOWNLOAD_YT_FORK) > 24*60*60)) {
                        $download_url = 'https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc';
                }
        }

        if (DOWNLOAD_YT_FORK == 'youtube-dl') {
                $ch = curl_init();
                curl_setopt($ch, CURLOPT_URL, 'https://yt-dl.org/downloads/latest/MD5SUMS');
                #curl_setopt($ch, CURLOPT_HEADER, false);
                curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                $cont = curl_exec($ch);
                $m = null;
                if (preg_match('@^(.+)  youtube\-dl$@ismU', $cont, $m)) {
                        $newest_version_md5 = $m[1];
                } else {
                        $newest_version_md5 = false;
                }

                if (!$newest_version_md5) {
                        fwrite(STDERR, "Failed to get MD5 sum of latest version of '".DOWNLOAD_YT_FORK."' online. Will not try to download/update '".DOWNLOAD_YT_FORK."' into local directory.\n");
                } else {
                        if (!file_exists(__DIR__.'/'.DOWNLOAD_YT_FORK) || ($newest_version_md5 != md5_file(__DIR__.'/'.DOWNLOAD_YT_FORK))) {
                                $download_url = 'https://yt-dl.org/latest/youtube-dl';
                        }
                }
        }

        if ($download_url) {
                // Try to download/update the file in our directory. It should be the newest available, since YT often breaks downloader
                if (file_exists(__DIR__.'/'.DOWNLOAD_YT_FORK)) {
                        echo "Trying to update '".DOWNLOAD_YT_FORK."' in local directory...\n";
                } else {
                        echo "Trying to download '".DOWNLOAD_YT_FORK."' into local directory...\n";
                }

                @chmod(__DIR__.'/'.DOWNLOAD_YT_FORK, 0777); // otherwise we might not be able to write to it

                if (!($binary = file_get_contents($download_url))) {
                        fwrite(STDERR, "Failed to download '".DOWNLOAD_YT_FORK."' into local directory (file_get_contents).\n");
                } else if (!@file_put_contents(__DIR__.'/'.DOWNLOAD_YT_FORK, $binary)) {
                        fwrite(STDERR, "Failed to download '".DOWNLOAD_YT_FORK."' into local directory (file_put_contents).\n");
                } else {
                        if (!@chmod(__DIR__.'/'.DOWNLOAD_YT_FORK, 0544)) {
                                fwrite(STDERR, "Failed to download '".DOWNLOAD_YT_FORK."' into local directory (chmod 544).\n");
                                @unlink(__DIR__.'/'.DOWNLOAD_YT_FORK); // try to delete, otherwise we might try to execute a non-executable file
                        }
                }
        }
}