Skip to content

Commit b8d118a

Browse files
author
pSpitzner
committed
added an as_is import option, e.g. for bootlegs that
have no online metadata, but manually cleaned data in tags
1 parent 377a218 commit b8d118a

File tree

11 files changed

+231
-34
lines changed

11 files changed

+231
-34
lines changed

backend/beets_flask/beets_sessions.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
import sys
5+
import taglib
56

67
from beets import importer, plugins
78
from beets.autotag import AlbumMatch, TrackMatch
@@ -216,7 +217,7 @@ def choose_match(self, task: importer.ImportTask):
216217
duplicates = task.find_duplicates(self.lib)
217218
if duplicates:
218219
print_(
219-
f'\nThis {"album" if task.is_album else "item"} is already in the library!'
220+
f"\nThis {'album' if task.is_album else 'item'} is already in the library!"
220221
)
221222
self.status = "duplicate"
222223
for duplicate in duplicates:
@@ -246,13 +247,14 @@ def __init__(
246247
tag_id: str | None = None,
247248
):
248249
# make sure to start with clean slate
250+
# apply all tweaks to global config before initializing the basesession!
249251
set_config_defaults()
250252
config["import"]["search_ids"].set([match_url])
251253

252254
if tag_id is not None:
253255
config["import"]["set_fields"]["gui_import_id"] = tag_id
254256

255-
# this also merged config_overlay into the global config
257+
# this also merges config_overlay into the global config
256258
super(MatchedImportSession, self).__init__(path, config_overlay)
257259

258260
# inconvenient: beets does not invoke a sessions resolve_duplicates() method if config["import"]["duplicate_action"] is set meaningfully (anything except 'ask'?).
@@ -315,7 +317,7 @@ def resolve_duplicate(self, task: importer.ImportTask, found_duplicates):
315317
config or config_overlay.
316318
"""
317319
print_(
318-
f'\nThis {"album" if task.is_album else "item"} is already in the library!'
320+
f"\nThis {'album' if task.is_album else 'item'} is already in the library!"
319321
)
320322
for duplicate in found_duplicates:
321323
old_dir = colorize("import_path", duplicate.item_dir().decode("utf-8"))
@@ -394,6 +396,92 @@ def track_paths_after_import(self) -> list[str]:
394396
return []
395397

396398

399+
class AsIsImportSession(MatchedImportSession):
400+
"""
401+
Import session to import without modifyin metadata.
402+
403+
Essentially `beet import --group-albums -A`, ideal for bootlegs and
404+
just getting a folder into your library where you are sure the metadata is correct.
405+
406+
This is a quick-and-dirty version before we rework with the newer sessions that
407+
are used for interactive imports.
408+
I derived this from MatchedImportSession, because it has some bits we need,
409+
but yeah, we should rethink this.
410+
411+
"""
412+
413+
def __init__(
414+
self,
415+
path: str,
416+
tag_id: str | None = None,
417+
):
418+
419+
config_overlay = {
420+
"import": {
421+
"group_albums": True,
422+
"autotag": False,
423+
"search_ids": [],
424+
}
425+
}
426+
427+
super().__init__(
428+
path=path,
429+
match_url=None,
430+
config_overlay=config_overlay,
431+
tag_id=tag_id,
432+
)
433+
434+
self.match_url = None
435+
self.match_album = None
436+
self.match_artist = None
437+
self.match_dist = None
438+
439+
# getting track_paths_after_import does not work, because
440+
# afaik the import_task only gives paths when we _copied_
441+
# so, because with as_is data its more likely that people
442+
# _move_ the files, we keep track of the paths before import
443+
self.track_paths_before_import = list(self.track_paths_before_import)
444+
self.taglib_tags = [
445+
taglib.File(fp).tags for fp in self.track_paths_before_import
446+
]
447+
448+
def debug(self):
449+
return config
450+
451+
def choose_match(self, task: importer.ImportTask):
452+
raise NotImplementedError(
453+
"AsIsImportSession should not need to choose matches."
454+
)
455+
456+
def run_and_capture_output(self) -> tuple[str, str]:
457+
err, out = super().run_and_capture_output()
458+
459+
# add some basic info of the added items to the preview
460+
# I do not know how to get a quick dump of file metadata using beets,
461+
# so we simply use taglib for now
462+
self.preview += "\n\n"
463+
if len(self.track_paths_before_import) == 0:
464+
self.preview += "No files to import."
465+
else:
466+
self.preview += f"Metadata in {len(self.track_paths_before_import)} file(s)\n\n"
467+
for fp in self.track_paths_before_import:
468+
self.preview += f" {fp}\n"
469+
tags = taglib.File(fp).tags
470+
for tag in tags.keys():
471+
self.preview += f" {tag.lower()}: {tags[tag][0]}\n"
472+
self.preview += "--------------------------------------------\n"
473+
474+
475+
self.preview += "\n\n (This is no guarantee the import worked)"
476+
# ... and this is a major TODO. How to get track paths _after_ an import,
477+
# even when using as is?
478+
self.preview = self.preview.strip()
479+
480+
return out, err
481+
482+
483+
484+
397485
def cli_command(beets_args: list[str], key: str = "s") -> tuple[str, str]:
398486
"""Simulate a cli interaction.
399487

