From 9147577b2b19f43ca827e9650261a86fb0450cef Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 13 May 2025 12:59:17 +0200 Subject: [PATCH 1/4] Moved importer into new files --- beets/importer/session.py | 303 ++++++++ beets/importer/stages.py | 396 +++++++++++ beets/importer/state.py | 142 ++++ beets/{importer.py => importer/tasks.py} | 848 ++--------------------- 4 files changed, 903 insertions(+), 786 deletions(-) create mode 100644 beets/importer/session.py create mode 100644 beets/importer/stages.py create mode 100644 beets/importer/state.py rename beets/{importer.py => importer/tasks.py} (60%) diff --git a/beets/importer/session.py b/beets/importer/session.py new file mode 100644 index 000000000..620eb688e --- /dev/null +++ b/beets/importer/session.py @@ -0,0 +1,303 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# 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. +from __future__ import annotations + +import os +import time +from typing import TYPE_CHECKING, Sequence + +from beets import config, dbcore, library, logging, plugins, util +from beets.importer.tasks import action +from beets.util import displayable_path, normpath, pipeline, syspath + +from .stages import * +from .state import ImportState + +if TYPE_CHECKING: + from beets.util import PathBytes + + from .tasks import ImportTask + + +QUEUE_SIZE = 128 + +# Global logger. +log = logging.getLogger("beets") + + +class ImportAbortError(Exception): + """Raised when the user aborts the tagging operation.""" + + pass + + +class ImportSession: + """Controls an import action. Subclasses should implement methods to + communicate with the user or otherwise make decisions. + """ + + logger: logging.Logger + paths: list[PathBytes] + lib: library.Library + + _is_resuming: dict[bytes, bool] + _merged_items: set[PathBytes] + _merged_dirs: set[PathBytes] + + def __init__( + self, + lib: library.Library, + loghandler: logging.Handler | None, + paths: Sequence[PathBytes] | None, + query: dbcore.Query | None, + ): + """Create a session. + + Parameters + ---------- + lib : library.Library + The library instance to which items will be imported. + loghandler : logging.Handler or None + A logging handler to use for the session's logger. If None, a + NullHandler will be used. + paths : os.PathLike or None + The paths to be imported. + query : dbcore.Query or None + A query to filter items for import. + """ + self.lib = lib + self.logger = self._setup_logging(loghandler) + self.query = query + self._is_resuming = {} + self._merged_items = set() + self._merged_dirs = set() + + # Normalize the paths. + self.paths = list(map(normpath, paths or [])) + + def _setup_logging(self, loghandler: logging.Handler | None): + logger = logging.getLogger(__name__) + logger.propagate = False + if not loghandler: + loghandler = logging.NullHandler() + logger.handlers = [loghandler] + return logger + + def set_config(self, config): + """Set `config` property from global import config and make + implied changes. + """ + # FIXME: Maybe this function should not exist and should instead + # provide "decision wrappers" like "should_resume()", etc. + iconfig = dict(config) + self.config = iconfig + + # Incremental and progress are mutually exclusive. + if iconfig["incremental"]: + iconfig["resume"] = False + + # When based on a query instead of directories, never + # save progress or try to resume. + if self.query is not None: + iconfig["resume"] = False + iconfig["incremental"] = False + + if iconfig["reflink"]: + iconfig["reflink"] = iconfig["reflink"].as_choice( + ["auto", True, False] + ) + + # Copy, move, reflink, link, and hardlink are mutually exclusive. + if iconfig["move"]: + iconfig["copy"] = False + iconfig["link"] = False + iconfig["hardlink"] = False + iconfig["reflink"] = False + elif iconfig["link"]: + iconfig["copy"] = False + iconfig["move"] = False + iconfig["hardlink"] = False + iconfig["reflink"] = False + elif iconfig["hardlink"]: + iconfig["copy"] = False + iconfig["move"] = False + iconfig["link"] = False + iconfig["reflink"] = False + elif iconfig["reflink"]: + iconfig["copy"] = False + iconfig["move"] = False + iconfig["link"] = False + iconfig["hardlink"] = False + + # Only delete when copying. + if not iconfig["copy"]: + iconfig["delete"] = False + + self.want_resume = config["resume"].as_choice([True, False, "ask"]) + + def tag_log(self, status, paths: Sequence[PathBytes]): + """Log a message about a given album to the importer log. The status + should reflect the reason the album couldn't be tagged. + """ + self.logger.info("{0} {1}", status, displayable_path(paths)) + + def log_choice(self, task: ImportTask, duplicate=False): + """Logs the task's current choice if it should be logged. If + ``duplicate``, then this is a secondary choice after a duplicate was + detected and a decision was made. + """ + paths = task.paths + if duplicate: + # Duplicate: log all three choices (skip, keep both, and trump). + if task.should_remove_duplicates: + self.tag_log("duplicate-replace", paths) + elif task.choice_flag in (action.ASIS, action.APPLY): + self.tag_log("duplicate-keep", paths) + elif task.choice_flag is action.SKIP: + self.tag_log("duplicate-skip", paths) + else: + # Non-duplicate: log "skip" and "asis" choices. + if task.choice_flag is action.ASIS: + self.tag_log("asis", paths) + elif task.choice_flag is action.SKIP: + self.tag_log("skip", paths) + + def should_resume(self, path: PathBytes): + raise NotImplementedError + + def choose_match(self, task: ImportTask): + raise NotImplementedError + + def resolve_duplicate(self, task: ImportTask, found_duplicates): + raise NotImplementedError + + def choose_item(self, task: ImportTask): + raise NotImplementedError + + def run(self): + """Run the import task.""" + self.logger.info("import started {0}", time.asctime()) + self.set_config(config["import"]) + + # Set up the pipeline. + if self.query is None: + stages = [read_tasks(self)] + else: + stages = [query_tasks(self)] + + # In pretend mode, just log what would otherwise be imported. + if self.config["pretend"]: + stages += [log_files(self)] + else: + if self.config["group_albums"] and not self.config["singletons"]: + # Split directory tasks into one task for each album. + stages += [group_albums(self)] + + # These stages either talk to the user to get a decision or, + # in the case of a non-autotagged import, just choose to + # import everything as-is. In *both* cases, these stages + # also add the music to the library database, so later + # stages need to read and write data from there. + if self.config["autotag"]: + stages += [lookup_candidates(self), user_query(self)] + else: + stages += [import_asis(self)] + + # Plugin stages. + for stage_func in plugins.early_import_stages(): + stages.append(plugin_stage(self, stage_func)) + for stage_func in plugins.import_stages(): + stages.append(plugin_stage(self, stage_func)) + + stages += [manipulate_files(self)] + + pl = pipeline.Pipeline(stages) + + # Run the pipeline. + plugins.send("import_begin", session=self) + try: + if config["threaded"]: + pl.run_parallel(QUEUE_SIZE) + else: + pl.run_sequential() + except ImportAbortError: + # User aborted operation. Silently stop. + pass + + # Incremental and resumed imports + + def already_imported(self, toppath: PathBytes, paths: Sequence[PathBytes]): + """Returns true if the files belonging to this task have already + been imported in a previous session. + """ + if self.is_resuming(toppath) and all( + [ImportState().progress_has_element(toppath, p) for p in paths] + ): + return True + if self.config["incremental"] and tuple(paths) in self.history_dirs: + return True + + return False + + _history_dirs = None + + @property + def history_dirs(self) -> set[tuple[PathBytes, ...]]: + # FIXME: This could be simplified to a cached property + if self._history_dirs is None: + self._history_dirs = ImportState().taghistory + return self._history_dirs + + def already_merged(self, paths: Sequence[PathBytes]): + """Returns true if all the paths being imported were part of a merge + during previous tasks. + """ + for path in paths: + if path not in self._merged_items and path not in self._merged_dirs: + return False + return True + + def mark_merged(self, paths: Sequence[PathBytes]): + """Mark paths and directories as merged for future reimport tasks.""" + self._merged_items.update(paths) + dirs = { + os.path.dirname(path) if os.path.isfile(syspath(path)) else path + for path in paths + } + self._merged_dirs.update(dirs) + + def is_resuming(self, toppath: PathBytes): + """Return `True` if user wants to resume import of this path. + + You have to call `ask_resume` first to determine the return value. + """ + return self._is_resuming.get(toppath, False) + + def ask_resume(self, toppath: PathBytes): + """If import of `toppath` was aborted in an earlier session, ask + user if they want to resume the import. + + Determines the return value of `is_resuming(toppath)`. + """ + if self.want_resume and ImportState().progress_has(toppath): + # Either accept immediately or prompt for input to decide. + if self.want_resume is True or self.should_resume(toppath): + log.warning( + "Resuming interrupted import of {0}", + util.displayable_path(toppath), + ) + self._is_resuming[toppath] = True + else: + # Clear progress; we're starting from the top. + ImportState().progress_reset(toppath) diff --git a/beets/importer/stages.py b/beets/importer/stages.py new file mode 100644 index 000000000..52b2a221a --- /dev/null +++ b/beets/importer/stages.py @@ -0,0 +1,396 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# 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. + +from __future__ import annotations + +import itertools +import logging +from typing import TYPE_CHECKING, Callable + +from beets import config, plugins +from beets.util import MoveOperation, displayable_path, pipeline + +from .tasks import ( + action, + ImportTask, + ImportTaskFactory, + SentinelImportTask, + SingletonImportTask, +) + +if TYPE_CHECKING: + from beets import library + + from .session import ImportSession + +# Global logger. +log = logging.getLogger("beets") + +# ---------------------------- Producer functions ---------------------------- # +# Functions that are called first i.e. they generate import tasks + + +def read_tasks(session: ImportSession): + """A generator yielding all the albums (as ImportTask objects) found + in the user-specified list of paths. In the case of a singleton + import, yields single-item tasks instead. + """ + skipped = 0 + + for toppath in session.paths: + # Check whether we need to resume the import. + session.ask_resume(toppath) + + # Generate tasks. + task_factory = ImportTaskFactory(toppath, session) + yield from task_factory.tasks() + skipped += task_factory.skipped + + if not task_factory.imported: + log.warning("No files imported from {0}", displayable_path(toppath)) + + # Show skipped directories (due to incremental/resume). + if skipped: + log.info("Skipped {0} paths.", skipped) + + +def query_tasks(session: ImportSession): + """A generator that works as a drop-in-replacement for read_tasks. + Instead of finding files from the filesystem, a query is used to + match items from the library. + """ + if session.config["singletons"]: + # Search for items. + for item in session.lib.items(session.query): + task = SingletonImportTask(None, item) + for task in task.handle_created(session): + yield task + + else: + # Search for albums. + for album in session.lib.albums(session.query): + log.debug( + "yielding album {0}: {1} - {2}", + album.id, + album.albumartist, + album.album, + ) + items = list(album.items()) + _freshen_items(items) + + task = ImportTask(None, [album.item_dir()], items) + for task in task.handle_created(session): + yield task + + +# ---------------------------------- Stages ---------------------------------- # +# Functions that process import tasks, may transform or filter them +# They are chained together in the pipeline e.g. stage2(stage1(task)) -> task + + +def group_albums(session: ImportSession): + """A pipeline stage that groups the items of each task into albums + using their metadata. + + Groups are identified using their artist and album fields. The + pipeline stage emits new album tasks for each discovered group. + """ + + def group(item): + return (item.albumartist or item.artist, item.album) + + task = None + while True: + task = yield task + if task.skip: + continue + tasks = [] + sorted_items: list[library.Item] = sorted(task.items, key=group) + for _, items in itertools.groupby(sorted_items, group): + l_items = list(items) + task = ImportTask(task.toppath, [i.path for i in l_items], l_items) + tasks += task.handle_created(session) + tasks.append(SentinelImportTask(task.toppath, task.paths)) + + task = pipeline.multiple(tasks) + + +@pipeline.mutator_stage +def lookup_candidates(session: ImportSession, task: ImportTask): + """A coroutine for performing the initial MusicBrainz lookup for an + album. It accepts lists of Items and yields + (items, cur_artist, cur_album, candidates, rec) tuples. If no match + is found, all of the yielded parameters (except items) are None. + """ + if task.skip: + # FIXME This gets duplicated a lot. We need a better + # abstraction. + return + + plugins.send("import_task_start", session=session, task=task) + log.debug("Looking up: {0}", displayable_path(task.paths)) + + # Restrict the initial lookup to IDs specified by the user via the -m + # option. Currently all the IDs are passed onto the tasks directly. + task.search_ids = session.config["search_ids"].as_str_seq() + + task.lookup_candidates() + + +@pipeline.stage +def user_query(session: ImportSession, task: ImportTask): + """A coroutine for interfacing with the user about the tagging + process. + + The coroutine accepts an ImportTask objects. It uses the + session's `choose_match` method to determine the `action` for + this task. Depending on the action additional stages are executed + and the processed task is yielded. + + It emits the ``import_task_choice`` event for plugins. Plugins have + access to the choice via the ``task.choice_flag`` property and may + choose to change it. + """ + if task.skip: + return task + + if session.already_merged(task.paths): + return pipeline.BUBBLE + + # Ask the user for a choice. + task.choose_match(session) + plugins.send("import_task_choice", session=session, task=task) + + # As-tracks: transition to singleton workflow. + if task.choice_flag is action.TRACKS: + # Set up a little pipeline for dealing with the singletons. + def emitter(task): + for item in task.items: + task = SingletonImportTask(task.toppath, item) + yield from task.handle_created(session) + yield SentinelImportTask(task.toppath, task.paths) + + return _extend_pipeline( + emitter(task), lookup_candidates(session), user_query(session) + ) + + # As albums: group items by albums and create task for each album + if task.choice_flag is action.ALBUMS: + return _extend_pipeline( + [task], + group_albums(session), + lookup_candidates(session), + user_query(session), + ) + + resolve_duplicates(session, task) + + if task.should_merge_duplicates: + # Create a new task for tagging the current items + # and duplicates together + duplicate_items = task.duplicate_items(session.lib) + + # Duplicates would be reimported so make them look "fresh" + _freshen_items(duplicate_items) + duplicate_paths = [item.path for item in duplicate_items] + + # Record merged paths in the session so they are not reimported + session.mark_merged(duplicate_paths) + + merged_task = ImportTask( + None, task.paths + duplicate_paths, task.items + duplicate_items + ) + + return _extend_pipeline( + [merged_task], lookup_candidates(session), user_query(session) + ) + + apply_choice(session, task) + return task + + +@pipeline.mutator_stage +def import_asis(session: ImportSession, task: ImportTask): + """Select the `action.ASIS` choice for all tasks. + + This stage replaces the initial_lookup and user_query stages + when the importer is run without autotagging. + """ + if task.skip: + return + + log.info("{}", displayable_path(task.paths)) + task.set_choice(action.ASIS) + apply_choice(session, task) + + +@pipeline.mutator_stage +def plugin_stage( + session: ImportSession, + func: Callable[[ImportSession, ImportTask], None], + task: ImportTask, +): + """A coroutine (pipeline stage) that calls the given function with + each non-skipped import task. These stages occur between applying + metadata changes and moving/copying/writing files. + """ + if task.skip: + return + + func(session, task) + + # Stage may modify DB, so re-load cached item data. + # FIXME Importer plugins should not modify the database but instead + # the albums and items attached to tasks. + task.reload() + + +@pipeline.stage +def log_files(session: ImportSession, task: ImportTask): + """A coroutine (pipeline stage) to log each file to be imported.""" + if isinstance(task, SingletonImportTask): + log.info("Singleton: {0}", displayable_path(task.item["path"])) + elif task.items: + log.info("Album: {0}", displayable_path(task.paths[0])) + for item in task.items: + log.info(" {0}", displayable_path(item["path"])) + + +# --------------------------------- Consumer --------------------------------- # +# Anything that should be placed last in the pipeline +# In theory every stage could be a consumer, but in practice there are some +# functions which are typically placed last in the pipeline + + +@pipeline.stage +def manipulate_files(session: ImportSession, task: ImportTask): + """A coroutine (pipeline stage) that performs necessary file + manipulations *after* items have been added to the library and + finalizes each task. + """ + if not task.skip: + if task.should_remove_duplicates: + task.remove_duplicates(session.lib) + + if session.config["move"]: + operation = MoveOperation.MOVE + elif session.config["copy"]: + operation = MoveOperation.COPY + elif session.config["link"]: + operation = MoveOperation.LINK + elif session.config["hardlink"]: + operation = MoveOperation.HARDLINK + elif session.config["reflink"] == "auto": + operation = MoveOperation.REFLINK_AUTO + elif session.config["reflink"]: + operation = MoveOperation.REFLINK + else: + operation = None + + task.manipulate_files( + session=session, + operation=operation, + write=session.config["write"], + ) + + # Progress, cleanup, and event. + task.finalize(session) + + +# ---------------------------- Utility functions ----------------------------- # +# Private functions only used in the stages above + + +def apply_choice(session: ImportSession, task: ImportTask): + """Apply the task's choice to the Album or Item it contains and add + it to the library. + """ + if task.skip: + return + + # Change metadata. + if task.apply: + task.apply_metadata() + plugins.send("import_task_apply", session=session, task=task) + + task.add(session.lib) + + # If ``set_fields`` is set, set those fields to the + # configured values. + # NOTE: This cannot be done before the ``task.add()`` call above, + # because then the ``ImportTask`` won't have an `album` for which + # it can set the fields. + if config["import"]["set_fields"]: + task.set_fields(session.lib) + + +def resolve_duplicates(session: ImportSession, task: ImportTask): + """Check if a task conflicts with items or albums already imported + and ask the session to resolve this. + """ + if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG): + found_duplicates = task.find_duplicates(session.lib) + if found_duplicates: + log.debug( + "found duplicates: {}".format([o.id for o in found_duplicates]) + ) + + # Get the default action to follow from config. + duplicate_action = config["import"]["duplicate_action"].as_choice( + { + "skip": "s", + "keep": "k", + "remove": "r", + "merge": "m", + "ask": "a", + } + ) + log.debug("default action for duplicates: {0}", duplicate_action) + + if duplicate_action == "s": + # Skip new. + task.set_choice(action.SKIP) + elif duplicate_action == "k": + # Keep both. Do nothing; leave the choice intact. + pass + elif duplicate_action == "r": + # Remove old. + task.should_remove_duplicates = True + elif duplicate_action == "m": + # Merge duplicates together + task.should_merge_duplicates = True + else: + # No default action set; ask the session. + session.resolve_duplicate(task, found_duplicates) + + session.log_choice(task, True) + + +def _freshen_items(items): + # Clear IDs from re-tagged items so they appear "fresh" when + # we add them back to the library. + for item in items: + item.id = None + item.album_id = None + + +def _extend_pipeline(tasks, *stages): + # Return pipeline extension for stages with list of tasks + if isinstance(tasks, list): + task_iter = iter(tasks) + else: + task_iter = tasks + + ipl = pipeline.Pipeline([task_iter] + list(stages)) + return pipeline.multiple(ipl.pull()) diff --git a/beets/importer/state.py b/beets/importer/state.py new file mode 100644 index 000000000..fccb7c282 --- /dev/null +++ b/beets/importer/state.py @@ -0,0 +1,142 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# 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. + +from __future__ import annotations + +import logging +import os +import pickle +from bisect import bisect_left, insort +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from beets import config + +if TYPE_CHECKING: + from beets.util import PathBytes + + +# Global logger. +log = logging.getLogger("beets") + + +@dataclass +class ImportState: + """Representing the progress of an import task. + + Opens the state file on creation of the class. If you want + to ensure the state is written to disk, you should use the + context manager protocol. + + Tagprogress allows long tagging tasks to be resumed when they pause. + + Taghistory is a utility for manipulating the "incremental" import log. + This keeps track of all directories that were ever imported, which + allows the importer to only import new stuff. + + Usage + ----- + ``` + # Readonly + progress = ImportState().tagprogress + + # Read and write + with ImportState() as state: + state["key"] = "value" + ``` + """ + + tagprogress: dict[PathBytes, list[PathBytes]] + taghistory: set[tuple[PathBytes, ...]] + path: PathBytes + + def __init__(self, readonly=False, path: PathBytes | None = None): + self.path = path or os.fsencode(config["statefile"].as_filename()) + self.tagprogress = {} + self.taghistory = set() + self._open() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._save() + + def _open( + self, + ): + try: + with open(self.path, "rb") as f: + state = pickle.load(f) + # Read the states + self.tagprogress = state.get("tagprogress", {}) + self.taghistory = state.get("taghistory", set()) + except Exception as exc: + # The `pickle` module can emit all sorts of exceptions during + # unpickling, including ImportError. We use a catch-all + # exception to avoid enumerating them all (the docs don't even have a + # full list!). + log.debug("state file could not be read: {0}", exc) + + def _save(self): + try: + with open(self.path, "wb") as f: + pickle.dump( + { + "tagprogress": self.tagprogress, + "taghistory": self.taghistory, + }, + f, + ) + except OSError as exc: + log.error("state file could not be written: {0}", exc) + + # -------------------------------- Tagprogress ------------------------------- # + + def progress_add(self, toppath: PathBytes, *paths: PathBytes): + """Record that the files under all of the `paths` have been imported + under `toppath`. + """ + with self as state: + imported = state.tagprogress.setdefault(toppath, []) + for path in paths: + if imported and imported[-1] <= path: + imported.append(path) + else: + insort(imported, path) + + def progress_has_element(self, toppath: PathBytes, path: PathBytes) -> bool: + """Return whether `path` has been imported in `toppath`.""" + imported = self.tagprogress.get(toppath, []) + i = bisect_left(imported, path) + return i != len(imported) and imported[i] == path + + def progress_has(self, toppath: PathBytes) -> bool: + """Return `True` if there exist paths that have already been + imported under `toppath`. + """ + return toppath in self.tagprogress + + def progress_reset(self, toppath: PathBytes | None): + """Reset the progress for `toppath`.""" + with self as state: + if toppath in state.tagprogress: + del state.tagprogress[toppath] + + # -------------------------------- Taghistory -------------------------------- # + + def history_add(self, paths: list[PathBytes]): + """Add the paths to the history.""" + with self as state: + state.taghistory.add(tuple(paths)) diff --git a/beets/importer.py b/beets/importer/tasks.py similarity index 60% rename from beets/importer.py rename to beets/importer/tasks.py index 2bdb16669..2d3dc44e8 100644 --- a/beets/importer.py +++ b/beets/importer/tasks.py @@ -12,44 +12,31 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Provides the basic, interface-agnostic workflow for importing and -autotagging music files. -""" - from __future__ import annotations -import itertools +import logging import os -import pickle import re import shutil import time -from bisect import bisect_left, insort from collections import defaultdict -from dataclasses import dataclass from enum import Enum from tempfile import mkdtemp -from typing import Callable, Iterable, Sequence +from typing import TYPE_CHECKING, Callable, Iterable, Sequence import mediafile -from beets import autotag, config, dbcore, library, logging, plugins, util -from beets.util import ( - MoveOperation, - ancestry, - displayable_path, - normpath, - pipeline, - sorted_walk, - syspath, -) +from beets import autotag, config, dbcore, library, plugins, util + +from .state import ImportState + +if TYPE_CHECKING: + from .session import ImportSession + +# Global logger. +log = logging.getLogger("beets") -action = Enum("action", ["SKIP", "ASIS", "TRACKS", "APPLY", "ALBUMS", "RETAG"]) -# The RETAG action represents "don't apply any match, but do record -# new metadata". It's not reachable via the standard command prompt but -# can be used by plugins. -QUEUE_SIZE = 128 SINGLE_ARTIST_THRESH = 0.25 # Usually flexible attributes are preserved (i.e., not updated) during @@ -74,9 +61,11 @@ REIMPORT_FRESH_FIELDS_ITEM = list(REIMPORT_FRESH_FIELDS_ALBUM) # Global logger. log = logging.getLogger("beets") -# Here for now to allow for a easy replace later on -# once we can move to a PathLike -PathBytes = bytes + +action = Enum("action", ["SKIP", "ASIS", "TRACKS", "APPLY", "ALBUMS", "RETAG"]) +# The RETAG action represents "don't apply any match, but do record +# new metadata". It's not reachable via the standard command prompt but +# can be used by plugins. class ImportAbortError(Exception): @@ -85,395 +74,20 @@ class ImportAbortError(Exception): pass -@dataclass -class ImportState: - """Representing the progress of an import task. - - Opens the state file on creation of the class. If you want - to ensure the state is written to disk, you should use the - context manager protocol. - - Tagprogress allows long tagging tasks to be resumed when they pause. - - Taghistory is a utility for manipulating the "incremental" import log. - This keeps track of all directories that were ever imported, which - allows the importer to only import new stuff. - - Usage - ----- - ``` - # Readonly - progress = ImportState().tagprogress - - # Read and write - with ImportState() as state: - state["key"] = "value" - ``` - """ - - tagprogress: dict[PathBytes, list[PathBytes]] - taghistory: set[tuple[PathBytes, ...]] - path: PathBytes - - def __init__(self, readonly=False, path: PathBytes | None = None): - self.path = path or os.fsencode(config["statefile"].as_filename()) - self.tagprogress = {} - self.taghistory = set() - self._open() - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self._save() - - def _open( - self, - ): - try: - with open(self.path, "rb") as f: - state = pickle.load(f) - # Read the states - self.tagprogress = state.get("tagprogress", {}) - self.taghistory = state.get("taghistory", set()) - except Exception as exc: - # The `pickle` module can emit all sorts of exceptions during - # unpickling, including ImportError. We use a catch-all - # exception to avoid enumerating them all (the docs don't even have a - # full list!). - log.debug("state file could not be read: {0}", exc) - - def _save(self): - try: - with open(self.path, "wb") as f: - pickle.dump( - { - "tagprogress": self.tagprogress, - "taghistory": self.taghistory, - }, - f, - ) - except OSError as exc: - log.error("state file could not be written: {0}", exc) - - # -------------------------------- Tagprogress ------------------------------- # - - def progress_add(self, toppath: PathBytes, *paths: PathBytes): - """Record that the files under all of the `paths` have been imported - under `toppath`. - """ - with self as state: - imported = state.tagprogress.setdefault(toppath, []) - for path in paths: - if imported and imported[-1] <= path: - imported.append(path) - else: - insort(imported, path) - - def progress_has_element(self, toppath: PathBytes, path: PathBytes) -> bool: - """Return whether `path` has been imported in `toppath`.""" - imported = self.tagprogress.get(toppath, []) - i = bisect_left(imported, path) - return i != len(imported) and imported[i] == path - - def progress_has(self, toppath: PathBytes) -> bool: - """Return `True` if there exist paths that have already been - imported under `toppath`. - """ - return toppath in self.tagprogress - - def progress_reset(self, toppath: PathBytes | None): - """Reset the progress for `toppath`.""" - with self as state: - if toppath in state.tagprogress: - del state.tagprogress[toppath] - - # -------------------------------- Taghistory -------------------------------- # - - def history_add(self, paths: list[PathBytes]): - """Add the paths to the history.""" - with self as state: - state.taghistory.add(tuple(paths)) - - -class ImportSession: - """Controls an import action. Subclasses should implement methods to - communicate with the user or otherwise make decisions. - """ - - logger: logging.Logger - paths: list[PathBytes] - lib: library.Library - - _is_resuming: dict[bytes, bool] - _merged_items: set[PathBytes] - _merged_dirs: set[PathBytes] - - def __init__( - self, - lib: library.Library, - loghandler: logging.Handler | None, - paths: Sequence[PathBytes] | None, - query: dbcore.Query | None, - ): - """Create a session. - - Parameters - ---------- - lib : library.Library - The library instance to which items will be imported. - loghandler : logging.Handler or None - A logging handler to use for the session's logger. If None, a - NullHandler will be used. - paths : os.PathLike or None - The paths to be imported. - query : dbcore.Query or None - A query to filter items for import. - """ - self.lib = lib - self.logger = self._setup_logging(loghandler) - self.query = query - self._is_resuming = {} - self._merged_items = set() - self._merged_dirs = set() - - # Normalize the paths. - self.paths = list(map(normpath, paths or [])) - - def _setup_logging(self, loghandler: logging.Handler | None): - logger = logging.getLogger(__name__) - logger.propagate = False - if not loghandler: - loghandler = logging.NullHandler() - logger.handlers = [loghandler] - return logger - - def set_config(self, config): - """Set `config` property from global import config and make - implied changes. - """ - # FIXME: Maybe this function should not exist and should instead - # provide "decision wrappers" like "should_resume()", etc. - iconfig = dict(config) - self.config = iconfig - - # Incremental and progress are mutually exclusive. - if iconfig["incremental"]: - iconfig["resume"] = False - - # When based on a query instead of directories, never - # save progress or try to resume. - if self.query is not None: - iconfig["resume"] = False - iconfig["incremental"] = False - - if iconfig["reflink"]: - iconfig["reflink"] = iconfig["reflink"].as_choice( - ["auto", True, False] - ) - - # Copy, move, reflink, link, and hardlink are mutually exclusive. - if iconfig["move"]: - iconfig["copy"] = False - iconfig["link"] = False - iconfig["hardlink"] = False - iconfig["reflink"] = False - elif iconfig["link"]: - iconfig["copy"] = False - iconfig["move"] = False - iconfig["hardlink"] = False - iconfig["reflink"] = False - elif iconfig["hardlink"]: - iconfig["copy"] = False - iconfig["move"] = False - iconfig["link"] = False - iconfig["reflink"] = False - elif iconfig["reflink"]: - iconfig["copy"] = False - iconfig["move"] = False - iconfig["link"] = False - iconfig["hardlink"] = False - - # Only delete when copying. - if not iconfig["copy"]: - iconfig["delete"] = False - - self.want_resume = config["resume"].as_choice([True, False, "ask"]) - - def tag_log(self, status, paths: Sequence[PathBytes]): - """Log a message about a given album to the importer log. The status - should reflect the reason the album couldn't be tagged. - """ - self.logger.info("{0} {1}", status, displayable_path(paths)) - - def log_choice(self, task: ImportTask, duplicate=False): - """Logs the task's current choice if it should be logged. If - ``duplicate``, then this is a secondary choice after a duplicate was - detected and a decision was made. - """ - paths = task.paths - if duplicate: - # Duplicate: log all three choices (skip, keep both, and trump). - if task.should_remove_duplicates: - self.tag_log("duplicate-replace", paths) - elif task.choice_flag in (action.ASIS, action.APPLY): - self.tag_log("duplicate-keep", paths) - elif task.choice_flag is (action.SKIP): - self.tag_log("duplicate-skip", paths) - else: - # Non-duplicate: log "skip" and "asis" choices. - if task.choice_flag is action.ASIS: - self.tag_log("asis", paths) - elif task.choice_flag is action.SKIP: - self.tag_log("skip", paths) - - def should_resume(self, path: PathBytes): - raise NotImplementedError - - def choose_match(self, task: ImportTask): - raise NotImplementedError - - def resolve_duplicate(self, task: ImportTask, found_duplicates): - raise NotImplementedError - - def choose_item(self, task: ImportTask): - raise NotImplementedError - - def run(self): - """Run the import task.""" - self.logger.info("import started {0}", time.asctime()) - self.set_config(config["import"]) - - # Set up the pipeline. - if self.query is None: - stages = [read_tasks(self)] - else: - stages = [query_tasks(self)] - - # In pretend mode, just log what would otherwise be imported. - if self.config["pretend"]: - stages += [log_files(self)] - else: - if self.config["group_albums"] and not self.config["singletons"]: - # Split directory tasks into one task for each album. - stages += [group_albums(self)] - - # These stages either talk to the user to get a decision or, - # in the case of a non-autotagged import, just choose to - # import everything as-is. In *both* cases, these stages - # also add the music to the library database, so later - # stages need to read and write data from there. - if self.config["autotag"]: - stages += [lookup_candidates(self), user_query(self)] - else: - stages += [import_asis(self)] - - # Plugin stages. - for stage_func in plugins.early_import_stages(): - stages.append(plugin_stage(self, stage_func)) - for stage_func in plugins.import_stages(): - stages.append(plugin_stage(self, stage_func)) - - stages += [manipulate_files(self)] - - pl = pipeline.Pipeline(stages) - - # Run the pipeline. - plugins.send("import_begin", session=self) - try: - if config["threaded"]: - pl.run_parallel(QUEUE_SIZE) - else: - pl.run_sequential() - except ImportAbortError: - # User aborted operation. Silently stop. - pass - - # Incremental and resumed imports - - def already_imported(self, toppath: PathBytes, paths: Sequence[PathBytes]): - """Returns true if the files belonging to this task have already - been imported in a previous session. - """ - if self.is_resuming(toppath) and all( - [ImportState().progress_has_element(toppath, p) for p in paths] - ): - return True - if self.config["incremental"] and tuple(paths) in self.history_dirs: - return True - - return False - - _history_dirs = None - - @property - def history_dirs(self) -> set[tuple[PathBytes, ...]]: - # FIXME: This could be simplified to a cached property - if self._history_dirs is None: - self._history_dirs = ImportState().taghistory - return self._history_dirs - - def already_merged(self, paths: Sequence[PathBytes]): - """Returns true if all the paths being imported were part of a merge - during previous tasks. - """ - for path in paths: - if path not in self._merged_items and path not in self._merged_dirs: - return False - return True - - def mark_merged(self, paths: Sequence[PathBytes]): - """Mark paths and directories as merged for future reimport tasks.""" - self._merged_items.update(paths) - dirs = { - os.path.dirname(path) if os.path.isfile(syspath(path)) else path - for path in paths - } - self._merged_dirs.update(dirs) - - def is_resuming(self, toppath: PathBytes): - """Return `True` if user wants to resume import of this path. - - You have to call `ask_resume` first to determine the return value. - """ - return self._is_resuming.get(toppath, False) - - def ask_resume(self, toppath: PathBytes): - """If import of `toppath` was aborted in an earlier session, ask - user if they want to resume the import. - - Determines the return value of `is_resuming(toppath)`. - """ - if self.want_resume and ImportState().progress_has(toppath): - # Either accept immediately or prompt for input to decide. - if self.want_resume is True or self.should_resume(toppath): - log.warning( - "Resuming interrupted import of {0}", - util.displayable_path(toppath), - ) - self._is_resuming[toppath] = True - else: - # Clear progress; we're starting from the top. - ImportState().progress_reset(toppath) - - -# The importer task class. - - class BaseImportTask: """An abstract base class for importer tasks. Tasks flow through the importer pipeline. Each stage can update them.""" - toppath: PathBytes | None - paths: list[PathBytes] + toppath: util.PathBytes | None + paths: list[util.PathBytes] items: list[library.Item] def __init__( self, - toppath: PathBytes | None, - paths: Iterable[PathBytes] | None, + toppath: util.PathBytes | None, + paths: Iterable[util.PathBytes] | None, items: Iterable[library.Item] | None, ): """Create a task. The primary fields that define a task are: @@ -539,8 +153,8 @@ class ImportTask(BaseImportTask): def __init__( self, - toppath: PathBytes | None, - paths: Iterable[PathBytes] | None, + toppath: util.PathBytes | None, + paths: Iterable[util.PathBytes] | None, items: Iterable[library.Item] | None, ): super().__init__(toppath, paths, items) @@ -662,7 +276,7 @@ class ImportTask(BaseImportTask): value = str(view.get()) log.debug( "Set field {1}={2} for {0}", - displayable_path(self.paths), + util.displayable_path(self.paths), field, value, ) @@ -823,7 +437,7 @@ class ImportTask(BaseImportTask): def manipulate_files( self, session: ImportSession, - operation: MoveOperation | None = None, + operation: util.MoveOperation | None = None, write=False, ): """Copy, move, link, hardlink or reflink (depending on `operation`) @@ -838,7 +452,7 @@ class ImportTask(BaseImportTask): items = self.imported_items() # Save the original paths of all items for deletion and pruning # in the next step (finalization). - self.old_paths: list[PathBytes] = [item.path for item in items] + self.old_paths: list[util.PathBytes] = [item.path for item in items] for item in items: if operation is not None: # In copy and link modes, treat re-imports specially: @@ -846,7 +460,7 @@ class ImportTask(BaseImportTask): # copied/moved as usual). old_path = item.path if ( - operation != MoveOperation.MOVE + operation != util.MoveOperation.MOVE and self.replaced_items[item] and session.lib.directory in util.ancestry(old_path) ): @@ -893,11 +507,13 @@ class ImportTask(BaseImportTask): and `replaced_albums` dictionaries. """ self.replaced_items = defaultdict(list) - self.replaced_albums: dict[PathBytes, library.Album] = defaultdict() + self.replaced_albums: dict[util.PathBytes, library.Album] = ( + defaultdict() + ) replaced_album_ids = set() for item in self.imported_items(): dup_items = list( - lib.items(dbcore.query.BytesQuery("path", item.path)) + lib.items(query=dbcore.query.BytesQuery("path", item.path)) ) self.replaced_items[item] = dup_items for dup_item in dup_items: @@ -938,7 +554,7 @@ class ImportTask(BaseImportTask): noun, new_obj.id, overwritten_fields, - displayable_path(new_obj.path), + util.displayable_path(new_obj.path), ) for key in overwritten_fields: del existing_fields[key] @@ -960,14 +576,14 @@ class ImportTask(BaseImportTask): "Reimported album {}. Preserving attribute ['added']. " "Path: {}", self.album.id, - displayable_path(self.album.path), + util.displayable_path(self.album.path), ) log.debug( "Reimported album {}. Preserving flexible attributes {}. " "Path: {}", self.album.id, list(album_fields.keys()), - displayable_path(self.album.path), + util.displayable_path(self.album.path), ) for item in self.imported_items(): @@ -979,7 +595,7 @@ class ImportTask(BaseImportTask): "Reimported item {}. Preserving attribute ['added']. " "Path: {}", item.id, - displayable_path(item.path), + util.displayable_path(item.path), ) item_fields = _reduce_and_log( item, dup_item._values_flex, REIMPORT_FRESH_FIELDS_ITEM @@ -990,7 +606,7 @@ class ImportTask(BaseImportTask): "Path: {}", item.id, list(item_fields.keys()), - displayable_path(item.path), + util.displayable_path(item.path), ) item.store() @@ -1003,7 +619,7 @@ class ImportTask(BaseImportTask): log.debug( "Replacing item {0}: {1}", dup_item.id, - displayable_path(item.path), + util.displayable_path(item.path), ) dup_item.remove() log.debug( @@ -1033,7 +649,7 @@ class ImportTask(BaseImportTask): the file still exists, no pruning is performed, so it's safe to call when the file in question may not have been removed. """ - if self.toppath and not os.path.exists(syspath(filename)): + if self.toppath and not os.path.exists(util.syspath(filename)): util.prune_dirs( os.path.dirname(filename), self.toppath, @@ -1044,7 +660,7 @@ class ImportTask(BaseImportTask): class SingletonImportTask(ImportTask): """ImportTask for a single track that is not associated to an album.""" - def __init__(self, toppath: PathBytes | None, item: library.Item): + def __init__(self, toppath: util.PathBytes | None, item: library.Item): super().__init__(toppath, [item.path], [item]) self.item = item self.is_album = False @@ -1127,7 +743,7 @@ class SingletonImportTask(ImportTask): value = str(view.get()) log.debug( "Set field {1}={2} for {0}", - displayable_path(self.paths), + util.displayable_path(self.paths), field, value, ) @@ -1246,9 +862,9 @@ class ArchiveImportTask(SentinelImportTask): if self.extracted and self.toppath: log.debug( "Removing extracted directory: {0}", - displayable_path(self.toppath), + util.displayable_path(self.toppath), ) - shutil.rmtree(syspath(self.toppath)) + shutil.rmtree(util.syspath(self.toppath)) def extract(self): """Extracts the archive to a temporary directory and sets @@ -1289,7 +905,7 @@ class ImportTaskFactory: indicated by a path. """ - def __init__(self, toppath: PathBytes, session: ImportSession): + def __init__(self, toppath: util.PathBytes, session: ImportSession): """Create a new task factory. `toppath` is the user-specified path to search for music to @@ -1300,7 +916,7 @@ class ImportTaskFactory: self.session = session self.skipped = 0 # Skipped due to incremental/resume. self.imported = 0 # "Real" tasks created. - self.is_archive = ArchiveImportTask.is_archive(syspath(toppath)) + self.is_archive = ArchiveImportTask.is_archive(util.syspath(toppath)) def tasks(self): """Yield all import tasks for music found in the user-specified @@ -1362,7 +978,7 @@ class ImportTaskFactory: single track when `toppath` is a file, a single directory in `flat` mode. """ - if not os.path.isdir(syspath(self.toppath)): + if not os.path.isdir(util.syspath(self.toppath)): yield [self.toppath], [self.toppath] elif self.session.config["flat"]: paths = [] @@ -1373,11 +989,12 @@ class ImportTaskFactory: for dirs, paths in albums_in_dir(self.toppath): yield dirs, paths - def singleton(self, path: PathBytes): + def singleton(self, path: util.PathBytes): """Return a `SingletonImportTask` for the music file.""" if self.session.already_imported(self.toppath, [path]): log.debug( - "Skipping previously-imported path: {0}", displayable_path(path) + "Skipping previously-imported path: {0}", + util.displayable_path(path), ) self.skipped += 1 return None @@ -1388,7 +1005,7 @@ class ImportTaskFactory: else: return None - def album(self, paths: Iterable[PathBytes], dirs=None): + def album(self, paths: Iterable[util.PathBytes], dirs=None): """Return a `ImportTask` with all media files from paths. `dirs` is a list of parent directories used to record already @@ -1400,7 +1017,8 @@ class ImportTaskFactory: if self.session.already_imported(self.toppath, dirs): log.debug( - "Skipping previously-imported path: {0}", displayable_path(dirs) + "Skipping previously-imported path: {0}", + util.displayable_path(dirs), ) self.skipped += 1 return None @@ -1414,7 +1032,7 @@ class ImportTaskFactory: else: return None - def sentinel(self, paths: Iterable[PathBytes] | None = None): + def sentinel(self, paths: Iterable[util.PathBytes] | None = None): """Return a `SentinelImportTask` indicating the end of a top-level directory import. """ @@ -1436,7 +1054,9 @@ class ImportTaskFactory: ) return - log.debug("Extracting archive: {0}", displayable_path(self.toppath)) + log.debug( + "Extracting archive: {0}", util.displayable_path(self.toppath) + ) archive_task = ArchiveImportTask(self.toppath) try: archive_task.extract() @@ -1449,7 +1069,7 @@ class ImportTaskFactory: log.debug("Archive extracted to: {0}", self.toppath) return archive_task - def read_item(self, path: PathBytes): + def read_item(self, path: util.PathBytes): """Return an `Item` read from the path. If an item cannot be read, return `None` instead and log an @@ -1462,355 +1082,11 @@ class ImportTaskFactory: # Silently ignore non-music files. pass elif isinstance(exc.reason, mediafile.UnreadableFileError): - log.warning("unreadable file: {0}", displayable_path(path)) + log.warning("unreadable file: {0}", util.displayable_path(path)) else: - log.error("error reading {0}: {1}", displayable_path(path), exc) - - -# Pipeline utilities - - -def _freshen_items(items): - # Clear IDs from re-tagged items so they appear "fresh" when - # we add them back to the library. - for item in items: - item.id = None - item.album_id = None - - -def _extend_pipeline(tasks, *stages): - # Return pipeline extension for stages with list of tasks - if isinstance(tasks, list): - task_iter = iter(tasks) - else: - task_iter = tasks - - ipl = pipeline.Pipeline([task_iter] + list(stages)) - return pipeline.multiple(ipl.pull()) - - -# Full-album pipeline stages. - - -def read_tasks(session: ImportSession): - """A generator yielding all the albums (as ImportTask objects) found - in the user-specified list of paths. In the case of a singleton - import, yields single-item tasks instead. - """ - skipped = 0 - - for toppath in session.paths: - # Check whether we need to resume the import. - session.ask_resume(toppath) - - # Generate tasks. - task_factory = ImportTaskFactory(toppath, session) - yield from task_factory.tasks() - skipped += task_factory.skipped - - if not task_factory.imported: - log.warning("No files imported from {0}", displayable_path(toppath)) - - # Show skipped directories (due to incremental/resume). - if skipped: - log.info("Skipped {0} paths.", skipped) - - -def query_tasks(session: ImportSession): - """A generator that works as a drop-in-replacement for read_tasks. - Instead of finding files from the filesystem, a query is used to - match items from the library. - """ - if session.config["singletons"]: - # Search for items. - for item in session.lib.items(session.query): - task = SingletonImportTask(None, item) - for task in task.handle_created(session): - yield task - - else: - # Search for albums. - for album in session.lib.albums(session.query): - log.debug( - "yielding album {0}: {1} - {2}", - album.id, - album.albumartist, - album.album, - ) - items = list(album.items()) - _freshen_items(items) - - task = ImportTask(None, [album.item_dir()], items) - for task in task.handle_created(session): - yield task - - -@pipeline.mutator_stage -def lookup_candidates(session: ImportSession, task: ImportTask): - """A coroutine for performing the initial MusicBrainz lookup for an - album. It accepts lists of Items and yields - (items, cur_artist, cur_album, candidates, rec) tuples. If no match - is found, all of the yielded parameters (except items) are None. - """ - if task.skip: - # FIXME This gets duplicated a lot. We need a better - # abstraction. - return - - plugins.send("import_task_start", session=session, task=task) - log.debug("Looking up: {0}", displayable_path(task.paths)) - - # Restrict the initial lookup to IDs specified by the user via the -m - # option. Currently all the IDs are passed onto the tasks directly. - task.search_ids = session.config["search_ids"].as_str_seq() - - task.lookup_candidates() - - -@pipeline.stage -def user_query(session: ImportSession, task: ImportTask): - """A coroutine for interfacing with the user about the tagging - process. - - The coroutine accepts an ImportTask objects. It uses the - session's `choose_match` method to determine the `action` for - this task. Depending on the action additional stages are executed - and the processed task is yielded. - - It emits the ``import_task_choice`` event for plugins. Plugins have - access to the choice via the ``task.choice_flag`` property and may - choose to change it. - """ - if task.skip: - return task - - if session.already_merged(task.paths): - return pipeline.BUBBLE - - # Ask the user for a choice. - task.choose_match(session) - plugins.send("import_task_choice", session=session, task=task) - - # As-tracks: transition to singleton workflow. - if task.choice_flag is action.TRACKS: - # Set up a little pipeline for dealing with the singletons. - def emitter(task): - for item in task.items: - task = SingletonImportTask(task.toppath, item) - yield from task.handle_created(session) - yield SentinelImportTask(task.toppath, task.paths) - - return _extend_pipeline( - emitter(task), lookup_candidates(session), user_query(session) - ) - - # As albums: group items by albums and create task for each album - if task.choice_flag is action.ALBUMS: - return _extend_pipeline( - [task], - group_albums(session), - lookup_candidates(session), - user_query(session), - ) - - resolve_duplicates(session, task) - - if task.should_merge_duplicates: - # Create a new task for tagging the current items - # and duplicates together - duplicate_items = task.duplicate_items(session.lib) - - # Duplicates would be reimported so make them look "fresh" - _freshen_items(duplicate_items) - duplicate_paths = [item.path for item in duplicate_items] - - # Record merged paths in the session so they are not reimported - session.mark_merged(duplicate_paths) - - merged_task = ImportTask( - None, task.paths + duplicate_paths, task.items + duplicate_items - ) - - return _extend_pipeline( - [merged_task], lookup_candidates(session), user_query(session) - ) - - apply_choice(session, task) - return task - - -def resolve_duplicates(session: ImportSession, task: ImportTask): - """Check if a task conflicts with items or albums already imported - and ask the session to resolve this. - """ - if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG): - found_duplicates = task.find_duplicates(session.lib) - if found_duplicates: - log.debug( - "found duplicates: {}".format([o.id for o in found_duplicates]) - ) - - # Get the default action to follow from config. - duplicate_action = config["import"]["duplicate_action"].as_choice( - { - "skip": "s", - "keep": "k", - "remove": "r", - "merge": "m", - "ask": "a", - } - ) - log.debug("default action for duplicates: {0}", duplicate_action) - - if duplicate_action == "s": - # Skip new. - task.set_choice(action.SKIP) - elif duplicate_action == "k": - # Keep both. Do nothing; leave the choice intact. - pass - elif duplicate_action == "r": - # Remove old. - task.should_remove_duplicates = True - elif duplicate_action == "m": - # Merge duplicates together - task.should_merge_duplicates = True - else: - # No default action set; ask the session. - session.resolve_duplicate(task, found_duplicates) - - session.log_choice(task, True) - - -@pipeline.mutator_stage -def import_asis(session: ImportSession, task: ImportTask): - """Select the `action.ASIS` choice for all tasks. - - This stage replaces the initial_lookup and user_query stages - when the importer is run without autotagging. - """ - if task.skip: - return - - log.info("{}", displayable_path(task.paths)) - task.set_choice(action.ASIS) - apply_choice(session, task) - - -def apply_choice(session: ImportSession, task: ImportTask): - """Apply the task's choice to the Album or Item it contains and add - it to the library. - """ - if task.skip: - return - - # Change metadata. - if task.apply: - task.apply_metadata() - plugins.send("import_task_apply", session=session, task=task) - - task.add(session.lib) - - # If ``set_fields`` is set, set those fields to the - # configured values. - # NOTE: This cannot be done before the ``task.add()`` call above, - # because then the ``ImportTask`` won't have an `album` for which - # it can set the fields. - if config["import"]["set_fields"]: - task.set_fields(session.lib) - - -@pipeline.mutator_stage -def plugin_stage( - session: ImportSession, - func: Callable[[ImportSession, ImportTask], None], - task: ImportTask, -): - """A coroutine (pipeline stage) that calls the given function with - each non-skipped import task. These stages occur between applying - metadata changes and moving/copying/writing files. - """ - if task.skip: - return - - func(session, task) - - # Stage may modify DB, so re-load cached item data. - # FIXME Importer plugins should not modify the database but instead - # the albums and items attached to tasks. - task.reload() - - -@pipeline.stage -def manipulate_files(session: ImportSession, task: ImportTask): - """A coroutine (pipeline stage) that performs necessary file - manipulations *after* items have been added to the library and - finalizes each task. - """ - if not task.skip: - if task.should_remove_duplicates: - task.remove_duplicates(session.lib) - - if session.config["move"]: - operation = MoveOperation.MOVE - elif session.config["copy"]: - operation = MoveOperation.COPY - elif session.config["link"]: - operation = MoveOperation.LINK - elif session.config["hardlink"]: - operation = MoveOperation.HARDLINK - elif session.config["reflink"] == "auto": - operation = MoveOperation.REFLINK_AUTO - elif session.config["reflink"]: - operation = MoveOperation.REFLINK - else: - operation = None - - task.manipulate_files( - session=session, - operation=operation, - write=session.config["write"], - ) - - # Progress, cleanup, and event. - task.finalize(session) - - -@pipeline.stage -def log_files(session: ImportSession, task: ImportTask): - """A coroutine (pipeline stage) to log each file to be imported.""" - if isinstance(task, SingletonImportTask): - log.info("Singleton: {0}", displayable_path(task.item["path"])) - elif task.items: - log.info("Album: {0}", displayable_path(task.paths[0])) - for item in task.items: - log.info(" {0}", displayable_path(item["path"])) - - -def group_albums(session: ImportSession): - """A pipeline stage that groups the items of each task into albums - using their metadata. - - Groups are identified using their artist and album fields. The - pipeline stage emits new album tasks for each discovered group. - """ - - def group(item): - return (item.albumartist or item.artist, item.album) - - task = None - while True: - task = yield task - if task.skip: - continue - tasks = [] - sorted_items: list[library.Item] = sorted(task.items, key=group) - for _, items in itertools.groupby(sorted_items, group): - l_items = list(items) - task = ImportTask(task.toppath, [i.path for i in l_items], l_items) - tasks += task.handle_created(session) - tasks.append(SentinelImportTask(task.toppath, task.paths)) - - task = pipeline.multiple(tasks) + log.error( + "error reading {0}: {1}", util.displayable_path(path), exc + ) MULTIDISC_MARKERS = (rb"dis[ck]", rb"cd") @@ -1821,11 +1097,11 @@ def is_subdir_of_any_in_list(path, dirs): """Returns True if path os a subdirectory of any directory in dirs (a list). In other case, returns False. """ - ancestors = ancestry(path) + ancestors = util.ancestry(path) return any(d in ancestors for d in dirs) -def albums_in_dir(path: PathBytes): +def albums_in_dir(path: util.PathBytes): """Recursively searches the given directory and returns an iterable of (paths, items) where paths is a list of directories and items is a list of Items that is probably an album. Specifically, any folder @@ -1835,7 +1111,7 @@ def albums_in_dir(path: PathBytes): ignore: list[str] = config["ignore"].as_str_seq() ignore_hidden: bool = config["ignore_hidden"].get(bool) - for root, dirs, files in sorted_walk( + for root, dirs, files in util.sorted_walk( path, ignore=ignore, ignore_hidden=ignore_hidden, logger=log ): items = [os.path.join(root, f) for f in files] From 68acaa6470a04474a2aefe9b1de71ae7fa10b861 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 13 May 2025 12:11:40 +0200 Subject: [PATCH 2/4] Renamed all `action` occurrences with `Action`. --- beets/importer/__init__.py | 38 +++++++++ beets/importer/session.py | 33 ++++---- beets/importer/stages.py | 22 ++--- beets/importer/tasks.py | 66 ++++++++------- beets/test/helper.py | 24 +++--- beets/ui/commands.py | 38 ++++----- beets/util/__init__.py | 4 + beetsplug/badfiles.py | 2 +- beetsplug/edit.py | 6 +- beetsplug/fetchart.py | 6 +- beetsplug/ihate.py | 6 +- beetsplug/zero.py | 4 +- docs/dev/plugins.rst | 2 +- test/plugins/test_importadded.py | 2 +- test/test_importer.py | 138 +++++++++++++++---------------- test/test_plugins.py | 10 +-- 16 files changed, 227 insertions(+), 174 deletions(-) create mode 100644 beets/importer/__init__.py diff --git a/beets/importer/__init__.py b/beets/importer/__init__.py new file mode 100644 index 000000000..586b238e6 --- /dev/null +++ b/beets/importer/__init__.py @@ -0,0 +1,38 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# 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. + +"""Provides the basic, interface-agnostic workflow for importing and +autotagging music files. +""" + +from .session import ImportAbortError, ImportSession +from .tasks import ( + Action, + ArchiveImportTask, + ImportTask, + SentinelImportTask, + SingletonImportTask, +) + +# Note: Stages are not exposed to the public API + +__all__ = [ + "ImportSession", + "ImportAbortError", + "Action", + "ImportTask", + "ArchiveImportTask", + "SentinelImportTask", + "SingletonImportTask", +] diff --git a/beets/importer/session.py b/beets/importer/session.py index 620eb688e..e45644fa3 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -18,10 +18,10 @@ import time from typing import TYPE_CHECKING, Sequence from beets import config, dbcore, library, logging, plugins, util -from beets.importer.tasks import action +from beets.importer.tasks import Action from beets.util import displayable_path, normpath, pipeline, syspath -from .stages import * +from . import stages as stagefuncs from .state import ImportState if TYPE_CHECKING: @@ -162,15 +162,15 @@ class ImportSession: # Duplicate: log all three choices (skip, keep both, and trump). if task.should_remove_duplicates: self.tag_log("duplicate-replace", paths) - elif task.choice_flag in (action.ASIS, action.APPLY): + elif task.choice_flag in (Action.ASIS, Action.APPLY): self.tag_log("duplicate-keep", paths) - elif task.choice_flag is action.SKIP: + elif task.choice_flag is Action.SKIP: self.tag_log("duplicate-skip", paths) else: # Non-duplicate: log "skip" and "asis" choices. - if task.choice_flag is action.ASIS: + if task.choice_flag is Action.ASIS: self.tag_log("asis", paths) - elif task.choice_flag is action.SKIP: + elif task.choice_flag is Action.SKIP: self.tag_log("skip", paths) def should_resume(self, path: PathBytes): @@ -192,17 +192,17 @@ class ImportSession: # Set up the pipeline. if self.query is None: - stages = [read_tasks(self)] + stages = [stagefuncs.read_tasks(self)] else: - stages = [query_tasks(self)] + stages = [stagefuncs.query_tasks(self)] # In pretend mode, just log what would otherwise be imported. if self.config["pretend"]: - stages += [log_files(self)] + stages += [stagefuncs.log_files(self)] else: if self.config["group_albums"] and not self.config["singletons"]: # Split directory tasks into one task for each album. - stages += [group_albums(self)] + stages += [stagefuncs.group_albums(self)] # These stages either talk to the user to get a decision or, # in the case of a non-autotagged import, just choose to @@ -210,17 +210,20 @@ class ImportSession: # also add the music to the library database, so later # stages need to read and write data from there. if self.config["autotag"]: - stages += [lookup_candidates(self), user_query(self)] + stages += [ + stagefuncs.lookup_candidates(self), + stagefuncs.user_query(self), + ] else: - stages += [import_asis(self)] + stages += [stagefuncs.import_asis(self)] # Plugin stages. for stage_func in plugins.early_import_stages(): - stages.append(plugin_stage(self, stage_func)) + stages.append(stagefuncs.plugin_stage(self, stage_func)) for stage_func in plugins.import_stages(): - stages.append(plugin_stage(self, stage_func)) + stages.append(stagefuncs.plugin_stage(self, stage_func)) - stages += [manipulate_files(self)] + stages += [stagefuncs.manipulate_files(self)] pl = pipeline.Pipeline(stages) diff --git a/beets/importer/stages.py b/beets/importer/stages.py index 52b2a221a..5b3540db4 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -22,7 +22,7 @@ from beets import config, plugins from beets.util import MoveOperation, displayable_path, pipeline from .tasks import ( - action, + Action, ImportTask, ImportTaskFactory, SentinelImportTask, @@ -173,7 +173,7 @@ def user_query(session: ImportSession, task: ImportTask): plugins.send("import_task_choice", session=session, task=task) # As-tracks: transition to singleton workflow. - if task.choice_flag is action.TRACKS: + if task.choice_flag is Action.TRACKS: # Set up a little pipeline for dealing with the singletons. def emitter(task): for item in task.items: @@ -186,7 +186,7 @@ def user_query(session: ImportSession, task: ImportTask): ) # As albums: group items by albums and create task for each album - if task.choice_flag is action.ALBUMS: + if task.choice_flag is Action.ALBUMS: return _extend_pipeline( [task], group_albums(session), @@ -194,7 +194,7 @@ def user_query(session: ImportSession, task: ImportTask): user_query(session), ) - resolve_duplicates(session, task) + _resolve_duplicates(session, task) if task.should_merge_duplicates: # Create a new task for tagging the current items @@ -216,7 +216,7 @@ def user_query(session: ImportSession, task: ImportTask): [merged_task], lookup_candidates(session), user_query(session) ) - apply_choice(session, task) + _apply_choice(session, task) return task @@ -231,8 +231,8 @@ def import_asis(session: ImportSession, task: ImportTask): return log.info("{}", displayable_path(task.paths)) - task.set_choice(action.ASIS) - apply_choice(session, task) + task.set_choice(Action.ASIS) + _apply_choice(session, task) @pipeline.mutator_stage @@ -312,7 +312,7 @@ def manipulate_files(session: ImportSession, task: ImportTask): # Private functions only used in the stages above -def apply_choice(session: ImportSession, task: ImportTask): +def _apply_choice(session: ImportSession, task: ImportTask): """Apply the task's choice to the Album or Item it contains and add it to the library. """ @@ -335,11 +335,11 @@ def apply_choice(session: ImportSession, task: ImportTask): task.set_fields(session.lib) -def resolve_duplicates(session: ImportSession, task: ImportTask): +def _resolve_duplicates(session: ImportSession, task: ImportTask): """Check if a task conflicts with items or albums already imported and ask the session to resolve this. """ - if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG): + if task.choice_flag in (Action.ASIS, Action.APPLY, Action.RETAG): found_duplicates = task.find_duplicates(session.lib) if found_duplicates: log.debug( @@ -360,7 +360,7 @@ def resolve_duplicates(session: ImportSession, task: ImportTask): if duplicate_action == "s": # Skip new. - task.set_choice(action.SKIP) + task.set_choice(Action.SKIP) elif duplicate_action == "k": # Keep both. Do nothing; leave the choice intact. pass diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 2d3dc44e8..4ca5cf89f 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -31,7 +31,7 @@ from beets import autotag, config, dbcore, library, plugins, util from .state import ImportState if TYPE_CHECKING: - from .session import ImportSession + from .session import ImportSession, PathBytes # Global logger. log = logging.getLogger("beets") @@ -62,18 +62,26 @@ REIMPORT_FRESH_FIELDS_ITEM = list(REIMPORT_FRESH_FIELDS_ALBUM) log = logging.getLogger("beets") -action = Enum("action", ["SKIP", "ASIS", "TRACKS", "APPLY", "ALBUMS", "RETAG"]) -# The RETAG action represents "don't apply any match, but do record -# new metadata". It's not reachable via the standard command prompt but -# can be used by plugins. - - class ImportAbortError(Exception): """Raised when the user aborts the tagging operation.""" pass +class Action(Enum): + """Enumeration of possible actions for an import task.""" + + SKIP = "SKIP" + ASIS = "ASIS" + TRACKS = "TRACKS" + APPLY = "APPLY" + ALBUMS = "ALBUMS" + RETAG = "RETAG" + # The RETAG action represents "don't apply any match, but do record + # new metadata". It's not reachable via the standard command prompt but + # can be used by plugins. + + class BaseImportTask: """An abstract base class for importer tasks. @@ -143,7 +151,7 @@ class ImportTask(BaseImportTask): system. """ - choice_flag: action | None = None + choice_flag: Action | None = None match: autotag.AlbumMatch | autotag.TrackMatch | None = None # Keep track of the current task item @@ -165,7 +173,7 @@ class ImportTask(BaseImportTask): self.search_ids = [] # user-supplied candidate IDs. def set_choice( - self, choice: action | autotag.AlbumMatch | autotag.TrackMatch + self, choice: Action | autotag.AlbumMatch | autotag.TrackMatch ): """Given an AlbumMatch or TrackMatch object or an action constant, indicates that an action has been selected for this task. @@ -174,20 +182,20 @@ class ImportTask(BaseImportTask): use isinstance to check for them. """ # Not part of the task structure: - assert choice != action.APPLY # Only used internally. + assert choice != Action.APPLY # Only used internally. if choice in ( - action.SKIP, - action.ASIS, - action.TRACKS, - action.ALBUMS, - action.RETAG, + Action.SKIP, + Action.ASIS, + Action.TRACKS, + Action.ALBUMS, + Action.RETAG, ): # TODO: redesign to stricten the type self.choice_flag = choice # type: ignore[assignment] self.match = None else: - self.choice_flag = action.APPLY # Implicit choice. + self.choice_flag = Action.APPLY # Implicit choice. self.match = choice # type: ignore[assignment] def save_progress(self): @@ -205,11 +213,11 @@ class ImportTask(BaseImportTask): @property def apply(self): - return self.choice_flag == action.APPLY + return self.choice_flag == Action.APPLY @property def skip(self): - return self.choice_flag == action.SKIP + return self.choice_flag == Action.SKIP # Convenient data. @@ -219,10 +227,10 @@ class ImportTask(BaseImportTask): (in which case the data comes from the files' current metadata) or APPLY (in which case the data comes from the choice). """ - if self.choice_flag in (action.ASIS, action.RETAG): + if self.choice_flag in (Action.ASIS, Action.RETAG): likelies, consensus = autotag.current_metadata(self.items) return likelies - elif self.choice_flag is action.APPLY and self.match: + elif self.choice_flag is Action.APPLY and self.match: return self.match.info.copy() assert False @@ -232,9 +240,9 @@ class ImportTask(BaseImportTask): If the tasks applies an album match the method only returns the matched items. """ - if self.choice_flag in (action.ASIS, action.RETAG): + if self.choice_flag in (Action.ASIS, Action.RETAG): return list(self.items) - elif self.choice_flag == action.APPLY and isinstance( + elif self.choice_flag == Action.APPLY and isinstance( self.match, autotag.AlbumMatch ): return list(self.match.mapping.keys()) @@ -401,7 +409,7 @@ class ImportTask(BaseImportTask): """ changes = {} - if self.choice_flag == action.ASIS: + if self.choice_flag == Action.ASIS: # Taking metadata "as-is". Guess whether this album is VA. plur_albumartist, freq = util.plurality( [i.albumartist or i.artist for i in self.items] @@ -418,7 +426,7 @@ class ImportTask(BaseImportTask): changes["albumartist"] = config["va_name"].as_str() changes["comp"] = True - elif self.choice_flag in (action.APPLY, action.RETAG): + elif self.choice_flag in (Action.APPLY, Action.RETAG): # Applying autotagged metadata. Just get AA from the first # item. if not self.items[0].albumartist: @@ -473,7 +481,7 @@ class ImportTask(BaseImportTask): # old paths. item.move(operation) - if write and (self.apply or self.choice_flag == action.RETAG): + if write and (self.apply or self.choice_flag == Action.RETAG): item.try_write() with session.lib.transaction(): @@ -490,7 +498,7 @@ class ImportTask(BaseImportTask): self.remove_replaced(lib) self.album = lib.add_album(self.imported_items()) - if self.choice_flag == action.APPLY and isinstance( + if self.choice_flag == Action.APPLY and isinstance( self.match, autotag.AlbumMatch ): # Copy album flexible fields to the DB @@ -672,10 +680,10 @@ class SingletonImportTask(ImportTask): (in which case the data comes from the files' current metadata) or APPLY (in which case the data comes from the choice). """ - assert self.choice_flag in (action.ASIS, action.RETAG, action.APPLY) - if self.choice_flag in (action.ASIS, action.RETAG): + assert self.choice_flag in (Action.ASIS, Action.RETAG, Action.APPLY) + if self.choice_flag in (Action.ASIS, Action.RETAG): return dict(self.item) - elif self.choice_flag is action.APPLY: + elif self.choice_flag is Action.APPLY: return self.match.info.copy() def imported_items(self): diff --git a/beets/test/helper.py b/beets/test/helper.py index 85ea6bcf7..67ae1cfcf 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -658,9 +658,9 @@ class ImportSessionFixture(ImportSession): >>> lib = Library(':memory:') >>> importer = ImportSessionFixture(lib, paths=['/path/to/import']) - >>> importer.add_choice(importer.action.SKIP) - >>> importer.add_choice(importer.action.ASIS) - >>> importer.default_choice = importer.action.APPLY + >>> importer.add_choice(importer.Action.SKIP) + >>> importer.add_choice(importer.Action.ASIS) + >>> importer.default_choice = importer.Action.APPLY >>> importer.run() This imports ``/path/to/import`` into `lib`. It skips the first @@ -673,7 +673,7 @@ class ImportSessionFixture(ImportSession): self._choices = [] self._resolutions = [] - default_choice = importer.action.APPLY + default_choice = importer.Action.APPLY def add_choice(self, choice): self._choices.append(choice) @@ -687,7 +687,7 @@ class ImportSessionFixture(ImportSession): except IndexError: choice = self.default_choice - if choice == importer.action.APPLY: + if choice == importer.Action.APPLY: return task.candidates[0] elif isinstance(choice, int): return task.candidates[choice - 1] @@ -707,7 +707,7 @@ class ImportSessionFixture(ImportSession): res = self.default_resolution if res == self.Resolution.SKIP: - task.set_choice(importer.action.SKIP) + task.set_choice(importer.Action.SKIP) elif res == self.Resolution.REMOVE: task.should_remove_duplicates = True elif res == self.Resolution.MERGE: @@ -720,7 +720,7 @@ class TerminalImportSessionFixture(TerminalImportSession): super().__init__(*args, **kwargs) self._choices = [] - default_choice = importer.action.APPLY + default_choice = importer.Action.APPLY def add_choice(self, choice): self._choices.append(choice) @@ -742,15 +742,15 @@ class TerminalImportSessionFixture(TerminalImportSession): except IndexError: choice = self.default_choice - if choice == importer.action.APPLY: + if choice == importer.Action.APPLY: self.io.addinput("A") - elif choice == importer.action.ASIS: + elif choice == importer.Action.ASIS: self.io.addinput("U") - elif choice == importer.action.ALBUMS: + elif choice == importer.Action.ALBUMS: self.io.addinput("G") - elif choice == importer.action.TRACKS: + elif choice == importer.Action.TRACKS: self.io.addinput("T") - elif choice == importer.action.SKIP: + elif choice == importer.Action.SKIP: self.io.addinput("S") else: self.io.addinput("M") diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 12ea4c94d..7b7554546 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -811,12 +811,12 @@ def _summary_judgment(rec): if config["import"]["quiet"]: if rec == Recommendation.strong: - return importer.action.APPLY + return importer.Action.APPLY else: action = config["import"]["quiet_fallback"].as_choice( { - "skip": importer.action.SKIP, - "asis": importer.action.ASIS, + "skip": importer.Action.SKIP, + "asis": importer.Action.ASIS, } ) elif config["import"]["timid"]: @@ -824,17 +824,17 @@ def _summary_judgment(rec): elif rec == Recommendation.none: action = config["import"]["none_rec_action"].as_choice( { - "skip": importer.action.SKIP, - "asis": importer.action.ASIS, + "skip": importer.Action.SKIP, + "asis": importer.Action.ASIS, "ask": None, } ) else: return None - if action == importer.action.SKIP: + if action == importer.Action.SKIP: print_("Skipping.") - elif action == importer.action.ASIS: + elif action == importer.Action.ASIS: print_("Importing as-is.") return action @@ -1064,7 +1064,7 @@ class TerminalImportSession(importer.ImportSession): # Take immediate action if appropriate. action = _summary_judgment(task.rec) - if action == importer.action.APPLY: + if action == importer.Action.APPLY: match = task.candidates[0] show_change(task.cur_artist, task.cur_album, match) return match @@ -1074,7 +1074,7 @@ class TerminalImportSession(importer.ImportSession): # Loop until we have a choice. while True: # Ask for a choice from the user. The result of - # `choose_candidate` may be an `importer.action`, an + # `choose_candidate` may be an `importer.Action`, an # `AlbumMatch` object for a specific selection, or a # `PromptChoice`. choices = self._get_choices(task) @@ -1089,7 +1089,7 @@ class TerminalImportSession(importer.ImportSession): ) # Basic choices that require no more action here. - if choice in (importer.action.SKIP, importer.action.ASIS): + if choice in (importer.Action.SKIP, importer.Action.ASIS): # Pass selection to main control flow. return choice @@ -1097,7 +1097,7 @@ class TerminalImportSession(importer.ImportSession): # function. elif choice in choices: post_choice = choice.callback(self, task) - if isinstance(post_choice, importer.action): + if isinstance(post_choice, importer.Action): return post_choice elif isinstance(post_choice, autotag.Proposal): # Use the new candidates and continue around the loop. @@ -1121,7 +1121,7 @@ class TerminalImportSession(importer.ImportSession): # Take immediate action if appropriate. action = _summary_judgment(task.rec) - if action == importer.action.APPLY: + if action == importer.Action.APPLY: match = candidates[0] show_item_change(task.item, match) return match @@ -1135,12 +1135,12 @@ class TerminalImportSession(importer.ImportSession): candidates, True, rec, item=task.item, choices=choices ) - if choice in (importer.action.SKIP, importer.action.ASIS): + if choice in (importer.Action.SKIP, importer.Action.ASIS): return choice elif choice in choices: post_choice = choice.callback(self, task) - if isinstance(post_choice, importer.action): + if isinstance(post_choice, importer.Action): return post_choice elif isinstance(post_choice, autotag.Proposal): candidates = post_choice.candidates @@ -1203,7 +1203,7 @@ class TerminalImportSession(importer.ImportSession): if sel == "s": # Skip new. - task.set_choice(importer.action.SKIP) + task.set_choice(importer.Action.SKIP) elif sel == "k": # Keep both. Do nothing; leave the choice intact. pass @@ -1239,16 +1239,16 @@ class TerminalImportSession(importer.ImportSession): """ # Standard, built-in choices. choices = [ - PromptChoice("s", "Skip", lambda s, t: importer.action.SKIP), - PromptChoice("u", "Use as-is", lambda s, t: importer.action.ASIS), + PromptChoice("s", "Skip", lambda s, t: importer.Action.SKIP), + PromptChoice("u", "Use as-is", lambda s, t: importer.Action.ASIS), ] if task.is_album: choices += [ PromptChoice( - "t", "as Tracks", lambda s, t: importer.action.TRACKS + "t", "as Tracks", lambda s, t: importer.Action.TRACKS ), PromptChoice( - "g", "Group albums", lambda s, t: importer.action.ALBUMS + "g", "Group albums", lambda s, t: importer.Action.ALBUMS ), ] choices += [ diff --git a/beets/util/__init__.py b/beets/util/__init__.py index e17de1f51..845d967d6 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -68,6 +68,10 @@ BytesOrStr = Union[str, bytes] PathLike = Union[BytesOrStr, Path] Replacements: TypeAlias = "Sequence[tuple[Pattern[str], str]]" +# Here for now to allow for a easy replace later on +# once we can move to a PathLike (mainly used in importer) +PathBytes = bytes + class HumanReadableError(Exception): """An Exception that can include a human-readable error message to diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index f93f03d5e..0903ebabf 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -194,7 +194,7 @@ class BadFiles(BeetsPlugin): sel = ui.input_options(["aBort", "skip", "continue"]) if sel == "s": - return importer.action.SKIP + return importer.Action.SKIP elif sel == "c": return None elif sel == "b": diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 51b36bdab..b92c48839 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -24,7 +24,7 @@ import yaml from beets import plugins, ui, util from beets.dbcore import types -from beets.importer import action +from beets.importer import Action from beets.ui.commands import PromptChoice, _do_query # These "safe" types can avoid the format/parse cycle that most fields go @@ -380,9 +380,9 @@ class EditPlugin(plugins.BeetsPlugin): # Save the new data. if success: - # Return action.RETAG, which makes the importer write the tags + # Return Action.RETAG, which makes the importer write the tags # to the files if needed without re-applying metadata. - return action.RETAG + return Action.RETAG else: # Edit cancelled / no edits made. Revert changes. for obj in task.items: diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index a1bd26055..5451b4dbb 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -1306,12 +1306,12 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): ): # Album already has art (probably a re-import); skip it. return - if task.choice_flag == importer.action.ASIS: + if task.choice_flag == importer.Action.ASIS: # For as-is imports, don't search Web sources for art. local = True elif task.choice_flag in ( - importer.action.APPLY, - importer.action.RETAG, + importer.Action.APPLY, + importer.Action.RETAG, ): # Search everywhere for art. local = False diff --git a/beetsplug/ihate.py b/beetsplug/ihate.py index 35788ea05..d6357294d 100644 --- a/beetsplug/ihate.py +++ b/beetsplug/ihate.py @@ -15,7 +15,7 @@ """Warns you about things you hate (or even blocks import).""" -from beets.importer import action +from beets.importer import Action from beets.library import Album, Item, parse_query_string from beets.plugins import BeetsPlugin @@ -65,11 +65,11 @@ class IHatePlugin(BeetsPlugin): skip_queries = self.config["skip"].as_str_seq() warn_queries = self.config["warn"].as_str_seq() - if task.choice_flag == action.APPLY: + if task.choice_flag == Action.APPLY: if skip_queries or warn_queries: self._log.debug("processing your hate") if self.do_i_hate_this(task, skip_queries): - task.choice_flag = action.SKIP + task.choice_flag = Action.SKIP self._log.info("skipped: {0}", summary(task)) return if self.do_i_hate_this(task, warn_queries): diff --git a/beetsplug/zero.py b/beetsplug/zero.py index 5d1244dec..7ee624ce7 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -19,7 +19,7 @@ import re import confuse from mediafile import MediaFile -from beets.importer import action +from beets.importer import Action from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs, input_yn @@ -105,7 +105,7 @@ class ZeroPlugin(BeetsPlugin): self.fields_to_progs[field] = [] def import_task_choice_event(self, session, task): - if task.choice_flag == action.ASIS and not self.warned: + if task.choice_flag == Action.ASIS and not self.warned: self._log.warning('cannot zero in "as-is" mode') self.warned = True # TODO request write in as-is mode diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 0ebff3231..2d30f86c9 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -648,6 +648,6 @@ by the choices on the core importer prompt, and hence should not be used: ``a``, ``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``. Additionally, the callback function can optionally specify the next action to -be performed by returning a ``importer.action`` value. It may also return a +be performed by returning a ``importer.Action`` value. It may also return a ``autotag.Proposal`` value to update the set of current proposals to be considered. diff --git a/test/plugins/test_importadded.py b/test/plugins/test_importadded.py index 608afb399..5c26fdca4 100644 --- a/test/plugins/test_importadded.py +++ b/test/plugins/test_importadded.py @@ -59,7 +59,7 @@ class ImportAddedTest(PluginMixin, ImportTestCase): self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.IDENT self.importer = self.setup_importer() - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) def tearDown(self): super().tearDown() diff --git a/test/test_importer.py b/test/test_importer.py index a28b646cf..34dea6df8 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -34,7 +34,7 @@ from mediafile import MediaFile from beets import config, importer, logging, util from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo -from beets.importer import albums_in_dir +from beets.importer.tasks import albums_in_dir from beets.test import _common from beets.test.helper import ( NEEDS_REFLINK, @@ -324,52 +324,52 @@ class ImportSingletonTest(ImportTestCase): def test_apply_asis_adds_track(self): assert self.lib.items().get() is None - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.items().get().title == "Tag Track 1" def test_apply_asis_does_not_add_album(self): assert self.lib.albums().get() is None - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get() is None def test_apply_asis_adds_singleton_path(self): self.assert_lib_dir_empty() - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() self.assert_file_in_lib(b"singletons", b"Tag Track 1.mp3") def test_apply_candidate_adds_track(self): assert self.lib.items().get() is None - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().title == "Applied Track 1" def test_apply_candidate_does_not_add_album(self): - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.albums().get() is None def test_apply_candidate_adds_singleton_path(self): self.assert_lib_dir_empty() - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() self.assert_file_in_lib(b"singletons", b"Applied Track 1.mp3") def test_skip_does_not_add_first_track(self): - self.importer.add_choice(importer.action.SKIP) + self.importer.add_choice(importer.Action.SKIP) self.importer.run() assert self.lib.items().get() is None def test_skip_adds_other_tracks(self): self.prepare_album_for_import(2) - self.importer.add_choice(importer.action.SKIP) - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.SKIP) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert len(self.lib.items()) == 1 @@ -385,8 +385,8 @@ class ImportSingletonTest(ImportTestCase): self.setup_importer() self.importer.paths = import_files - self.importer.add_choice(importer.action.ASIS) - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert len(self.lib.items()) == 2 @@ -406,7 +406,7 @@ class ImportSingletonTest(ImportTestCase): # As-is item import. assert self.lib.albums().get() is None - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() for item in self.lib.items(): @@ -421,7 +421,7 @@ class ImportSingletonTest(ImportTestCase): # Autotagged. assert self.lib.albums().get() is None self.importer.clear_choices() - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() for item in self.lib.items(): @@ -449,41 +449,41 @@ class ImportTest(ImportTestCase): def test_apply_asis_adds_album(self): assert self.lib.albums().get() is None - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().album == "Tag Album" def test_apply_asis_adds_tracks(self): assert self.lib.items().get() is None - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.items().get().title == "Tag Track 1" def test_apply_asis_adds_album_path(self): self.assert_lib_dir_empty() - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() self.assert_file_in_lib(b"Tag Artist", b"Tag Album", b"Tag Track 1.mp3") def test_apply_candidate_adds_album(self): assert self.lib.albums().get() is None - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.albums().get().album == "Applied Album" def test_apply_candidate_adds_tracks(self): assert self.lib.items().get() is None - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().title == "Applied Track 1" def test_apply_candidate_adds_album_path(self): self.assert_lib_dir_empty() - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() self.assert_file_in_lib( b"Applied Artist", b"Applied Album", b"Applied Track 1.mp3" @@ -496,14 +496,14 @@ class ImportTest(ImportTestCase): mediafile.genre = "Tag Genre" mediafile.save() - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().genre == "" def test_apply_from_scratch_keeps_format(self): config["import"]["from_scratch"] = True - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().format == "MP3" @@ -511,7 +511,7 @@ class ImportTest(ImportTestCase): config["import"]["from_scratch"] = True bitrate = 80000 - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().bitrate == bitrate @@ -521,7 +521,7 @@ class ImportTest(ImportTestCase): import_file = os.path.join(self.import_dir, b"album", b"track_1.mp3") self.assertExists(import_file) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() self.assertNotExists(import_file) @@ -531,26 +531,26 @@ class ImportTest(ImportTestCase): import_file = os.path.join(self.import_dir, b"album", b"track_1.mp3") self.assertExists(import_file) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() self.assertNotExists(import_file) def test_skip_does_not_add_track(self): - self.importer.add_choice(importer.action.SKIP) + self.importer.add_choice(importer.Action.SKIP) self.importer.run() assert self.lib.items().get() is None def test_skip_non_album_dirs(self): self.assertIsDir(os.path.join(self.import_dir, b"album")) self.touch(b"cruft", dir=self.import_dir) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert len(self.lib.albums()) == 1 def test_unmatched_tracks_not_added(self): self.prepare_album_for_import(2) self.matcher.matching = self.matcher.MISSING - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert len(self.lib.items()) == 1 @@ -577,7 +577,7 @@ class ImportTest(ImportTestCase): def test_asis_no_data_source(self): assert self.lib.items().get() is None - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() with pytest.raises(AttributeError): @@ -599,7 +599,7 @@ class ImportTest(ImportTestCase): # As-is album import. assert self.lib.albums().get() is None - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() for album in self.lib.albums(): @@ -621,7 +621,7 @@ class ImportTest(ImportTestCase): # Autotagged. assert self.lib.albums().get() is None self.importer.clear_choices() - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() for album in self.lib.albums(): @@ -656,9 +656,9 @@ class ImportTracksTest(ImportTestCase): assert self.lib.items().get() is None assert self.lib.albums().get() is None - self.importer.add_choice(importer.action.TRACKS) - self.importer.add_choice(importer.action.APPLY) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.TRACKS) + self.importer.add_choice(importer.Action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().title == "Applied Track 1" assert self.lib.albums().get() is None @@ -666,9 +666,9 @@ class ImportTracksTest(ImportTestCase): def test_apply_tracks_adds_singleton_path(self): self.assert_lib_dir_empty() - self.importer.add_choice(importer.action.TRACKS) - self.importer.add_choice(importer.action.APPLY) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.TRACKS) + self.importer.add_choice(importer.Action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() self.assert_file_in_lib(b"singletons", b"Applied Track 1.mp3") @@ -687,7 +687,7 @@ class ImportCompilationTest(ImportTestCase): self.matcher.restore() def test_asis_homogenous_sets_albumartist(self): - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Tag Artist" for item in self.lib.items(): @@ -699,7 +699,7 @@ class ImportCompilationTest(ImportTestCase): self.import_media[1].artist = "Another Artist" self.import_media[1].save() - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Various Artists" for item in self.lib.items(): @@ -711,7 +711,7 @@ class ImportCompilationTest(ImportTestCase): self.import_media[1].artist = "Another Artist" self.import_media[1].save() - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() for item in self.lib.items(): assert item.comp @@ -722,7 +722,7 @@ class ImportCompilationTest(ImportTestCase): self.import_media[1].artist = "Other Artist" self.import_media[1].save() - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Other Artist" for item in self.lib.items(): @@ -736,7 +736,7 @@ class ImportCompilationTest(ImportTestCase): mediafile.mb_albumartistid = "Album Artist ID" mediafile.save() - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Album Artist" assert self.lib.albums().get().mb_albumartistid == "Album Artist ID" @@ -755,7 +755,7 @@ class ImportCompilationTest(ImportTestCase): mediafile.mb_albumartistid = "Album Artist ID" mediafile.save() - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Album Artist" assert self.lib.albums().get().albumartists == [ @@ -802,7 +802,7 @@ class ImportExistingTest(ImportTestCase): self.importer.run() assert len(self.lib.items()) == 1 - self.reimporter.add_choice(importer.action.APPLY) + self.reimporter.add_choice(importer.Action.APPLY) self.reimporter.run() assert len(self.lib.items()) == 1 @@ -810,18 +810,18 @@ class ImportExistingTest(ImportTestCase): self.importer.run() assert len(self.lib.albums()) == 1 - self.reimporter.add_choice(importer.action.APPLY) + self.reimporter.add_choice(importer.Action.APPLY) self.reimporter.run() assert len(self.lib.albums()) == 1 def test_does_not_duplicate_singleton_track(self): - self.importer.add_choice(importer.action.TRACKS) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.TRACKS) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert len(self.lib.items()) == 1 - self.reimporter.add_choice(importer.action.TRACKS) - self.reimporter.add_choice(importer.action.APPLY) + self.reimporter.add_choice(importer.Action.TRACKS) + self.reimporter.add_choice(importer.Action.APPLY) self.reimporter.run() assert len(self.lib.items()) == 1 @@ -831,7 +831,7 @@ class ImportExistingTest(ImportTestCase): medium.title = "New Title" medium.save() - self.reimporter.add_choice(importer.action.ASIS) + self.reimporter.add_choice(importer.Action.ASIS) self.reimporter.run() assert self.lib.items().get().title == "New Title" @@ -846,7 +846,7 @@ class ImportExistingTest(ImportTestCase): ) self.assert_file_in_lib(old_path) - self.reimporter.add_choice(importer.action.ASIS) + self.reimporter.add_choice(importer.Action.ASIS) self.reimporter.run() self.assert_file_in_lib( b"Applied Artist", b"Applied Album", b"New Title.mp3" @@ -865,7 +865,7 @@ class ImportExistingTest(ImportTestCase): self.assert_file_in_lib(old_path) config["import"]["copy"] = False - self.reimporter.add_choice(importer.action.ASIS) + self.reimporter.add_choice(importer.Action.ASIS) self.reimporter.run() self.assert_file_not_in_lib( b"Applied Artist", b"Applied Album", b"New Title.mp3" @@ -880,7 +880,7 @@ class ImportExistingTest(ImportTestCase): ) self.reimporter = self.setup_importer() - self.reimporter.add_choice(importer.action.APPLY) + self.reimporter.add_choice(importer.Action.APPLY) self.reimporter.run() new_path = os.path.join( b"Applied Artist", b"Applied Album", b"Applied Track 1.mp3" @@ -899,7 +899,7 @@ class ImportExistingTest(ImportTestCase): ) self.reimporter = self.setup_importer(move=True) - self.reimporter.add_choice(importer.action.APPLY) + self.reimporter.add_choice(importer.Action.APPLY) self.reimporter.run() self.assertNotExists(self.import_media[0].path) @@ -913,9 +913,9 @@ class GroupAlbumsImportTest(ImportTestCase): self.setup_importer() # Split tracks into two albums and use both as-is - self.importer.add_choice(importer.action.ALBUMS) - self.importer.add_choice(importer.action.ASIS) - self.importer.add_choice(importer.action.ASIS) + self.importer.add_choice(importer.Action.ALBUMS) + self.importer.add_choice(importer.Action.ASIS) + self.importer.add_choice(importer.Action.ASIS) def tearDown(self): super().tearDown() @@ -972,7 +972,7 @@ class GlobalGroupAlbumsImportTest(GroupAlbumsImportTest): def setUp(self): super().setUp() self.importer.clear_choices() - self.importer.default_choice = importer.action.ASIS + self.importer.default_choice = importer.Action.ASIS config["import"]["group_albums"] = True @@ -1019,7 +1019,7 @@ class InferAlbumDataTest(BeetsTestCase): ) def test_asis_homogenous_single_artist(self): - self.task.set_choice(importer.action.ASIS) + self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() assert not self.items[0].comp assert self.items[0].albumartist == self.items[2].artist @@ -1027,7 +1027,7 @@ class InferAlbumDataTest(BeetsTestCase): def test_asis_heterogenous_va(self): self.items[0].artist = "another artist" self.items[1].artist = "some other artist" - self.task.set_choice(importer.action.ASIS) + self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() @@ -1037,7 +1037,7 @@ class InferAlbumDataTest(BeetsTestCase): def test_asis_comp_applied_to_all_items(self): self.items[0].artist = "another artist" self.items[1].artist = "some other artist" - self.task.set_choice(importer.action.ASIS) + self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() @@ -1047,7 +1047,7 @@ class InferAlbumDataTest(BeetsTestCase): def test_asis_majority_artist_single_artist(self): self.items[0].artist = "another artist" - self.task.set_choice(importer.action.ASIS) + self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() @@ -1060,7 +1060,7 @@ class InferAlbumDataTest(BeetsTestCase): for item in self.items: item.albumartist = "some album artist" item.mb_albumartistid = "some album artist id" - self.task.set_choice(importer.action.ASIS) + self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() @@ -1089,7 +1089,7 @@ class InferAlbumDataTest(BeetsTestCase): def test_small_single_artist_album(self): self.items = [self.items[0]] self.task.items = self.items - self.task.set_choice(importer.action.ASIS) + self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() assert not self.items[0].comp @@ -1599,7 +1599,7 @@ class ReimportTest(ImportTestCase): def _setup_session(self, singletons=False): self.setup_importer(import_dir=self.libdir, singletons=singletons) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) def _album(self): return self.lib.albums().get() @@ -1845,7 +1845,7 @@ class ImportMusicBrainzIdTest(ImportTestCase): search_ids=[self.MB_RELEASE_PREFIX + self.ID_RELEASE_0] ) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.albums().get().album == "VALID_RELEASE_0" @@ -1858,7 +1858,7 @@ class ImportMusicBrainzIdTest(ImportTestCase): ) self.importer.add_choice(2) # Pick the 2nd best match (release 1). - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.albums().get().album == "VALID_RELEASE_1" @@ -1867,7 +1867,7 @@ class ImportMusicBrainzIdTest(ImportTestCase): search_ids=[self.MB_RECORDING_PREFIX + self.ID_RECORDING_0] ) - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().title == "VALID_RECORDING_0" @@ -1880,7 +1880,7 @@ class ImportMusicBrainzIdTest(ImportTestCase): ) self.importer.add_choice(2) # Pick the 2nd best match (recording 1). - self.importer.add_choice(importer.action.APPLY) + self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().title == "VALID_RECORDING_1" diff --git a/test/test_plugins.py b/test/test_plugins.py index d273de698..25f1f3c66 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -24,10 +24,10 @@ from mediafile import MediaFile from beets import config, plugins, ui from beets.dbcore import types from beets.importer import ( + Action, ArchiveImportTask, SentinelImportTask, SingletonImportTask, - action, ) from beets.library import Item from beets.plugins import MetadataSourcePlugin @@ -389,7 +389,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "aBort", ) + ("Foo", "baR") - self.importer.add_choice(action.SKIP) + self.importer.add_choice(Action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with( opts, default="a", require=ANY @@ -424,7 +424,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): ) + ("Foo", "baR") config["import"]["singletons"] = True - self.importer.add_choice(action.SKIP) + self.importer.add_choice(Action.SKIP) self.importer.run() self.mock_input_options.assert_called_with( opts, default="a", require=ANY @@ -461,7 +461,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "enter Id", "aBort", ) + ("baZ",) - self.importer.add_choice(action.SKIP) + self.importer.add_choice(Action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with( opts, default="a", require=ANY @@ -523,7 +523,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): return [ui.commands.PromptChoice("f", "Foo", self.foo)] def foo(self, session, task): - return action.SKIP + return Action.SKIP self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') From ce61ff0006cf0d8e5a4c5c613e212ad6af04eaf7 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 13 May 2025 13:05:07 +0200 Subject: [PATCH 3/4] Removed pathbytes (lint error) --- beets/importer/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 4ca5cf89f..d2f638c55 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -31,7 +31,7 @@ from beets import autotag, config, dbcore, library, plugins, util from .state import ImportState if TYPE_CHECKING: - from .session import ImportSession, PathBytes + from .session import ImportSession # Global logger. log = logging.getLogger("beets") From 7d96334924b8a333bed16f8a5e7653ebffb6f074 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 17 May 2025 13:13:27 +0200 Subject: [PATCH 4/4] Added function move to git ignore --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 8848bf384..4703203ba 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -47,3 +47,5 @@ f36bc497c8c8f89004f3f6879908d3f0b25123e1 # 2025 # Fix formatting c490ac5810b70f3cf5fd8649669838e8fdb19f4d +# Importer restructure +9147577b2b19f43ca827e9650261a86fb0450cef \ No newline at end of file