Repository: beetbox/beets

Issue:
Allow importing from logfile
When importing large collections, it makes sense to do the automated import first, then do the manual part afterwards:

    $ beet import -l skipped.log -q /path/to/library

It would be great if it was possible to continue the import using the logfile instead of a directory:

    $ beet import skipped.log

Beets should be able to parse the logfile and call import on each directory that was skipped.


Discussion:
My current workaround:

```
$ grep -v "^import" missing.log | sed "s/^[a-z-]\+ //" | python <(cat << EOF
import sys
import os.path
for line in sys.stdin:
    print(os.path.commonpath(line.rstrip("\n").split("; ")))
EOF
) | xargs -d $'\n' beet import 
```
I use a sricpt sh for that:

```
date=$(date '+%Y%m%d' | cut -c 3-)

grep skip ~/.config/beets/import.log | sed 's/skip //' > ~/.config/beets/skipped_import.log

mv ~/.config/beets/import.log ~/.config/beets/import.log."$date"

manual_import() {
until [ ! -s ~/.config/beets/skipped_import.log ]
do
  chek=$(head -n 1 ~/.config/beets/skipped_import.log)
  beet import -CW "$chek" &&
  sed -i '1d' ~/.config/beets/skipped_import.log
done
}

manual_import
```
I'm pretty sure that a feature like this has been discussed before; but I can't find the issue right now.
Well, it looks like the log file format is intended to be machine-readable, so it's not really far-fetched to assume that such a feature was considered at some point.

Resolving patch:
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -79,6 +79,43 @@ def _do_query(lib, query, album, also_items=True):
     return items, albums
 
 
+def _paths_from_logfile(path):
+    """Parse the logfile and yield skipped paths to pass to the `import`
+    command.
+    """
+    with open(path, mode="r", encoding="utf-8") as fp:
+        for i, line in enumerate(fp, start=1):
+            verb, sep, paths = line.rstrip("\n").partition(" ")
+            if not sep:
+                raise ValueError(f"line {i} is invalid")
+
+            # Ignore informational lines that don't need to be re-imported.
+            if verb in {"import", "duplicate-keep", "duplicate-replace"}:
+                continue
+
+            if verb not in {"asis", "skip", "duplicate-skip"}:
+                raise ValueError(f"line {i} contains unknown verb {verb}")
+
+            yield os.path.commonpath(paths.split("; "))
+
+
+def _parse_logfiles(logfiles):
+    """Parse all `logfiles` and yield paths from it."""
+    for logfile in logfiles:
+        try:
+            yield from _paths_from_logfile(syspath(normpath(logfile)))
+        except ValueError as err:
+            raise ui.UserError('malformed logfile {}: {}'.format(
+                util.displayable_path(logfile),
+                str(err)
+            )) from err
+        except IOError as err:
+            raise ui.UserError('unreadable logfile {}: {}'.format(
+                util.displayable_path(logfile),
+                str(err)
+            )) from err
+
+
 # fields: Shows a list of available fields for queries and format strings.
 
 def _print_keys(query):
@@ -913,12 +950,6 @@ def import_files(lib, paths, query):
     """Import the files in the given list of paths or matching the
     query.
     """
-    # Check the user-specified directories.
-    for path in paths:
-        if not os.path.exists(syspath(normpath(path))):
-            raise ui.UserError('no such file or directory: {}'.format(
-                displayable_path(path)))
-
     # Check parameter consistency.
     if config['import']['quiet'] and config['import']['timid']:
         raise ui.UserError("can't be both quiet and timid")
@@ -960,7 +991,12 @@ def import_func(lib, opts, args):
     else:
         query = None
         paths = args
-        if not paths:
+
+        # The paths from the logfiles go into a separate list to allow handling
+        # errors differently from user-specified paths.
+        paths_from_logfiles = list(_parse_logfiles(opts.from_logfiles or []))
+
+        if not paths and not paths_from_logfiles:
             raise ui.UserError('no path specified')
 
         # On Python 2, we used to get filenames as raw bytes, which is