backend/beets_flask/config/beets_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def reset(self):
9292
if ib_folder is None:
9393
ib_folder = os.path.expanduser("~/.config/beets-flask")
9494
ib_config_path = os.path.join(ib_folder, "config.yaml")
95+
print(f"{ib_config_path=}")
9596

9697
# Check if the user config exists
9798
# if not, copy the example config to the user config location

backend/beets_flask/config/config_bf_example.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,15 @@ gui:
3030
Lorem:
3131
name: "Awesome Inbox"
3232
path: "/music/inbox"
33-
autotag: no # no | "preview" | "import" | "auto"
33+
autotag: no # no | "preview" | "import" | "import_as_is" | "auto"
3434
# autotag controls what should happen with newly added album folders.
3535
# no: do nothing
3636
# "preview": fetch data but do not import (recommended)
3737
# "import": fetch data and always import (not recommended)
38+
# "import_as_is": import as is, do not fetch data, but groups albums
39+
# useful for bootlegs, where you manually clean metadata
40+
# and beets wont be able to find any online
41+
# same as `beet import --group-albums -A`
3842
# "auto": fetch data and import if a good match is found (recommended for established libraries,
3943
# but not for a big initial import when you start with beets for the first time)
4044
Ipsum:

backend/beets_flask/database/models/tag.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Tag(Base):
4141
_valid_kinds = [
4242
"preview",
4343
"import",
44+
"import_as_is",
4445
"auto", # generates a preview, and depending on user config, imports if good match
4546
]
4647

backend/beets_flask/invoker.py

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
from rq.decorators import job
1616
from sqlalchemy import delete
1717

