Repository: beetbox/beets

Issue:
program abortion due to exception in fnmatch_all()
Sorry about not knowing much how to compose this kind of messages!

In the duplicates init.py, under _def prune_dirs(),_ I've added a try-except-clause in order to avoid error and aborted run

It now looks like this:

```
for directory in ancestors:
        directory = syspath(directory)
        if not os.path.exists(directory):
            # Directory gone already.
            continue
        try:
            if fnmatch_all(os.listdir(directory), clutter):
                # Directory contains only clutter (or nothing).
                try:
                    shutil.rmtree(directory)
                except OSError:
                    break
                except BaseException as foo:
                    print(foo)
                    break
            else:
                break
        except BaseException as foo:
            print(foo)
            break
```
### Problem

I haven't really understood exactly what's going wrong.
(Maybe some day when I've more energy?)

The program aborts with a non-intuitive error message about too many levels of symlinks.
### Setup
- Linux Mint 64 bits, 17.3, xfce
- Python 2.7
- beets version: beets version 1.3.17
  plugins: duplicates

My configuration is:

``` yaml
user configuration: /home/johan/.config/beets/config.yaml
data directory: /home/johan/.config/beets
plugin paths: 
Sending event: pluginload
library database: /tmp/tracks.db
library directory: /mnt/qnap-212
Sending event: library_opened
verbose: 1
library: /tmp/tracks.db
per_disc_numbering: yes

statefile: prints.pickle
chroma:
    auto: yes
original_date: yes
ftintitle:
    format: (feat. {0})
lastgenre:
    auto: no
    count: 5
    force: no
    source: track
    separator: '/ '
    whitelist: ~/.config/beets/whitelist.txt
    canonical: ~/.config/beets/canonical.txt
duplicates:
    keys: [acoustid_fingerprint]
    merge: yes
    tiebreak:
        items: [bitrate]
    count: no
    full: no
    format: ''
    move: ''
    tag: ''
    path: no
    copy: ''
    album: no
    strict: no
    checksum: ''
    delete: no

plugins: chroma duplicates
directory: /mnt/qnap-212/

import:
    write: yes
    copy: no
    move: no
    link: no
    delete: no
    resume: ask
    incremental: yes
    timid: no
    log: prints_importlog.txt
    autotag: no
    singletons: yes
    default_action: apply
    detail: no
    flat: no
    group_albums: no
    pretend: no
    languages:
    - en
    - de
    - es
    - sv
    - fr
    - pt
    - fi
    - it
log: printslog.txt

paths:
    singleton: '%if{$mb_trackid,mb_}Singles/%if{$artist,%lower{%asciify{$artist}},_}/%if{$album,%lower{%asciify{$album}},_}%if{$year, [$year]}/%if{$disc,$disc-}%if{$track,$track. }%if{$title,$title,_}%if{$album, [$album]}'
    comp: '%if{$mb_albumid,mb_}Collections/%lower{%asciify{$album}}_%aunique{}/%if{$disc,$disc-}%if{$track,$track. }%if{$title,$title,_}%if{$artist, [$artist]}'
    default: '%if{$mb_albumid,mb_}Albums/%if{$albumartist,%lower{%asciify{$albumartist}}_}/%lower{%asciify{$album}}_%aunique{}/%if{$disc,$disc-}%if{$track,$track. }%if{$title,$title,_}'
replace:
    '[\\/\xa0]': _
    '[`\x27]': "\u2019"
    '[\"]': "\u201D"
    \.\.\.: "\u2026"
    ^\-: _
    ^\.: _
    '[\x00-\x1f]': _
    '[<>:"\?\*\|]': _
    \.$: _
    \s+$: ''
    ^\s+: ''

match:
    distance_weights:
        artist: 2.0
        album: 2.5
        year: 1.0
        label: 0.5
        catalognum: 0.5
        albumdisambig: 0.5
        album_id: 2.0
        tracks: 2.0
        missing_tracks: 0.1
        unmatched_tracks: 5.0
        track_title: 2.0
        track_artist: 2.0
        track_index: 1.0
        track_length: 9.0
        track_id: 2.0
    preferred:
        countries: []
        media: []
        original_year: yes
    ignored: [track_length unmatched_tracks]
    track_length_grace: 3
    track_length_max: 15

