4

The following script, play_movie.sh is meant to automatically select a movie if there is only one in the current directory and play it with ffplay. Otherwise it is meant to display a selection of films to the user and take input from them (the film to be played).

#!/usr/bin/bash

output_a=$(ls -R *.[aA][vV][iI] 2>/dev/null)
output_m=$(ls -R *.[mM][kKpP][vV4]] 2>/dev/null)
output_v=$(ls -R *.[vV][oO][bB]] 2>/dev/null)
all_exts="${output_a}${output_m}${output_v}"
ln_cnt=$(echo "${all_exts}" | wc -l)

if [[ "$ln_cnt" -eq 1 ]]; then
  echo -e "Playing: ${all_exts}\n"
  ffplay -hide_banner -infbuf -fs -sn -ast a:0 "${all_exts}"
elif [[ "$ln_cnt" -gt 1 ]]; then
  printf "Select a file out of the list below"
  ls -R *.[aA][vV][iI] 2>/dev/null 
  ls -R *.[mM][kKpP][vV4] 2>/dev/null 
  ls -R *.[vV][oO][bB] 2>/dev/null
  read line; ffplay -hide_banner -infbuf -fs -sn -ast a:0 "$line"
fi

The problem that I have with this script:

  1. The script executes the first branch of if statement even if there are many movies in the current directory. Then I get No such file or directory.
  2. If there is only one film to be played I get No such file or directory.

I had trouble relying on the output of ls in the past. Is it doable to fix this script without the need of using find?

