Skip to content

Commit

Permalink
merged auto-import
Browse files Browse the repository at this point in the history
  • Loading branch information
pSpitzner committed Oct 7, 2024
1 parent 66d13ff commit 2584a2e
Show file tree
Hide file tree
Showing 9 changed files with 91 additions and 23 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ gui:
Inbox:
name: "Inbox"
path: "/music/inbox"
autotag: no # no | "preview" | "import"
autotag: no # no | "preview" | "import" | "auto"
```

## Terminal
Expand Down
4 changes: 2 additions & 2 deletions backend/beets_flask/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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.
"""

Expand Down
69 changes: 58 additions & 11 deletions backend/beets_flask/invoker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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"

Expand Down
10 changes: 9 additions & 1 deletion backend/beets_flask/models/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/hooks/useSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function SearchContextProvider({ children }: { children: React.ReactNode
} = useQuery({
...searchQueryOptions<MinimalItem | MinimalAlbum>({
searchFor: sentQuery,
kind: type,
type,
}),
enabled: sentQuery.length > 0,
});
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/library/_query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,18 +288,18 @@ export interface SearchResult<T extends MinimalItem | MinimalAlbum> {

export const searchQueryOptions = <T extends MinimalItem | MinimalAlbum>({
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,
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/tags/tagView.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -35,7 +35,7 @@
display: inline-flex;
flex-direction: row;
align-items: flex-start;
justify-content: begin;
justify-content: start;
width: auto;
}
.albumIcons {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/routes/library/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function SearchBar() {
value={type}
exclusive
onChange={handleTypeChange}
aria-label="Search Kind"
aria-label="Search Type"
>
<ToggleButton value="item">Item</ToggleButton>
<ToggleButton value="album">Album</ToggleButton>
Expand Down

0 comments on commit 2584a2e

Please sign in to comment.