@@ -969,6 +1005,31 @@ def import_func(lib, opts, args):
         # filename.
         paths = [p.encode(util.arg_encoding(), 'surrogateescape')
                  for p in paths]
+        paths_from_logfiles = [p.encode(util.arg_encoding(), 'surrogateescape')
+                               for p in paths_from_logfiles]
+
+        # Check the user-specified directories.
+        for path in paths:
+            if not os.path.exists(syspath(normpath(path))):
+                raise ui.UserError('no such file or directory: {}'.format(
+                    displayable_path(path)))
+
+        # Check the directories from the logfiles, but don't throw an error in
+        # case those paths don't exist. Maybe some of those paths have already
+        # been imported and moved separately, so logging a warning should
+        # suffice.
+        for path in paths_from_logfiles:
+            if not os.path.exists(syspath(normpath(path))):
+                log.warning('No such file or directory: {}'.format(
+                    displayable_path(path)))
+                continue
+
+            paths.append(path)
+
+        # If all paths were read from a logfile, and none of them exist, throw
+        # an error
+        if not paths:
+            raise ui.UserError('none of the paths are importable')
 
     import_files(lib, paths, query)
 
@@ -1061,6 +1122,11 @@ def import_func(lib, opts, args):
     metavar='ID',
     help='restrict matching to a specific metadata backend ID'
 )
+import_cmd.parser.add_option(
+    '--from-logfile', dest='from_logfiles', action='append',
+    metavar='PATH',
+    help='read skipped paths from an existing logfile'
+)
 import_cmd.parser.add_option(
     '--set', dest='set_fields', action='callback',
     callback=_store_dict,


Test patch:
diff --git a/test/test_ui.py b/test/test_ui.py
--- a/test/test_ui.py
+++ b/test/test_ui.py
@@ -729,6 +729,40 @@ def test_quiet_timid_disallowed(self):
         self.assertRaises(ui.UserError, commands.import_files, None, [],
                           None)
 
+    def test_parse_paths_from_logfile(self):
+        if os.path.__name__ == 'ntpath':
+            logfile_content = (
+                "import started Wed Jun 15 23:08:26 2022\n"
+                "asis C:\\music\\Beatles, The\\The Beatles; C:\\music\\Beatles, The\\The Beatles\\CD 01; C:\\music\\Beatles, The\\The Beatles\\CD 02\n"  # noqa: E501
+                "duplicate-replace C:\\music\\Bill Evans\\Trio '65\n"
+                "skip C:\\music\\Michael Jackson\\Bad\n"
+                "skip C:\\music\\Soulwax\\Any Minute Now\n"
+            )
+            expected_paths = [
+                "C:\\music\\Beatles, The\\The Beatles",
+                "C:\\music\\Michael Jackson\\Bad",
+                "C:\\music\\Soulwax\\Any Minute Now",
+            ]
+        else:
+            logfile_content = (
+                "import started Wed Jun 15 23:08:26 2022\n"
+                "asis /music/Beatles, The/The Beatles; /music/Beatles, The/The Beatles/CD 01; /music/Beatles, The/The Beatles/CD 02\n"  # noqa: E501
+                "duplicate-replace /music/Bill Evans/Trio '65\n"
+                "skip /music/Michael Jackson/Bad\n"
+                "skip /music/Soulwax/Any Minute Now\n"
+            )
+            expected_paths = [
+                "/music/Beatles, The/The Beatles",
+                "/music/Michael Jackson/Bad",
+                "/music/Soulwax/Any Minute Now",
+            ]
+
+        logfile = os.path.join(self.temp_dir, b"logfile.log")
+        with open(logfile, mode="w") as fp:
+            fp.write(logfile_content)
+        actual_paths = list(commands._paths_from_logfile(logfile))
+        self.assertEqual(actual_paths, expected_paths)
+
 
 @_common.slow_test()
 class ConfigTest(unittest.TestCase, TestHelper, _common.Assertions):