5
  • 6
    You don't. ls doesn't work like that and you can't really parse the output of ls reliable. Use find ... -print0 and arrays (readarray -td '' in bash) or zsh and its recursive globs with qualifiers instead Aug 22, 2023 at 8:41
  • 5
    If you think you need to parse the output of ls, chances are you are approaching the problem incorrectly. find is usually what you want instead, possibly plus some post-processing of the file names or some calls to stat to get the data you want. Aug 22, 2023 at 19:24
  • 1
    Why are you using the -R option? That's for recursing into subdirectories.
    – Barmar
    Aug 23, 2023 at 13:38
  • @Barmar -R is not necessary. Sometimes I have directories of many seasons of a TV series - in that case I find that useful.
    – John Smith
    Aug 23, 2023 at 18:19
  • When debugging, shell flag -x is your friend! Also using shell arrays (in BASH) is probably more reliable (like array=($(ls -R | grep 'your pattern')); ${#array[*]} is the number of items).
    – U. Windl
    Aug 24, 2023 at 9:18

2 Answers 2

13

Assuming the “if there is only one in the current directory” part is correct (i.e. you only care about files in the current directory), you can avoid using ls, let alone find:

#!/usr/bin/bash -

shopt -s nullglob
set -- *.[aA][vV][iI] *.[mM][kKpP][vV4] *.[vV][oO][bB]

if [[ "$#" -eq 1 ]]; then
  printf>&2 "Playing: %s\n\n" "$@"
  ffplay -hide_banner -infbuf -fs -sn -ast a:0 -- "$@"
elif [[ "$#" -gt 1 ]]; then
  printf>&2 "Select a file out of the list below:\n"
  printf>&2 "%s\n" "$@"
  IFS= read -r line &&
    ffplay -hide_banner -infbuf -fs -sn -ast a:0 -- "$line"
fi

This sets nullglob so that globs with no matches are removed, then sets the parameters to all matching files.

As illustrated in Stéphane Chazelas’ answer, you can use select to improve the user experience (select uses the positional parameters by default, which fits in nicely with the use of set above):

#!/usr/bin/bash -

shopt -s nullglob
set -- *.[aA][vV][iI] *.[mM][kKpP][vV4] *.[vV][oO][bB]

if [[ "$#" -eq 1 ]]; then
  printf>&2 "Playing: %s\n\n" "$@"
  ffplay -hide_banner -infbuf -fs -sn -ast a:0 -- "$@"
elif [[ "$#" -gt 1 ]]; then
  select video; do
    ffplay -hide_banner -infbuf -fs -sn -ast a:0 -- "$video"
    exit
  done
fi
3
  • Would you decode set -- *.[aA][vV][iI] *.[mM][kKpP][vV4] *.[vV][oO][bB] for me? What does -- mean? The line seems to assign the output of ls *.[aA][vV][iI] *.[mM][kKpP][vV4] *.[vV][oO][bB] to $@
    – John Smith
    Aug 22, 2023 at 16:34
  • set can do many things; one of those is to set the positional parameters. set -- foo will set the positional parameters to the single string “foo”; the -- is used to indicate the start of the arguments for the positional parameters (and allows an empty set of arguments to be distinguished from the no-argument set invocation). Aug 22, 2023 at 16:44
  • With set -- *.[aA][vV][iI] *.[mM][kKpP][vV4] *.[vV][oO][bB], the shell expands the globs; since nullglob is enabled, globs with no matches are removed, and globs with matches are replaced with those matches. Then set -- sets the positional parameters to the files that were found (if any). "$@" expands to all the positional parameters, properly quoted. $# expands to the number of positional parameters, which corresponds to the number of files found. Aug 22, 2023 at 16:46
12

You can't use ls like that. Those *.[aA][vV][iI] are expanded by the shell, not ls, the -R for ls is for it to Recursively list the contents of directories, for which you need to pass it the path of directories, not files, and even then, it would output the list of files (any non-hidden files) in those directories without their directory components, so ffmpeg could not find them.

Also, unless you use the --zero option of very recent versions of the GNU implementation of ls, the output of ls is simply not post-processable reliably.

Here to find files with that list of extensions anywhere in the current working directory and below, you'd use find. To store a list of files (or of anything for that matters), you use an array variable, not a string/scalar variable.

Using zsh instead of bash here would make it a lot easier as zsh has (properly working) recursive globbing and glob qualifiers which mean you can also restrict the list of files to those of type regular. Globs also skip hidden files by default (like ls but not find where you need to do that skipping by hand):

#! /usr/bin/zsh -
set -o extendedglob
videos=( **/*.(#i)(avi|mkv|mp4|vob)(N-.) )

play() {
  print -ru2 Playing $argv
  ffplay -hide_banner -infbuf -fs -sn -ast a:0 -- $argv
}

case $#videos in
  (0) print -u2 No video found; exit 1;;
  (1) play $videos;;
  (*) print -u2 Select a video:
      select video in $videos; do
        play $video || exit
        break
      done;;
esac

With bash (the GNU shell) + GNU find, you could do something approaching with:

#! /usr/bin/bash -
readarray -td '' videos < <(
  LC_ALL=C find . \
    -regextype posix-extended \
    -name '.?*' -prune -o \
      -iregex '.*\.(avi|mkv|mp4|vob)' \
      -xtype f \
      -printf '%P\0' |
    sort -z
)

shopt -u xpg_echo
play() {
  echo>&2 -E Playing "$@"
  ffplay -hide_banner -infbuf -fs -sn -ast a:0 -- "$@"
}

case ${#videos[@]} in
  (0) echo>&2 No video found; exit 1;;
  (1) play "${videos[@]}";;
  (*) echo>&2 Select a video:
      select video in "${videos[@]}"; do
        play "$video" || exit
        break
      done;;
esac

Some other notes:

  • Your echo -e would break for filenames that contain \ characters. Here you do not want the escape sequences to be expanded, hence the -E for echo or -r for print. If you want it to print an extra newline, add it to the last argument of echo literally or with $'\n'. Also note that bash's echo only accepts options if either the posix or xpg_echo is unset (and they are set by default on some systems and/or some environments). Generally, using echo is best avoided, I still use it here for its ability to easily print its arguments joined with spaces (which is more cumbersome to do with printf, and bash only has print as a loadable plugin whose few systems have included by default when they have bash at all) and on the ground that we know the shell is bash, so the play function could be used to play more than one video at a time.
  • Prompts and user messages should generally go to stderr (fd 2). That's where select's output goes for instance.
  • The syntax to read a line is IFS= read -r line, not read line. But in any case, file paths don't have to be made of a single line, as the newline character is as valid as any in a file name.
  • note that your *.[mM][kKpP][vV4] would also match on files.mv4 (not m4v).

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .