Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,15 @@ Warning: failed to verify integrity
Do you want to skip this album? (Y/n)
```

Alteratively, if auto fixing is enabled,
then _beets-check_ will fix the file using the configured tool.
If the fix is successful, then the import will continue as normal.
Otherwise, it will prompt you in a similar way above,
where you can choose to continue or skip the offending items.

After a track has been added to the database and all modifications to the tags
have been written, beets-check adds the checksums. This is virtually the same as
running `beets check -a ` after the import.
running `beets check -a` after the import.

If you run `import` with the `--quiet` flag the importer will skip
files that do not pass third-party tests automatically and log an
Expand Down Expand Up @@ -237,6 +243,8 @@ check:
write-check: yes
write-update: yes
convert-update: yes
integrity: yes
auto-fix: no
threads: num_of_cpus
```

Expand All @@ -252,6 +260,8 @@ other beets commands. You can disable each option by setting its value to `no`.
`beet write` or `beet modify`.
- `convert-update: no` Don’t updated the checksum if a file has been
converted with the `--keep-new` flag.
- `integrity: no` Don't preform integrity checks on import
- `auto-fix: yes` Automatically try to fix files on import with [third-party tools](#third-party-tools)
- `threads: 4` Use four threads to compute checksums.

### Third-party Tools
Expand Down
59 changes: 43 additions & 16 deletions beetsplug/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
import re
import shutil
import sys
from collections.abc import MutableSequence
from concurrent import futures
from hashlib import sha256
from optparse import OptionParser
from subprocess import PIPE, STDOUT, Popen, check_call

import beets
from beets import config, importer, logging
from beets.library import ReadError
from beets import config, logging
from beets.library import Item, ReadError
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, UserError, colorize, decargs, input_yn
from beets.util import displayable_path, syspath
Expand Down Expand Up @@ -70,6 +71,7 @@ def __init__(self):
"import": True,
"write-check": True,
"write-update": True,
"auto-fix": False,
"integrity": True,
"convert-update": True,
"threads": os.cpu_count(),
Expand All @@ -81,9 +83,12 @@ def __init__(self):
"fix": "mp3val -nb -f {0}",
},
"flac": {
"cmdline": "flac --test --silent {0}",
# More aggressive check by default
"cmdline": "flac --test --silent --warnings-as-errors {0}",
"formats": "FLAC",
"error": "^.*: ERROR,? (.*)$",
"error": "^.*: (?:WARNING|ERROR),? (.*)$",
# Recodes and fixes errors
"fix": 'flac -VFf --preserve-modtime -o "{0}" "${0}"',
},
"oggz-validate": {"cmdline": "oggz-validate {0}", "formats": "OGG"},
},
Expand Down Expand Up @@ -144,24 +149,46 @@ def copy_original_checksum(self, config, task):
item.store()

def verify_import_integrity(self, session, task):
integrity_errors = []
failed_items: MutableSequence[tuple[IntegrityError, Item]] = []
if not task.items:
return
for item in task.items:
try:
verify_integrity(item)
except IntegrityError as ex:
integrity_errors.append(ex)

if integrity_errors:
log.warning("Warning: failed to verify integrity")
for error in integrity_errors:
log.warning(f" {displayable_path(item.path)}: {error}")
if beets.config["import"]["quiet"] or input_yn(
"Do you want to skip this album (Y/n)"
):
log.info("Skipping.")
task.choice_flag = ImporterAction.SKIP
failed_items.append((ex, item))

if not failed_items:
return

has_unfixable_errors: bool = False
log.warning("Warning: failed to verify integrity")
for error, item in failed_items:
log.warning(f" {displayable_path(item.path)}: {error}")
if not self.config["auto-fix"]:
has_unfixable_errors = True
continue

checker = IntegrityChecker.fixer(item)
if not checker:
has_unfixable_errors = True
continue
log.info(f"Fixing file: {displayable_path(item.path)}")
try:
checker.fix(item)
except Exception as e:
log.error(f"Failed to fix {displayable_path(item.path)}: {e}")
has_unfixable_errors = True
item["checksum"] = compute_checksum(item)

if not has_unfixable_errors:
return

if beets.config["import"]["quiet"] or input_yn(
"Do you want to skip this album (Y/n)"
):
log.info("Skipping.")
task.choice_flag = ImporterAction.SKIP


class CheckCommand(Subcommand):
Expand Down
Binary file added test/fixtures/fail.mp3
Binary file not shown.
15 changes: 14 additions & 1 deletion test/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,5 +260,18 @@ def installNone(cls):
check.IntegrityChecker._all_available = []

def check(self, item):
if b"truncated" in item.path:
if b"truncated" in item.path or b'fail' in item.path:
raise check.IntegrityError(item.path, "file is corrupt")

def can_fix(self, item):
return True

def fix(self, item):
mf = MediaFile(item.path)

if b'truncated' in item.path:
mf.url = "fixed"
mf.save()

if b"fail" in item.path:
raise check.IntegrityError(item.path, "cannot fix file")
58 changes: 58 additions & 0 deletions test/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,64 @@ def test_add_corrupt_files(self):
mediafile = MediaFile(item.path)
assert mediafile.title == "truncated tag"

def test_fix_corrupt_files(self):
self.config["check"]["auto-fix"] = True

MockChecker.install()
self.setupImportDir(["ok.mp3", "truncated.mp3"])

with self.mockAutotag(), captureLog() as logs:
beets.ui._raw_main(["import", self.import_dir])

assert len(self.lib.items()) == 2
assert "Fixing file:" in "\n".join(logs)

item = self.lib.items("truncated").get()
verify_checksum(item)

mediafile = MediaFile(item.path)
assert mediafile.url == "fixed"

def test_fix_corrupt_files_fail_skip(self):
self.config["check"]["auto-fix"] = True

MockChecker.install()
self.setupImportDir(["ok.mp3", "fail.mp3"])

with self.mockAutotag(), captureLog() as logs, controlStdin("y"):
beets.ui._raw_main(["import", self.import_dir])

assert len(self.lib.items()) == 0
assert "Fixing file:" in "\n".join(logs)
assert "Failed to fix" in "\n".join(logs)

def test_fix_corrupt_files_fail(self):
self.config["check"]["auto-fix"] = True

MockChecker.install()
self.setupImportDir(["ok.mp3", "fail.mp3"])

with self.mockAutotag(), captureLog() as logs, controlStdin("n"):
beets.ui._raw_main(["import", self.import_dir])

assert len(self.lib.items()) == 2
assert "Fixing file:" in "\n".join(logs)
assert "Failed to fix" in "\n".join(logs)

def test_fix_corrupt_files_quiet(self):
self.config["check"]["auto-fix"] = True
self.config["import"]["quiet"] = True

MockChecker.install()
self.setupImportDir(["ok.mp3", "fail.mp3"])

with self.mockAutotag(), captureLog() as logs:
beets.ui._raw_main(["import", self.import_dir])

assert len(self.lib.items()) == 0
assert "Fixing file:" in "\n".join(logs)
assert "Failed to fix" in "\n".join(logs)


class WriteTest(TestHelper, TestCase):
def setUp(self):
Expand Down
Loading