beets-unskipper 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- beets_unskipper-1.0.0/.gitignore +9 -0
- beets_unskipper-1.0.0/LICENSE +21 -0
- beets_unskipper-1.0.0/PKG-INFO +99 -0
- beets_unskipper-1.0.0/README.md +73 -0
- beets_unskipper-1.0.0/beetsplug/unskipper/__init__.py +338 -0
- beets_unskipper-1.0.0/beetsplug/unskipper/app.py +690 -0
- beets_unskipper-1.0.0/beetsplug/unskipper/pathremap.py +83 -0
- beets_unskipper-1.0.0/beetsplug/unskipper/scan.py +36 -0
- beets_unskipper-1.0.0/beetsplug/unskipper/sidecar.py +79 -0
- beets_unskipper-1.0.0/beetsplug/unskipper/statefile.py +85 -0
- beets_unskipper-1.0.0/pyproject.toml +44 -0
- beets_unskipper-1.0.0/screenshot.png +0 -0
- beets_unskipper-1.0.0/uv.lock +842 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florent Le Moël
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: beets-unskipper
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Beets plugin to browse and edit the import state file (state.pickle) with a small TUI
|
|
5
|
+
Project-URL: Homepage, https://github.com/FlorentLM/beets-unskipper
|
|
6
|
+
Project-URL: Issues, https://github.com/FlorentLM/beets-unskipper/issues
|
|
7
|
+
Author-email: Florent Le Moël <25004801+FlorentLM@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: beets,beetsplug,import,music,skipped,tui
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Environment :: Console :: Curses
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
23
|
+
Requires-Python: <3.15,>=3.10
|
|
24
|
+
Requires-Dist: beets>=2.11.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# beets-unskipper
|
|
28
|
+
|
|
29
|
+
A [beets](https://beets.io) plugin to browse and edit beets' import state
|
|
30
|
+
file (`state.pickle`) with a small terminal UI.
|
|
31
|
+
|
|
32
|
+
Beets records already-imported directories in `state.pickle`, and then any other import with
|
|
33
|
+
`--incremental` will skip anything listed in there. `unskipper` lets you browse this
|
|
34
|
+
file and remove ("unskip") entries, so beets will reconsider them next time you import.
|
|
35
|
+
|
|
36
|
+
It also keeps a human-readable sidecar file (`<statefile>.unskipper.json`) with additional data
|
|
37
|
+
per item, recorded automatically during import, and that can be used to rebuild a lost
|
|
38
|
+
or corrupted `state.pickle`.
|
|
39
|
+
|
|
40
|
+
<div align="center">
|
|
41
|
+
<img src="screenshot.png" alt="Screenshot of te plugin's interface" width="900">
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
git clone https://github.com/FlorentLM/beets-unskipper
|
|
48
|
+
uv pip install beets-unskipper
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
or with Beets' default `uv tool` install:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv tool install beets --with ./beets-unskipper --reinstall
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then add `unskipper` to the `plugins` line in your beets config.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
beet unskipper
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Option | Description |
|
|
66
|
+
|----------------------------|---------------------------------------------------------------------------|
|
|
67
|
+
| `-s`, `--state-file` | Path to `state.pickle` (default: beets' configured location) |
|
|
68
|
+
| `-j`, `--unskipper-json` | Path to the sidecar JSON (default: alongside the state file) |
|
|
69
|
+
| `-r`, `--remap OLD=NEW` | Remap paths starting with `OLD` to `NEW` when loading |
|
|
70
|
+
| `--rebuild` | Rebuild `state.pickle` from the sidecar JSON, then exit |
|
|
71
|
+
| `--migrate-db` | With `--remap`, rewrite item/album paths in the beets database, then exit |
|
|
72
|
+
| `-d`, `--dry-run` | With `--rebuild` or `--migrate-db`, preview changes without writing |
|
|
73
|
+
|
|
74
|
+
### Moving your music library
|
|
75
|
+
|
|
76
|
+
If you move your whole library to a new location (new drive, new mount point, etc.),
|
|
77
|
+
`--remap` can update everything that beets-unskipper and beets itself track, so incremental
|
|
78
|
+
imports and the state UI keep working against the new paths:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
beet unskipper --remap /old/path=/new/path --migrate-db # rewrite paths in the beets database
|
|
82
|
+
beet unskipper --remap /old/path=/new/path --rebuild # rebuild state.pickle and apply the remap to the sidecar
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Add `-d`/`--dry-run` to either command to preview what would change without writing anything.
|
|
86
|
+
|
|
87
|
+
### Keys
|
|
88
|
+
|
|
89
|
+
| Key | Action |
|
|
90
|
+
|------------------------------------|---------------------------------------------------------------------------------|
|
|
91
|
+
| `↓`/`↑` | move |
|
|
92
|
+
| `Shift+↓`/`Shift+↑`, `PgDn`/`PgUp` | move by a page |
|
|
93
|
+
| `a`-`z` | jump to next row starting with that letter |
|
|
94
|
+
| `+`/`-` | jump to next entry marked new (unimported) |
|
|
95
|
+
| `Enter` | run `beet import` on the selected new folder (using your existing beets config) |
|
|
96
|
+
| `space` | mark/unmark row |
|
|
97
|
+
| `Del`/`Backspace` | delete marked rows (or current one if none marked) |
|
|
98
|
+
| `Ctrl+S` | save |
|
|
99
|
+
| `Esc` | quit |
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# beets-unskipper
|
|
2
|
+
|
|
3
|
+
A [beets](https://beets.io) plugin to browse and edit beets' import state
|
|
4
|
+
file (`state.pickle`) with a small terminal UI.
|
|
5
|
+
|
|
6
|
+
Beets records already-imported directories in `state.pickle`, and then any other import with
|
|
7
|
+
`--incremental` will skip anything listed in there. `unskipper` lets you browse this
|
|
8
|
+
file and remove ("unskip") entries, so beets will reconsider them next time you import.
|
|
9
|
+
|
|
10
|
+
It also keeps a human-readable sidecar file (`<statefile>.unskipper.json`) with additional data
|
|
11
|
+
per item, recorded automatically during import, and that can be used to rebuild a lost
|
|
12
|
+
or corrupted `state.pickle`.
|
|
13
|
+
|
|
14
|
+
<div align="center">
|
|
15
|
+
<img src="screenshot.png" alt="Screenshot of te plugin's interface" width="900">
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
git clone https://github.com/FlorentLM/beets-unskipper
|
|
22
|
+
uv pip install beets-unskipper
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
or with Beets' default `uv tool` install:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
uv tool install beets --with ./beets-unskipper --reinstall
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then add `unskipper` to the `plugins` line in your beets config.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
beet unskipper
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Option | Description |
|
|
40
|
+
|----------------------------|---------------------------------------------------------------------------|
|
|
41
|
+
| `-s`, `--state-file` | Path to `state.pickle` (default: beets' configured location) |
|
|
42
|
+
| `-j`, `--unskipper-json` | Path to the sidecar JSON (default: alongside the state file) |
|
|
43
|
+
| `-r`, `--remap OLD=NEW` | Remap paths starting with `OLD` to `NEW` when loading |
|
|
44
|
+
| `--rebuild` | Rebuild `state.pickle` from the sidecar JSON, then exit |
|
|
45
|
+
| `--migrate-db` | With `--remap`, rewrite item/album paths in the beets database, then exit |
|
|
46
|
+
| `-d`, `--dry-run` | With `--rebuild` or `--migrate-db`, preview changes without writing |
|
|
47
|
+
|
|
48
|
+
### Moving your music library
|
|
49
|
+
|
|
50
|
+
If you move your whole library to a new location (new drive, new mount point, etc.),
|
|
51
|
+
`--remap` can update everything that beets-unskipper and beets itself track, so incremental
|
|
52
|
+
imports and the state UI keep working against the new paths:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
beet unskipper --remap /old/path=/new/path --migrate-db # rewrite paths in the beets database
|
|
56
|
+
beet unskipper --remap /old/path=/new/path --rebuild # rebuild state.pickle and apply the remap to the sidecar
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Add `-d`/`--dry-run` to either command to preview what would change without writing anything.
|
|
60
|
+
|
|
61
|
+
### Keys
|
|
62
|
+
|
|
63
|
+
| Key | Action |
|
|
64
|
+
|------------------------------------|---------------------------------------------------------------------------------|
|
|
65
|
+
| `↓`/`↑` | move |
|
|
66
|
+
| `Shift+↓`/`Shift+↑`, `PgDn`/`PgUp` | move by a page |
|
|
67
|
+
| `a`-`z` | jump to next row starting with that letter |
|
|
68
|
+
| `+`/`-` | jump to next entry marked new (unimported) |
|
|
69
|
+
| `Enter` | run `beet import` on the selected new folder (using your existing beets config) |
|
|
70
|
+
| `space` | mark/unmark row |
|
|
71
|
+
| `Del`/`Backspace` | delete marked rows (or current one if none marked) |
|
|
72
|
+
| `Ctrl+S` | save |
|
|
73
|
+
| `Esc` | quit |
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Beets plugin unskipper: browse and edit beets' import state file
|
|
3
|
+
(`state.pickle`) with a small terminal UI.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Dict, Tuple
|
|
11
|
+
|
|
12
|
+
from beets import ui
|
|
13
|
+
from beets.importer import SentinelImportTask
|
|
14
|
+
from beets.plugins import BeetsPlugin
|
|
15
|
+
|
|
16
|
+
from . import sidecar
|
|
17
|
+
from . import pathremap
|
|
18
|
+
from .statefile import default_state_path, StateFileData
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnskipperPlugin(BeetsPlugin):
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
super().__init__()
|
|
25
|
+
# id(task) -> {'paths': ..., 'toppath': ..., 'task': ...}, until the task's outcome (imported/skipped) is known
|
|
26
|
+
self._pending: Dict[int, dict] = {}
|
|
27
|
+
|
|
28
|
+
self._session = None # Session doing the touching
|
|
29
|
+
self._toppaths: set[bytes] = set() # toppaths touched so far in the session
|
|
30
|
+
|
|
31
|
+
self.register_listener('import_task_choice', self._on_choice)
|
|
32
|
+
self.register_listener('import_task_files', self._on_files)
|
|
33
|
+
self.register_listener('cli_exit', self._on_exit)
|
|
34
|
+
|
|
35
|
+
def commands(self):
|
|
36
|
+
cmd = ui.Subcommand(
|
|
37
|
+
'unskipper',
|
|
38
|
+
help="Browse/edit beets' import state file",
|
|
39
|
+
)
|
|
40
|
+
cmd.parser.add_option(
|
|
41
|
+
'-s', '--state-file',
|
|
42
|
+
dest='state_file',
|
|
43
|
+
help='Path to state.pickle',
|
|
44
|
+
)
|
|
45
|
+
cmd.parser.add_option(
|
|
46
|
+
'-j', '--unskipper-json',
|
|
47
|
+
dest='sidecar_file',
|
|
48
|
+
help='Path to the unskipper sidecar JSON (defaults to alongside the state file)',
|
|
49
|
+
)
|
|
50
|
+
cmd.parser.add_option(
|
|
51
|
+
'-r', '--remap',
|
|
52
|
+
dest='remap',
|
|
53
|
+
metavar='OLD=NEW',
|
|
54
|
+
help='Remap paths starting with OLD to start with NEW when loading',
|
|
55
|
+
)
|
|
56
|
+
cmd.parser.add_option(
|
|
57
|
+
'--rebuild', action='store_true', dest='rebuild',
|
|
58
|
+
help="Overwrite state.pickle by rebuilding it from the sidecar JSON",
|
|
59
|
+
)
|
|
60
|
+
cmd.parser.add_option(
|
|
61
|
+
'--migrate-db', action='store_true', dest='migrate_db',
|
|
62
|
+
help="Rewrite item/album paths in the beets database using --remap OLD=NEW",
|
|
63
|
+
)
|
|
64
|
+
cmd.parser.add_option(
|
|
65
|
+
'-d', '--dry-run', action='store_true', dest='dry_run',
|
|
66
|
+
help="With --rebuild, print a summary of what would change without writing state.pickle",
|
|
67
|
+
)
|
|
68
|
+
cmd.func = self._run
|
|
69
|
+
return [cmd]
|
|
70
|
+
|
|
71
|
+
def _run(self, lib, opts, args):
|
|
72
|
+
from .app import UnskipperApp
|
|
73
|
+
|
|
74
|
+
path = Path(opts.state_file) if opts.state_file else default_state_path()
|
|
75
|
+
sidecar_path = Path(opts.sidecar_file) if opts.sidecar_file else sidecar.sidecar_path(path)
|
|
76
|
+
|
|
77
|
+
remap = None
|
|
78
|
+
if opts.remap:
|
|
79
|
+
try:
|
|
80
|
+
remap = pathremap.parse_remap_arg(opts.remap)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise ui.UserError(f'--remap {exc}')
|
|
83
|
+
|
|
84
|
+
if opts.migrate_db:
|
|
85
|
+
if not remap:
|
|
86
|
+
raise ui.UserError('--migrate-db requires --remap OLD=NEW')
|
|
87
|
+
self._migrate_db(lib, remap, dry_run=bool(opts.dry_run))
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
if opts.rebuild:
|
|
91
|
+
self._rebuild(path, sidecar_path, remap, dry_run=bool(opts.dry_run))
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if not path.exists():
|
|
95
|
+
raise ui.UserError(f'State file not found: {path}')
|
|
96
|
+
|
|
97
|
+
UnskipperApp(path, remap=remap, sidecar_path=sidecar_path, lib=lib).run()
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _rebuild(path: Path, sidecar_path: Path, remap, dry_run: bool = False) -> None:
|
|
101
|
+
from .statefile import StateFileData, from_sidecar, load as load_state, save as save_state
|
|
102
|
+
|
|
103
|
+
if not sidecar_path.exists():
|
|
104
|
+
raise ui.UserError(f'Sidecar file not found: {sidecar_path}')
|
|
105
|
+
|
|
106
|
+
data = sidecar.load(sidecar_path)
|
|
107
|
+
new_data = data
|
|
108
|
+
if remap:
|
|
109
|
+
old, new = remap
|
|
110
|
+
new_data = pathremap.remap_sidecar(data, old, new)
|
|
111
|
+
|
|
112
|
+
new_state = from_sidecar(new_data)
|
|
113
|
+
sidecar_changed = new_data != data
|
|
114
|
+
|
|
115
|
+
if dry_run:
|
|
116
|
+
old_state = load_state(path) if path.exists() else StateFileData()
|
|
117
|
+
UnskipperPlugin._print_rebuild_diff(path, old_state, new_state)
|
|
118
|
+
if sidecar_changed:
|
|
119
|
+
print(f' sidecar: {sidecar_path} would also be rewritten in place with remapped paths')
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
prompt = f'This will overwrite {path}'
|
|
123
|
+
if sidecar_changed:
|
|
124
|
+
prompt += f' and remap paths in {sidecar_path}'
|
|
125
|
+
prompt += '. Continue?'
|
|
126
|
+
|
|
127
|
+
if (path.exists() or sidecar_changed) and not ui.input_yn(prompt, require=True):
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if sidecar_changed:
|
|
131
|
+
sidecar.save(sidecar_path, new_data)
|
|
132
|
+
|
|
133
|
+
save_state(path, new_state)
|
|
134
|
+
print(
|
|
135
|
+
f'Rebuilt {path} from {sidecar_path} '
|
|
136
|
+
f'({len(new_state.taghistory)} taghistory entries, '
|
|
137
|
+
f'{len(new_state.tagprogress)} tagprogress toppaths)'
|
|
138
|
+
)
|
|
139
|
+
if sidecar_changed:
|
|
140
|
+
print(f'Remapped paths in {sidecar_path}.')
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _print_rebuild_diff(path: Path, old_state: StateFileData, new_state: StateFileData) -> None:
|
|
144
|
+
added_history = new_state.taghistory - old_state.taghistory
|
|
145
|
+
removed_history = old_state.taghistory - new_state.taghistory
|
|
146
|
+
|
|
147
|
+
old_toppaths = set(old_state.tagprogress)
|
|
148
|
+
new_toppaths = set(new_state.tagprogress)
|
|
149
|
+
|
|
150
|
+
added_progress = new_toppaths - old_toppaths
|
|
151
|
+
removed_progress = old_toppaths - new_toppaths
|
|
152
|
+
|
|
153
|
+
changed_progress = {
|
|
154
|
+
t for t in old_toppaths & new_toppaths
|
|
155
|
+
if old_state.tagprogress[t] != new_state.tagprogress[t]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
print(f'Rebuild preview for {path} (dry run):')
|
|
159
|
+
print(
|
|
160
|
+
f' taghistory: {len(old_state.taghistory)} -> {len(new_state.taghistory)} entries '
|
|
161
|
+
f'(+{len(added_history)}, -{len(removed_history)})'
|
|
162
|
+
)
|
|
163
|
+
print(
|
|
164
|
+
f' tagprogress: {len(old_state.tagprogress)} -> {len(new_state.tagprogress)} toppaths '
|
|
165
|
+
f'(+{len(added_progress)}, -{len(removed_progress)}, ~{len(changed_progress)} changed)'
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _migrate_db(lib, remap: Tuple[str, str], dry_run: bool = False) -> None:
|
|
170
|
+
|
|
171
|
+
old, new = remap
|
|
172
|
+
old_b = os.fsencode(old.rstrip(os.sep))
|
|
173
|
+
new_b = os.fsencode(new.rstrip(os.sep))
|
|
174
|
+
|
|
175
|
+
item_changes = []
|
|
176
|
+
for item in lib.items():
|
|
177
|
+
new_path = pathremap.remap_bytes(item.path, old_b, new_b)
|
|
178
|
+
if new_path != item.path:
|
|
179
|
+
item_changes.append((item, new_path))
|
|
180
|
+
|
|
181
|
+
album_changes = []
|
|
182
|
+
for album in lib.albums():
|
|
183
|
+
if not album.artpath:
|
|
184
|
+
continue
|
|
185
|
+
new_artpath = pathremap.remap_bytes(album.artpath, old_b, new_b)
|
|
186
|
+
if new_artpath != album.artpath:
|
|
187
|
+
album_changes.append((album, new_artpath))
|
|
188
|
+
|
|
189
|
+
print(
|
|
190
|
+
f'{len(item_changes)} item path(s) and {len(album_changes)} album art path(s) '
|
|
191
|
+
f'would be remapped ({old} -> {new}).'
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if dry_run:
|
|
195
|
+
for item, new_path in item_changes[:20]:
|
|
196
|
+
print(f' {os.fsdecode(item.path)} -> {os.fsdecode(new_path)}')
|
|
197
|
+
if len(item_changes) > 20:
|
|
198
|
+
print(f' … (+{len(item_changes) - 20} more)')
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if not (item_changes or album_changes):
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if not ui.input_yn(
|
|
205
|
+
f'This will rewrite {len(item_changes)} item path(s) and {len(album_changes)} '
|
|
206
|
+
f'album art path(s) in the beets database. Continue?', require=True
|
|
207
|
+
):
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if lib.path.exists():
|
|
211
|
+
backup_path = lib.path.with_name(lib.path.name + '.bak')
|
|
212
|
+
shutil.copy2(lib.path, backup_path)
|
|
213
|
+
|
|
214
|
+
with lib.transaction():
|
|
215
|
+
for item, new_path in item_changes:
|
|
216
|
+
item.path = new_path
|
|
217
|
+
item.store()
|
|
218
|
+
for album, new_artpath in album_changes:
|
|
219
|
+
album.artpath = new_artpath
|
|
220
|
+
album.store()
|
|
221
|
+
|
|
222
|
+
print('Database paths migrated.')
|
|
223
|
+
|
|
224
|
+
# Import-time sidecar recording
|
|
225
|
+
|
|
226
|
+
def _on_choice(self, session, task) -> None:
|
|
227
|
+
|
|
228
|
+
# Sentinel: nothing to record
|
|
229
|
+
if task.toppath is None or isinstance(task, SentinelImportTask):
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
self._session = session
|
|
233
|
+
self._toppaths.add(task.toppath)
|
|
234
|
+
|
|
235
|
+
self._pending[id(task)] = {
|
|
236
|
+
'task': task,
|
|
237
|
+
'session': session,
|
|
238
|
+
'paths': tuple(task.paths),
|
|
239
|
+
'toppath': task.toppath,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
def _on_files(self, session, task) -> None:
|
|
243
|
+
# Fires once a task's status is set to "not skipped", after
|
|
244
|
+
# files have been moved/copied/linked
|
|
245
|
+
rec = self._pending.pop(id(task), None)
|
|
246
|
+
if rec is not None:
|
|
247
|
+
self._record(
|
|
248
|
+
rec, outcome='imported',
|
|
249
|
+
operation=self._operation_name(session),
|
|
250
|
+
release=self._release_id(task),
|
|
251
|
+
dest_paths=[item.path for item in task.imported_items()],
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def _on_exit(self, lib) -> None:
|
|
255
|
+
# Anything still pending never reached import_task_files: it was skipped
|
|
256
|
+
for rec in self._pending.values():
|
|
257
|
+
self._record(rec, outcome='skipped', release=self._release_id(rec['task']))
|
|
258
|
+
self._pending.clear()
|
|
259
|
+
|
|
260
|
+
if self._toppaths and self._in_tagprogress(self._session):
|
|
261
|
+
path = sidecar.sidecar_path(default_state_path())
|
|
262
|
+
with sidecar.lock:
|
|
263
|
+
data = sidecar.load(path)
|
|
264
|
+
|
|
265
|
+
for toppath in self._toppaths:
|
|
266
|
+
sidecar.record_import_done(data, toppath)
|
|
267
|
+
|
|
268
|
+
sidecar.save(path, data)
|
|
269
|
+
|
|
270
|
+
self._toppaths.clear()
|
|
271
|
+
self._session = None
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _operation_name(session) -> str | None:
|
|
275
|
+
# Mirrors logic in beets.importer.stages.manipulate_files
|
|
276
|
+
cfg = session.config
|
|
277
|
+
|
|
278
|
+
if cfg['move']:
|
|
279
|
+
return 'move'
|
|
280
|
+
if cfg['copy']:
|
|
281
|
+
return 'copy'
|
|
282
|
+
if cfg['link']:
|
|
283
|
+
return 'symlink'
|
|
284
|
+
if cfg['hardlink']:
|
|
285
|
+
return 'hardlink'
|
|
286
|
+
if cfg['reflink'].get() == 'auto':
|
|
287
|
+
return 'reflink_auto'
|
|
288
|
+
if cfg['reflink']:
|
|
289
|
+
return 'reflink'
|
|
290
|
+
return 'in-place' # file left where it was
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _release_id(task) -> str | None:
|
|
294
|
+
album = getattr(task, 'album', None)
|
|
295
|
+
if album is not None and getattr(album, 'mb_albumid', None):
|
|
296
|
+
return album.mb_albumid
|
|
297
|
+
match = getattr(task, 'match', None)
|
|
298
|
+
info = getattr(match, 'info', None) if match else None
|
|
299
|
+
return getattr(info, 'album_id', None) if info else None
|
|
300
|
+
|
|
301
|
+
def _record(self,
|
|
302
|
+
rec: dict,
|
|
303
|
+
outcome: str,
|
|
304
|
+
operation: Optional[str] = None,
|
|
305
|
+
release: Optional[str] = None,
|
|
306
|
+
dest_paths: Optional[list] = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
|
|
309
|
+
path = sidecar.sidecar_path(default_state_path())
|
|
310
|
+
session = rec['session']
|
|
311
|
+
|
|
312
|
+
with sidecar.lock:
|
|
313
|
+
data = sidecar.load(path)
|
|
314
|
+
|
|
315
|
+
sidecar.record(
|
|
316
|
+
data, rec['paths'], rec['toppath'], outcome,
|
|
317
|
+
choice=rec['task'].choice_flag.name if rec['task'].choice_flag else None,
|
|
318
|
+
operation=operation,
|
|
319
|
+
release=release,
|
|
320
|
+
kind='album' if rec['task'].is_album else 'singleton',
|
|
321
|
+
in_tagprogress=self._in_tagprogress(session),
|
|
322
|
+
in_taghistory=self._in_taghistory(session, outcome),
|
|
323
|
+
dest_paths=dest_paths,
|
|
324
|
+
)
|
|
325
|
+
sidecar.save(path, data)
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _in_tagprogress(session) -> bool:
|
|
329
|
+
return bool(getattr(session, 'want_resume', False)) # True, False or "ask"
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _in_taghistory(session, outcome: str) -> bool:
|
|
333
|
+
cfg = session.config
|
|
334
|
+
if not cfg['incremental']:
|
|
335
|
+
return False
|
|
336
|
+
if outcome == 'skipped' and cfg['incremental_skip_later']:
|
|
337
|
+
return False
|
|
338
|
+
return True
|