From 2584a2e5100beb472b88539a6cda29e3f9656068 Mon Sep 17 00:00:00 2001 From: pSpitzner Date: Mon, 7 Oct 2024 18:26:45 +0200 Subject: [PATCH] merged auto-import --- CHANGELOG.md | 13 ++++ README.md | 2 +- backend/beets_flask/inbox.py | 4 +- backend/beets_flask/invoker.py | 69 ++++++++++++++++--- backend/beets_flask/models/tag.py | 10 ++- .../src/components/common/hooks/useSearch.tsx | 2 +- frontend/src/components/library/_query.ts | 8 +-- .../src/components/tags/tagView.module.scss | 4 +- frontend/src/routes/library/search.tsx | 2 +- 9 files changed, 91 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e35f28e..806a94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Upcoming] + +### Fixed + +- Renamed `kind` to `type` in search frontend code to be consistent with backend. + Using kind for tags (preview, import, auto), and types for search (album, track). + +### Added + +- Auto-import: automatically import folders that are added to the inbox if the match is good enough. + After a preview, import will start if the match quality is above the configured. + Enable via the config.yaml, set the `autotag` field of a configred inbox folders to `"auto"`. + ## [0.0.4] - 24-10-04 ### Fixed diff --git a/README.md b/README.md index 4b5b336..1bcf265 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ gui: Inbox: name: "Inbox" path: "/music/inbox" - autotag: no # no | "preview" | "import" + autotag: no # no | "preview" | "import" | "auto" ``` ## Terminal diff --git a/backend/beets_flask/inbox.py b/backend/beets_flask/inbox.py index 62fe474..bbb127c 100644 --- a/backend/beets_flask/inbox.py +++ b/backend/beets_flask/inbox.py @@ -145,7 +145,7 @@ def retag_folder( # Args path: str, full path to the folder - kind: str, 'preview' or 'import' + kind: str or None (default). If None, the configured autotag kind from the inbox this folder is in will be used. with_status: None or list of strings. If None (default), always retag, no matter what. If list of strings, only retag if the tag for the folder matches one of the supplied statuses. """ @@ -181,7 +181,7 @@ def retag_inbox( # Args path: str, full path to the inbox - kind: str, 'preview' or 'import' + kind: str or None (default). If None, the configured autotag kind from the inbox in will be used. with_status: None or list of strings. If None (default), always retag, no matter what. If list of strings, only retag if the tag for the folder matches one of the supplied statuses. """ diff --git a/backend/beets_flask/invoker.py b/backend/beets_flask/invoker.py index 9ec4eb9..5371c83 100644 --- a/backend/beets_flask/invoker.py +++ b/backend/beets_flask/invoker.py @@ -18,6 +18,7 @@ db_session, Session, ) +from beets_flask.config import config from beets_flask.routes.errors import InvalidUsage from beets_flask.routes.sse import update_client_view from sqlalchemy import delete @@ -56,6 +57,11 @@ def enqueue(id: str, session: Session | None = None): preview_queue.enqueue(runPreview, id) elif tag.kind == "import": import_queue.enqueue(runImport, id) + elif tag.kind == "auto": + preview_job = preview_queue.enqueue(runPreview, id) + import_queue.enqueue( + AutoImport, id, depends_on=preview_job + ) else: raise ValueError(f"Unknown kind {tag.kind}") @@ -89,14 +95,12 @@ def runPreview(tagId: str, callback_url: str | None = None) -> str | None: with db_session() as session: log.debug(f"Preview task on {tagId}") bt = Tag.get_by(Tag.id == tagId, session=session) - if bt is None: raise InvalidUsage(f"Tag {tagId} not found in database") - session.merge(bt) - bt.kind = "preview" bt.status = "tagging" bt.updated_at = datetime.now() + session.merge(bt) session.commit() update_client_view( type="tag", @@ -172,17 +176,15 @@ def runImport( callback_url (str | None, optional): called on status change. Defaults to None. Returns: - The folder all imported files share in common. Empty list if nothing was imported. + List of track paths after import, as strings. (empty if nothing imported) + """ with db_session() as session: log.debug(f"Import task on {tagId}") - bt = Tag.get_by(Tag.id == tagId) - if bt is None: raise InvalidUsage(f"Tag {tagId} not found in database") - bt.kind = "import" bt.status = "importing" bt.updated_at = datetime.now() session.merge(bt) @@ -261,12 +263,58 @@ def runImport( return bt.track_paths_after +@job(timeout=600, queue=import_queue) +def AutoImport(tagId: str, callback_url: str | None = None) -> list[str] | None: + """ + Automatically run an import session for a tag after a preview has been generated. + We check preview quality and user settings before running the import. + + Args: + tagId (str): The ID of the tag to be imported. + callback_url (str | None, optional): URL to call on status change. Defaults to None. + Returns: + List of track paths after import, as strings. (empty if nothing imported) + """ + with db_session() as session: + log.debug(f"AutoImport task on {tagId}") + bt = Tag.get_by(Tag.id == tagId) + if bt is None: + raise InvalidUsage(f"Tag {tagId} not found in database") + + if bt.status != "tagged": + log.info( + f"Skipping auto import, we only import after a successfull preview (status 'tagged' not '{bt.status}'). {bt.album_folder=}" + ) + # we should consider to do an explicit duplicate check here + # because two previews yielding the same match might finish at the same time + return [] + + if bt.kind != "auto": + log.debug( + f"For auto importing, tag kind needs to be 'auto' not '{bt.kind}'. {bt.album_folder=}" + ) + return [] + + if config["import"]["timid"].get(bool): + log.info( + "Auto importing is disabled if `import:timid=yes` is set in config" + ) + return [] + + strong_rec_thresh = config["match"]["strong_rec_thresh"].get(float) + if bt.distance is None or bt.distance > strong_rec_thresh: # type: ignore + log.info( + f"Skipping auto import of {bt.album_folder=} with {bt.distance=} > {strong_rec_thresh=}" + ) + return [] + + return runImport(tagId, callback_url=callback_url) + + def _get_or_gen_match_url(tagId, session: Session) -> str | None: bt = Tag.get_by(Tag.id == tagId, session=session) - if bt is None: raise InvalidUsage(f"Tag {tagId} not found in database") - if bt.match_url is not None: log.debug(f"Match url already exists for {bt.album_folder}: {bt.match_url}") return bt.match_url @@ -280,6 +328,7 @@ def _get_or_gen_match_url(tagId, session: Session) -> str | None: ) bs = PreviewSession(path=bt.album_folder) bs.run_and_capture_output() + return bs.match_url @@ -290,14 +339,12 @@ def tag_status( Get the status of a tag by its id or path. Returns "untagged" if the tag does not exist or the path was not tagged yet. """ - with db_session(session) as s: bt = None if id is not None: bt = Tag.get_by(Tag.id == id, session=s) elif path is not None: bt = Tag.get_by(Tag.album_folder == path, session=s) - if bt is None or bt.status is None: return "untagged" diff --git a/backend/beets_flask/models/tag.py b/backend/beets_flask/models/tag.py index b99277a..90482d4 100644 --- a/backend/beets_flask/models/tag.py +++ b/backend/beets_flask/models/tag.py @@ -28,15 +28,23 @@ class Tag(Base): status: Mapped[str] kind: Mapped[str] + kind: Mapped[str] _valid_statuses = [ "dummy", "pending", "tagging", + "tagged", + "importing", + "imported", "failed", "unmatched", "duplicate", ] - _valid_kind = ["preview", "import"] + _valid_kinds = [ + "preview", + "import", + "auto", # generates a preview, and depending on user config, imports if good match + ] # we could alternatively handle this by allowing multiple tag groups archived: Mapped[bool] = mapped_column(default=False) diff --git a/frontend/src/components/common/hooks/useSearch.tsx b/frontend/src/components/common/hooks/useSearch.tsx index a586999..cf6e3ad 100644 --- a/frontend/src/components/common/hooks/useSearch.tsx +++ b/frontend/src/components/common/hooks/useSearch.tsx @@ -73,7 +73,7 @@ export function SearchContextProvider({ children }: { children: React.ReactNode } = useQuery({ ...searchQueryOptions({ searchFor: sentQuery, - kind: type, + type, }), enabled: sentQuery.length > 0, }); diff --git a/frontend/src/components/library/_query.ts b/frontend/src/components/library/_query.ts index 9c86c73..1dd0d8c 100644 --- a/frontend/src/components/library/_query.ts +++ b/frontend/src/components/library/_query.ts @@ -288,18 +288,18 @@ export interface SearchResult { export const searchQueryOptions = ({ searchFor, - kind, + type, }: { searchFor: string; - kind: "item" | "album"; + type: "item" | "album"; }) => queryOptions({ - queryKey: ["search", kind, searchFor], + queryKey: ["search", type, searchFor], queryFn: async ({ signal }) => { const expand = false; const minimal = true; const url = _url_parse_minimal_expand( - `/library/${kind}/query/${encodeURIComponent(searchFor)}`, + `/library/${type}/query/${encodeURIComponent(searchFor)}`, { expand, minimal, diff --git a/frontend/src/components/tags/tagView.module.scss b/frontend/src/components/tags/tagView.module.scss index 4acc527..9344563 100644 --- a/frontend/src/components/tags/tagView.module.scss +++ b/frontend/src/components/tags/tagView.module.scss @@ -24,7 +24,7 @@ // to get the color codes working, you will need the ansi css classes form our main.css .tagPreview { white-space: pre-wrap; - overflow-wrap: break-workd; // this allows wrapping e.g. spotify urls + overflow-wrap: break-word; // this allows wrapping e.g. spotify urls font-family: monospace; font-size: 0.7rem; } @@ -35,7 +35,7 @@ display: inline-flex; flex-direction: row; align-items: flex-start; - justify-content: begin; + justify-content: start; width: auto; } .albumIcons { diff --git a/frontend/src/routes/library/search.tsx b/frontend/src/routes/library/search.tsx index ec4f789..b59dcd0 100644 --- a/frontend/src/routes/library/search.tsx +++ b/frontend/src/routes/library/search.tsx @@ -96,7 +96,7 @@ function SearchBar() { value={type} exclusive onChange={handleTypeChange} - aria-label="Search Kind" + aria-label="Search Type" > Item Album