93

Yes, I'm sorting out my music. I've got everything arranged beautifully in the following mantra: /Artist/Album/Track - Artist - Title.ext and if one exists, the cover sits in /Artist/Album/cover.(jpg|png).

I want to scan through all the second-level directories and find the ones that don't have a cover. By second level, I mean I don't care if /Britney Spears/ doesn't have a cover.jpg, but I would care if /Britney Spears/In The Zone/ didn't have one.

Don't worry about the cover-downloading (that's a fun project for me tomorrow) I only care about the glorious bash-fuiness about an inverse-ish find example.

2

5 Answers 5

120

Case 1: You know the exact file name to look for

Use find with test -e your_file to check if a file exists. For example, you look for directories which have no cover.jpg in them:

find base_dir -mindepth 2 -maxdepth 2 -type d '!' -exec test -e "{}/cover.jpg" ';' -print

It's case sensitive though.

Case 2: You want to be more flexible

You're not sure of the case, and the extension might be jPg, png...

find base_dir -mindepth 2 -maxdepth 2 -type d '!' -exec sh -c 'ls -1 "{}"|egrep -i -q "^cover\.(jpg|png)$"' ';' -print

Explanation:

  • You need to spawn a shell sh for each directory since piping isn't possible when using find
  • ls -1 "{}" outputs just the filenames of the directory find is currently traversing
  • egrep (instead of grep) uses extended regular expressions; -i makes the search case insensitive, -q makes it omit any output
  • "^cover\.(jpg|png)$" is the search pattern. In this example, it matches e.g. cOver.png, Cover.JPG or cover.png. The . must be escaped otherwise it means that it matches any character. ^ marks the start of the line, $ its end

Other search pattern examples for egrep:

Substitute the egrep -i -q "^cover\.(jpg|png)$" part with:

  • egrep -i -q "cover\.(jpg|png)$" : Also matches cd_cover.png, album_cover.JPG ...
  • egrep -q "^cover\.(jpg|png)$" : Matches cover.png, cover.jpg, but NOT Cover.jpg (case sensitivity is not turned off)
  • egrep -iq "^(cover|front)\.jpg$" : matches e.g. front.jpg, Cover.JPG but not Cover.PNG

For more info on this, check out Regular Expressions.

8
  • 1
    Absolutely beautiful — with the problem that it's not flexible to pick between cases or different extensions (I tried a wildcard but no-go). I wonder if there's a better alternative to test.
    – Oli
    Oct 6, 2012 at 0:08
  • 1
    Hmm you can nest the find with this -exec bash -c '[[ -n $(find "{}" -iname "cover.*") ]]' \; but that's pretty filthy in terms of optimisation. It does work though.
    – Oli
    Oct 6, 2012 at 22:26
  • I found that you can pass test a load of -o EXPRESSION for OR queries... eg: test -e "{}/cover.jpg" -o -e "{}/cover.png" which is better than doing a full blown search but it's still case sensitive.
    – Oli
    Oct 9, 2012 at 8:10
  • I should note that comparing the performance of this (two tests, per my last comment) against the other two solutions (comm'd find and comm'd globbing) this is by far the slowest (684ms vs 40ms and 50ms respectively)
    – Oli
    May 16, 2013 at 13:43
  • The original in-answer solution takes over a second and breaks in circumstances that have $ in the dir name (Ke$ha, for example).
    – Oli
    May 16, 2013 at 13:45
13

Simple, it transpires. The following gets a list of directories with the cover and compares that with a list of all the second-level directories. Lines that appear in both "files" are suppressed, leaving a list of directories that need covers.

comm -3 \
    <(find ~/Music/ -iname 'cover.*' -printf '%h\n' | sort -u) \
    <(find ~/Music/ -maxdepth 2 -mindepth 2 -type d | sort) \
| sed 's/^.*Music\///'

Hooray.

Notes:

  • comm's arguments are as follows:

    • -1 suppress lines unique to file1
    • -2 suppress lines unique to file2
    • -3 suppress lines that appear in both files
  • comm only takes files, hence the kooky <(...) input method. This pipes the content via a real [temporary] file.

  • comm needs sorted input or it doesn't work and find does by no means guarantee an order. It also needs to be unique. The first find operation could find multiple files for cover.* so there could be duplicate entries. sort -u quickly ruffles those down to one. The second find is always going to be unique.

  • dirname is a handy tool for getting a file's dir without resorting to sed (et al).

  • find and comm are both a bit messy with their output. The final sed is there to clean things up so you're left with Artist/Album. This may or may not be desirable for you.

2
  • 2
    your first find can possibly be simplified to find ~/Music/ -iname 'cover.*' -printf '%h\n', avoiding the need for dirname. though dirname is handy elsewhere.
    – Tom
    Oct 9, 2012 at 22:32
  • Thanks @Tom, that is a lot faster that forking out everywhere (29ms vs 734ms on my music dir - both "warm" finds)
    – Oli
    Oct 10, 2012 at 9:37
9

This is much nicer to solve with globbing than with find.

$ cd ... # to the directory one level above the album/artist structure

$ echo */*/*.cover   # lists all the covers

$ printf "%s\n" */*/*.cover # lists all the covers, one per line

Now suppose you have no stray files in this nice structure. The current directory contains only artist subdirectories, and those contain only album subdirectories. Then we can do something like this:

$ diff  <(for x in */*/cover.jpg; do echo "$(dirname "$x")" ; done) <(printf "%s\n" */*)

The <(...) syntax is Bash process substitution: it lets you use a command in place of a file argument. It lets you treat the output of a command as a file. So we can run two programs, and take their diff, without saving their output in temporary files. The diff program thinks it is working with two files, but in fact it's reading from two pipes.

The command that produces the right hand input to diff, printf "%s\n" */*, just lists the album directories. The left hand command iterates through the *.cover paths and prints their directory names.

Test run:

$ find .   # let's see what we have here
.
./a
./a/b
./foo
./foo/bar
./foo/baz
./foo/baz/cover.jpg

$ diff  <(for x in */*/cover.jpg; do echo "$(dirname "$x")" ; done) <(printf "%s\n" */*)
0a1,2
> a/b
> foo/bar

Aha, the a/b and foo/bar directories have no cover.jpg.

There are some broken corner cases, like that by default * expands to itself if it matches nothing. This can be addressed with Bash's set -o nullglob.

2
  • Apologies for the late reply. It's an interesting idea but: covers can be in png and jpb and , wouldn't comm be cleaner than diff?
    – Oli
    May 16, 2013 at 13:27
  • comm -3 <(printf "%s\n" */*/cover* | sed -r 's/\/[^\/]+$//' | sort -u) <(printf "%s\n" */*) seems like a sensible compromise without any of diff's fluff. It is, however, a little bit slower than my double-find.
    – Oli
    May 16, 2013 at 13:43
3

Let's make it simple and easy to understand.

for dir in /*/*; do
  if [ ! -f $dir/cover.jpg ] && [ ! -f $dir/cover.png ]; then
    echo $dir
  fi
done

Make it one line (note that we need an extra ; in the one-line version):

for dir in /*/*; do if [ ! -f $dir/cover.jpg ] && [ ! -f $dir/cover.png ]; then echo $dir; fi done
1
  • 2
    Finally! No inscrutable find incantations, no temporary files, just a simple answer to a simple question.
    – Mark VY
    Jun 23, 2022 at 23:23
1
ls --color=never */*.txt | sed 's|/.*||' | sort -u -n > withtxt.txt
ls --color=never -d * | sort -u -n > all.txt
diff all.txt withtxt.txt

Will show all directories that do not have txt files in them.

You must log in to answer this question.

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