18-
from beets_flask.beets_sessions import MatchedImportSession, PreviewSession, colorize
18+
from beets_flask.beets_sessions import (
19+
AsIsImportSession,
20+
MatchedImportSession,
21+
PreviewSession,
22+
colorize,
23+
)
1924
from beets_flask.config import config
2025
from beets_flask.database import Tag, db_session
2126
from beets_flask.redis import import_queue, preview_queue, redis_conn, tag_queue
@@ -57,6 +62,8 @@ def enqueue(id: str, session: Session | None = None):
5762
preview_queue.enqueue(runPreview, id)
5863
elif tag.kind == "import":
5964
import_queue.enqueue(runImport, id)
65+
elif tag.kind == "import_as_is":
66+
import_queue.enqueue(runImport, id, as_is=True)
6067
elif tag.kind == "auto":
6168
preview_job = preview_queue.enqueue(runPreview, id)
6269
import_queue.enqueue(AutoImport, id, depends_on=preview_job)
@@ -180,6 +187,7 @@ def runPreview(
180187
def runImport(
181188
tagId: str,
182189
match_url: str | None = None,
190+
as_is: bool = False,
183191
callback_url: str | None = None,
184192
) -> list[str]:
185193
"""Start Import session for a tag.
@@ -190,6 +198,14 @@ def runImport(
190198
191199
Parameters
192200
----------
201+
tagId:str
202+
The ID of the tag to be imported, used to load info from db.
203+
as_is: bool, optional
204+
If true, we import as-is, **grouping albums** and ignoring the match_url.
205+
Effectively like `beet import --group-albums -A`.
206+
Default False.
207+
match_url: str, optional
208+
The match url to use for import, if we have it.
193209
callback_url: str, optional
194210
Called when the import status changes.
195211
@@ -199,7 +215,7 @@ def runImport(
199215
200216
"""
201217
with db_session() as session:
202-
log.info(f"Import task on {tagId}")
218+
log.info(f"Import task: {as_is=} {tagId=}")
203219
bt = Tag.get_by(Tag.id == tagId, session=session)
204220
if bt is None:
205221
raise InvalidUsage(f"Tag {tagId} not found in database")
@@ -220,22 +236,31 @@ def runImport(
220236
message="Importing started",
221237
)
222238

223-
match_url = match_url or _get_or_gen_match_url(tagId, session)
224-
if not match_url:
225-
if callback_url:
226-
requests.post(
227-
callback_url,
228-
json={
229-
"status": "beets import failed: no match url found.",
230-
"tag": bt.to_dict(),
231-
},
232-
)
233-
return []
239+
if not as_is:
240+
match_url = match_url or _get_or_gen_match_url(tagId, session)
241+
if not match_url:
242+
if callback_url:
243+
requests.post(
244+
callback_url,
245+
json={
246+
"status": "beets import failed: no match url found.",
247+
"tag": bt.to_dict(),
248+
},
249+
)
250+
return []
234251

235252
try:
236-
bs = MatchedImportSession(
237-
path=bt.album_folder, match_url=match_url, tag_id=bt.id
238-
)
253+
if as_is:
254+
bs = AsIsImportSession(
255+
path=bt.album_folder,
256+
tag_id=bt.id,
257+
)
258+
else:
259+
bs = MatchedImportSession(
260+
path=bt.album_folder,
261+
match_url=match_url,
262+
tag_id=bt.id,
263+
)
239264
bs.run_and_capture_output()
240265

241266
bt.preview = bs.preview
@@ -244,7 +269,7 @@ def runImport(
244269
bt.match_album = bs.match_album
245270
bt.match_artist = bs.match_artist
246271
bt.num_tracks = bs.match_num_tracks
247-
bt.track_paths_after = bs.track_paths_after_import
272+
bt.track_paths_after = bs.track_paths_before_import
248273
bt.status = "imported" if bs.status == "ok" else bs.status
249274
log.debug(f"tried import {bt.status=}")
250275
except Exception as e:

backend/beets_flask/utility.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from math import floor
66

77
from flask import current_app
8-
from rq import Worker
98

109
from .logger import log
1110

@@ -25,6 +24,7 @@ def wrapper(*args, **kwargs):
2524

2625
def get_running_jobs():
2726
running_jobs = []
27+
from rq import Worker
2828
workers = Worker.all()
2929
for worker in workers:
3030
job = worker.get_current_job()

backend/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ dependencies = [
3131
"eventlet>=0.32.0",
3232
# beets plugin dependencies
3333
"pylast>=5.2.0",
34+
# taglib for as-is imports, temporary
35+
"pytaglib>=3.0.0",
3436
]
3537

3638
[project.optional-dependencies]

frontend/src/components/common/contextMenu.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const defaultActions = [
8383
<RetagAction key="action-retag" />,
8484
<ImportAction key="action-import" />,
8585
<InteractiveImportAction key="action-interactive-import" />,
86+
<ImportAsIsAction key="action-import-as-is" />,
8687
<TerminalImportAction key="action-terminal-import" />,
8788
<UndoImportAction key="action-undo-import" />,
8889
<CopyPathAction key="action-copy-path" />,
@@ -552,6 +553,46 @@ export function ImportAction(props: Partial<ActionProps>) {
552553
);
553554
}
554555

556+
export function ImportAsIsAction(props: Partial<ActionProps>) {
557+
const { closeMenu } = useContextMenu();
558+
const { getSelected } = useSelection();
559+
const importOptions: UseMutationOptions = {
560+
mutationFn: async () => {
561+
await fetch(`/tag/add`, {
562+
method: "POST",
563+
headers: {
564+
"Content-Type": "application/json",
565+
},
566+
body: JSON.stringify({
567+
folders: getSelected(),
568+
kind: "import_as_is",
569+
}),
570+
});
571+
},
572+
onSuccess: async () => {
573+
closeMenu();
574+
const selected = getSelected();
575+
for (const tagPath of selected) {
576+
await queryClient.setQueryData(["tag", tagPath], (old: TagI) => {
577+
return { ...old, status: "pending" };
578+
});
579+
}
580+
},
581+
onError: (error: Error) => {
582+
console.error(error);
583+
},
584+
};
585+
586+
return (
587+
<ActionWithMutation
588+
{...props}
589+
icon={<ImportAutoSvg />}
590+
text={"Import (as is)"}
591+
mutationOption={importOptions}
592+
/>
593+
);
594+
}
595+
555596
export function DeleteAction(props: Partial<ActionProps>) {
556597
const { closeMenu } = useContextMenu();
557598
const { getSelected } = useSelection();

frontend/src/components/tags/similarityBadge.module.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
border: var(--bs-border-width) solid var(--bs-border-color);
1818
height: 1.1rem;
1919
padding: 0.1rem 0.3rem;
20+
min-width: 2.1rem; // this is roughly 4 chars
2021
-webkit-touch-callout: none;
2122
-webkit-user-select: none;
2223
-khtml-user-select: none;

0 commit comments

Comments
 (0)