```


Comments:

Patch:
diff --git a/beets/random.py b/beets/random.py
new file mode 100644
index 00000000..5387da4d
--- /dev/null
+++ b/beets/random.py
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2016, Philippe Mongeau.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Get a random song or album from the library.
+"""
+from __future__ import division, absolute_import, print_function
+
+import random
+from operator import attrgetter
+from itertools import groupby
+
+
+def _length(obj, album):
+    """Get the duration of an item or album.
+    """
+    if album:
+        return sum(i.length for i in obj.items())
+    else:
+        return obj.length
+
+
+def _equal_chance_permutation(objs, field='albumartist', random_gen=None):
+    """Generate (lazily) a permutation of the objects where every group
+    with equal values for `field` have an equal chance of appearing in
+    any given position.
+    """
+    rand = random_gen or random
+
+    # Group the objects by artist so we can sample from them.
+    key = attrgetter(field)
+    objs.sort(key=key)
+    objs_by_artists = {}
+    for artist, v in groupby(objs, key):
+        objs_by_artists[artist] = list(v)
+
+    # While we still have artists with music to choose from, pick one
+    # randomly and pick a track from that artist.
+    while objs_by_artists:
+        # Choose an artist and an object for that artist, removing
+        # this choice from the pool.
+        artist = rand.choice(list(objs_by_artists.keys()))
+        objs_from_artist = objs_by_artists[artist]
+        i = rand.randint(0, len(objs_from_artist) - 1)
+        yield objs_from_artist.pop(i)
+
+        # Remove the artist if we've used up all of its objects.
+        if not objs_from_artist:
+            del objs_by_artists[artist]
+
+
+def _take(iter, num):
+    """Return a list containing the first `num` values in `iter` (or
+    fewer, if the iterable ends early).
+    """
+    out = []
+    for val in iter:
+        out.append(val)
+        num -= 1
+        if num <= 0:
+            break
+    return out
+
+
+def _take_time(iter, secs, album):
+    """Return a list containing the first values in `iter`, which should
+    be Item or Album objects, that add up to the given amount of time in
+    seconds.
+    """
+    out = []
+    total_time = 0.0
+    for obj in iter:
+        length = _length(obj, album)
+        if total_time + length <= secs:
+            out.append(obj)
+            total_time += length
+    return out
+
+
+def random_objs(objs, album, number=1, time=None, equal_chance=False,
+                random_gen=None):
+    """Get a random subset of the provided `objs`.
+
+    If `number` is provided, produce that many matches. Otherwise, if
+    `time` is provided, instead select a list whose total time is close
+    to that number of minutes. If `equal_chance` is true, give each
+    artist an equal chance of being included so that artists with more
+    songs are not represented disproportionately.
+    """
+    rand = random_gen or random
+
+    # Permute the objects either in a straightforward way or an
+    # artist-balanced way.
+    if equal_chance:
+        perm = _equal_chance_permutation(objs)
+    else:
+        perm = objs
+        rand.shuffle(perm)  # N.B. This shuffles the original list.
+
+    # Select objects by time our count.
+    if time:
+        return _take_time(perm, time * 60, album)
+    else:
+        return _take(perm, number)
diff --git a/beets/util/__init__.py b/beets/util/__init__.py
index f3dedcb4..f5ad2da2 100644
--- a/beets/util/__init__.py
+++ b/beets/util/__init__.py
@@ -283,13 +283,13 @@ def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
             continue
         clutter = [bytestring_path(c) for c in clutter]
         match_paths = [bytestring_path(d) for d in os.listdir(directory)]
-        if fnmatch_all(match_paths, clutter):
-            # Directory contains only clutter (or nothing).
-            try:
+        try:
+            if fnmatch_all(match_paths, clutter):
+                # Directory contains only clutter (or nothing).
                 shutil.rmtree(directory)
-            except OSError:
+            else:
                 break
-        else:
+        except OSError:
             break
 
 
diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py
index 423cde2b..876dcacd 100644
--- a/beetsplug/mpdstats.py
+++ b/beetsplug/mpdstats.py
@@ -256,10 +256,6 @@ class MPDStats(object):
         if not path:
             return
 
-        if is_url(path):
-            self._log.info(u'playing stream {0}', displayable_path(path))
-            return
-
         played, duration = map(int, status['time'].split(':', 1))
         remaining = duration - played
 
@@ -276,6 +272,14 @@ class MPDStats(object):
                 if diff <= self.time_threshold:
                     return
 
+                if self.now_playing['path'] == path and played == 0:
+                    self.handle_song_change(self.now_playing)
+
+        if is_url(path):
+            self._log.info(u'playing stream {0}', displayable_path(path))
+            self.now_playing = None
+            return
+
         self._log.info(u'playing {0}', displayable_path(path))
 
         self.now_playing = {
diff --git a/beetsplug/random.py b/beetsplug/random.py
index 65caaf90..a8e29313 100644
--- a/beetsplug/random.py
+++ b/beetsplug/random.py
@@ -19,97 +19,7 @@ from __future__ import division, absolute_import, print_function
 
 from beets.plugins import BeetsPlugin
 from beets.ui import Subcommand, decargs, print_
-import random
-from operator import attrgetter
-from itertools import groupby
-
-
-def _length(obj, album):
-    """Get the duration of an item or album.
-    """
-    if album:
-        return sum(i.length for i in obj.items())
-    else:
-        return obj.length
-
-
-def _equal_chance_permutation(objs, field='albumartist'):
-    """Generate (lazily) a permutation of the objects where every group
-    with equal values for `field` have an equal chance of appearing in
-    any given position.
-    """
-    # Group the objects by artist so we can sample from them.
-    key = attrgetter(field)
-    objs.sort(key=key)
-    objs_by_artists = {}
-    for artist, v in groupby(objs, key):
-        objs_by_artists[artist] = list(v)
-
-    # While we still have artists with music to choose from, pick one
-    # randomly and pick a track from that artist.
-    while objs_by_artists:
-        # Choose an artist and an object for that artist, removing
-        # this choice from the pool.
-        artist = random.choice(list(objs_by_artists.keys()))
-        objs_from_artist = objs_by_artists[artist]
-        i = random.randint(0, len(objs_from_artist) - 1)
-        yield objs_from_artist.pop(i)
-
-        # Remove the artist if we've used up all of its objects.
-        if not objs_from_artist:
-            del objs_by_artists[artist]
-
-
-def _take(iter, num):
-    """Return a list containing the first `num` values in `iter` (or
-    fewer, if the iterable ends early).
-    """
-    out = []
-    for val in iter:
-        out.append(val)
-        num -= 1
-        if num <= 0:
-            break
-    return out
-
-
-def _take_time(iter, secs, album):
-    """Return a list containing the first values in `iter`, which should
-    be Item or Album objects, that add up to the given amount of time in
-    seconds.
-    """
-    out = []
-    total_time = 0.0
-    for obj in iter:
-        length = _length(obj, album)
-        if total_time + length <= secs:
-            out.append(obj)
-            total_time += length
-    return out
-
-
-def random_objs(objs, album, number=1, time=None, equal_chance=False):
-    """Get a random subset of the provided `objs`.
-
-    If `number` is provided, produce that many matches. Otherwise, if
-    `time` is provided, instead select a list whose total time is close
-    to that number of minutes. If `equal_chance` is true, give each
-    artist an equal chance of being included so that artists with more
-    songs are not represented disproportionately.
-    """
-    # Permute the objects either in a straightforward way or an
-    # artist-balanced way.
-    if equal_chance:
-        perm = _equal_chance_permutation(objs)
-    else:
-        perm = objs
-        random.shuffle(perm)  # N.B. This shuffles the original list.
-
-    # Select objects by time our count.
-    if time:
-        return _take_time(perm, time * 60, album)
-    else:
-        return _take(perm, number)
+from beets.random import random_objs
 
 
 def random_func(lib, opts, args):
diff --git a/docs/changelog.rst b/docs/changelog.rst
index b2c8437b..43b6b20f 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -195,6 +195,8 @@ Fixes:
   is long.
   Thanks to :user:`ray66`.
   :bug:`3207` :bug:`2752`
+* Fix an unhandled exception when pruning empty directories.
+  :bug:`1996` :bug:`3209`
 
 .. _python-itunes: https://github.com/ocelma/python-itunes
 


Test patch:
diff --git a/test/test_random.py b/test/test_random.py
new file mode 100644
index 00000000..4c31acdd
--- /dev/null
+++ b/test/test_random.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2019, Carl Suster
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Test the beets.random utilities associated with the random plugin.
+"""
+
+from __future__ import division, absolute_import, print_function
+
+import unittest
+from test.helper import TestHelper
+
+import math
+from random import Random
+
+from beets import random
+
+
+class RandomTest(unittest.TestCase, TestHelper):
+    def setUp(self):
+        self.lib = None
+        self.artist1 = 'Artist 1'
+        self.artist2 = 'Artist 2'
+        self.item1 = self.create_item(artist=self.artist1)
+        self.item2 = self.create_item(artist=self.artist2)
+        self.items = [self.item1, self.item2]
+        for _ in range(8):
+            self.items.append(self.create_item(artist=self.artist2))
+        self.random_gen = Random()
+        self.random_gen.seed(12345)
+
+    def tearDown(self):
+        pass
+
+    def _stats(self, data):
+        mean = sum(data) / len(data)
+        stdev = math.sqrt(
+                sum((p - mean) ** 2 for p in data) / (len(data) - 1))
+        quot, rem = divmod(len(data), 2)
+        if rem:
+            median = sorted(data)[quot]
+        else:
+            median = sum(sorted(data)[quot - 1:quot + 1]) / 2
+        return mean, stdev, median
+
+    def test_equal_permutation(self):
+        """We have a list of items where only one item is from artist1 and the
+        rest are from artist2. If we permute weighted by the artist field then
+        the solo track will almost always end up near the start. If we use a
+        different field then it'll be in the middle on average.
+        """
+        def experiment(field, histogram=False):
+            """Permutes the list of items 500 times and calculates the position
+            of self.item1 each time. Returns stats about that position.
+            """
+            positions = []
+            for _ in range(500):
+                shuffled = list(random._equal_chance_permutation(
+                    self.items, field=field, random_gen=self.random_gen))
+                positions.append(shuffled.index(self.item1))
+            # Print a histogram (useful for debugging).
+            if histogram:
+                for i in range(len(self.items)):
+                    print('{:2d} {}'.format(i, '*' * positions.count(i)))
+            return self._stats(positions)
+
+        mean1, stdev1, median1 = experiment('artist')
+        mean2, stdev2, median2 = experiment('track')
+        self.assertAlmostEqual(0, median1, delta=1)
+        self.assertAlmostEqual(len(self.items) // 2, median2, delta=1)
+        self.assertGreater(stdev2, stdev1)