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.
@@ -0,0 +1,9 @@
1
+ .idea
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ .pytest_cache/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ tests/
@@ -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