Using read to capture multiple variables from a command's output

I have a youtube-dl command that when run outputs two new lines, and I'm trying to capture each of those to variables.

Here is what I have so far, but for some reason I can't understand, it only captures the first line of output while discarding the second ($audio is empty).

read -r video audio <<< "$(youtube-dl -g --youtube-skip-dash-manifest https://www.youtube.com/watch?v=-gcvAxJJiGo)"

How can I fix it to make it capture both inputs, or alternatively, is there a more reliable way to do so?


Solution 1:

read -r video audio will capture the first ($IFS separated) word into "video" and the rest of the line into "audio"

Another way to capture 2 lines is with command grouping and a process substitution:

{
  read -r video
  read -r audio
} < <(
  youtube-dl -g --youtube-skip-dash-manifest "$url"
)

The space between < <( is important: a <(process substitution) acts like a file, so you're using < to redirect input from a file.


It just occurred to me: another way to read lines of output is the mapfile command:

mapfile -t lines < <(youtube-dl ...)
declare -p lines  # just to inspect the array contents

video=${lines[0]}
audio=${lines[1]}

Solution 2:

read reads one line by design. The simplest way to read two lines is to use read twice.

exec 3< <(youtube-dl -g --youtube-skip-dash-manifest 'https://www.youtube.com/watch?v=-gcvAxJJiGo')
IFS= read -r -u3 video
IFS= read -r -u3 audio
exec 3>&-

read -u is not portable, neither is <(…). They work in Bash and your question is tagged bash, so they should work for you.

Note I single-quoted the string containing ? in case you had a directory https:/www.youtube.com/ and a matching file therein. It's only remotely possible, still quoting wildcard characters that should not be treated as wildcards is good practice in any shell. Compare what happens in Zsh.

Empty IFS is the right thing in general, when you want to read a line.

This other answer does not use exec and it's good. It's my personal preference to set input up visually before tools that will use it (compare this answer). Unfortunately <(…) { …; } does not work. With exec I can do it.

Or I can do it by piping youtube-dl to the rest of the script.

youtube-dl -g --youtube-skip-dash-manifest 'https://www.youtube.com/watch?v=-gcvAxJJiGo' \
| {
IFS= read -r video
IFS= read -r audio
}

Normally Bash runs the { … } fragment in a subshell, so the variables will not survive after }. shopt -s lastpipe can change this (if job control is not active). Without lastpipe (which is not portable) the entire approach is portable but you need to run everything that uses the variables inside the { } block because you don't know if it's a subshell or not (POSIX allows both behaviors). Note the stdin inside the block comes from youtube-dl; if anything inside the block needs to use the stdin of the entire script then you may need to use exec anyway to juggle the descriptors.

Another portable approach is like this:

input="$(youtube-dl -g --youtube-skip-dash-manifest 'https://www.youtube.com/watch?v=-gcvAxJJiGo')"
video="$(printf '%s\n' "$input" | head -n 1)"
audio="$(printf '%s\n' "$input" | head -n 2 | tail -n 1)"

Instead of head and tail you can use sed or awk to isolate the right line.

Note this solution does not use read. If you need features of read then this is not the way. It seems you don't need the features of read in this particular case though.

Even if youtube-dl was very fast, I would not advise video="$(youtube-dl … | head -n 1)" plus audio="$(youtube-dl … | …)". If video and audio come from the same input, from the same invocation of youtube-dl, then they are as coherent as the tool allows. Otherwise they may be incoherent. Frankly I don't know how incoherent they may be (if ever); but I know in general it's better to query once (e.g. date +%H:+%M should always be coherent; date +%H; date +%M will surprise you if you run it just before a full hour).