diff --git a/dtb/gui.py b/dtb/gui.py
index f96defc..7651d52 100755
--- a/dtb/gui.py
+++ b/dtb/gui.py
@@ -193,13 +193,19 @@ class Application(ttk.Frame):  # pragma: no cover - manual test, pylint: disable
     def do_ignore(self):
         """Ignore selected songs."""
         for index in (int(s) for s in self.listbox_incoming.curselection()):
-            self.incoming[index].ignore()
+            song = self.incoming[index]
+            song.ignore()
         self.update()
 
     def do_download(self):
-        """Download all songs."""
-        for index in (int(s) for s in self.listbox_incoming.curselection()):
-            self.incoming[index].download()
+        """Download selected songs."""
+        indicies = (int(s) for s in self.listbox_incoming.curselection())
+        try:
+            for index in indicies:
+                song = self.incoming[index]
+                song.download(catch=False)
+        except IOError as exc:
+            self.show_error_from_exception(exc, "Download Error")
         self.update()
 
     def update(self):
@@ -219,6 +225,12 @@ class Application(ttk.Frame):  # pragma: no cover - manual test, pylint: disable
         for song in self.incoming:
             self.listbox_incoming.insert(tk.END, song.in_string)
 
+    @staticmethod
+    def show_error_from_exception(exception, title="Error"):
+        """Convert an exception to an error dialog."""
+        message = str(exception).capitalize().replace(": ", ":\n\n")
+        messagebox.showerror(title, message)
+
 
 def main(args=None):
     """Process command-line arguments and run the program."""
diff --git a/dtb/song.py b/dtb/song.py
index 7bd39a9..5df079d 100755
--- a/dtb/song.py
+++ b/dtb/song.py
@@ -67,7 +67,7 @@ class Song(object):
         filename = os.path.basename(self.source)
         return "{} (to {})".format(filename, self.friendname)
 
-    def download(self):
+    def download(self, catch=True):
         """Move the song to the user's download directory.
 
         @return: path to downloaded file or None on broken links
@@ -78,6 +78,9 @@ class Song(object):
         dst = None
         # Move the file or copy from the link
         try:
+            if not os.path.isdir(self.downloads):
+                msg = "invalid download location: {}".format(self.downloads)
+                raise IOError(msg)
             if src == self.path:
                 logging.info("moving {}...".format(src))
                 # Copy then delete in case the operation is cancelled
@@ -95,8 +98,9 @@ class Song(object):
                     logging.warning("broken link: {}".format(self.path))
                     os.remove(self.path)
         except IOError as error:
-            # TODO: these errors need to be left uncaught for the GUI
-            logging.warning(error)
+            logging.error(error)
+            if not catch:
+                raise
         return dst
 
     def ignore(self):

Strange Behavior when download path does not exist
If my download path does not exist on my machine I end up with a file named whatever is the directory was supposed to be. e.g.

Download path: `~/jkloo/downloads/fake`

The directory `fake` does not exist. The result is a file (`testfile.jpg`) downloads as `fake` in the directory `~/jkloo/downloads`. Adding the `.jpg` extension and opening the image reveals the expected file.

This is likely only an issue when a user manually changes their DL path in `info.yml` or the first time the GUI is run on a machine / for a user.

possible fixes:

1. only save the DL path if it exists
- create the DL directory if it doesn't exist
- error pop-up if path does not exist

diff --git a/dtb/test/test_song.py b/dtb/test/test_song.py
index c80778a..250dfa8 100644
--- a/dtb/test/test_song.py
+++ b/dtb/test/test_song.py
@@ -100,10 +100,22 @@ class TestSong(unittest.TestCase):  # pylint: disable=R0904
         mock_remove.assert_called_once_with(self.broken.path)
 
     @patch('os.remove', Mock(side_effect=IOError))
-    def test_download_error(self):
+    def test_download_error_caught(self):
         """Verify errors are caught while downloading."""
         self.song.download()
 
+    @patch('os.remove', Mock(side_effect=IOError))
+    def test_download_error_uncaught(self):
+        """Verify errors are not caught while downloading if requested."""
+        self.assertRaises(IOError, self.song.download, catch=False)
+
+    @patch('os.remove')
+    @patch('os.path.isdir', Mock(return_value=False))
+    def test_download_invalid_dest(self, mock_remove):
+        """Verify downloads are only attempted with a valid destination."""
+        self.song.download()
+        self.assertFalse(mock_remove.called)
+
     @patch('os.remove')
     def test_ignore(self, mock_remove):
         """Verify a song can be ignored."""

python setup.py install
parse_log_pytest
requirements.txt
nose pytest
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
DropTheBeat==0.1.dev0
exceptiongroup==1.2.2
iniconfig==2.1.0
nose==1.3.7
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
PyYAML==3.13
tomli==2.2.1

name: dropthebeat
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - dropthebeat==0.1.dev0
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - nose==1.3.7
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - pyyaml==3.13
      - tomli==2.2.1
prefix: /opt/conda/envs/dropthebeat

dtb/test/test_song.py::TestSong::test_download_error_uncaught
dtb/test/test_song.py::TestSong::test_download_broken dtb/test/test_song.py::TestSong::test_download_error_caught dtb/test/test_song.py::TestSong::test_download_invalid_dest dtb/test/test_song.py::TestSong::test_download_link dtb/test/test_song.py::TestSong::test_download_song dtb/test/test_song.py::TestSong::test_ignore dtb/test/test_song.py::TestSong::test_in_string dtb/test/test_song.py::TestSong::test_link dtb/test/test_song.py::TestSong::test_link_missing_directory dtb/test/test_song.py::TestSong::test_out_string dtb/test/test_song.py::TestSong::test_source_file dtb/test/test_song.py::TestSong::test_source_file_bad dtb/test/test_song.py::TestSong::test_source_link dtb/test/test_song.py::TestSong::test_source_song dtb/test/test_song.py::TestSong::test_str
dtb/gui.py dtb/song.py
dtb/gui.py dtb/song.py