setlist-organiser 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Hollingsworth
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 OF OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: setlist-organiser
3
+ Version: 0.1.0
4
+ Summary: CLI tool for musical directors to classify and organise audio stem exports by instrument category.
5
+ Author-email: Alex Hollingsworth <alex@hsoundworks.co.uk>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alex-hollingsworth1/setlist_and_stem_organiser
8
+ Project-URL: Issues, https://github.com/alex-hollingsworth1/setlist_and_stem_organiser/issues
9
+ Keywords: audio,music,stems,ableton,playback,musical-director,cli
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Multimedia :: Sound/Audio
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # Setlist Organiser
25
+
26
+ CLI tool for classifying audio stem filenames into categories and organising them into folder structure via copy or move operations.
27
+
28
+ ## What it does
29
+
30
+ - Scans a source folder for audio files
31
+ - Classifies each file by filename keywords (e.g. `DRUMS`, `BASS`, `VOX`)
32
+ - Copies or moves files to `OUTPUT_ROOT/<CATEGORY>/...`
33
+ - Supports dry-run, recursive scan, custom keyword config, and interactive review for `OTHER` files
34
+
35
+ ## Requirements
36
+
37
+ - Python `>=3.11`
38
+
39
+ ## Install
40
+
41
+ From the project root:
42
+
43
+ ```bash
44
+ python -m venv .venv
45
+ source .venv/bin/activate
46
+ pip install -e .
47
+ ```
48
+
49
+ This exposes the command:
50
+
51
+ ```bash
52
+ setlist-organiser --help
53
+ ```
54
+
55
+ ## Basic usage
56
+
57
+ ```bash
58
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT
59
+ ```
60
+
61
+ Example:
62
+
63
+ ```bash
64
+ setlist-organiser ../../AUDIO_FILES_FOR_TESTING output
65
+ ```
66
+
67
+ For paths with spaces, quote them:
68
+
69
+ ```bash
70
+ setlist-organiser "/path/with spaces/Stems '24" output
71
+ ```
72
+
73
+ ## Common flags
74
+
75
+ - `--dry-run`
76
+ Show what would be copied without writing files.
77
+ - `--quiet`
78
+ Suppress per-file listing.
79
+ - `--summary-only`
80
+ Print category counts.
81
+ - `--show-other`
82
+ Print files currently classified as `OTHER`.
83
+ - `--recursive`
84
+ Scan nested folders.
85
+ - `--config PATH`
86
+ Load extra keyword mappings from a JSON config file.
87
+ - `--review`
88
+ Interactively review files in `OTHER` before execution.
89
+ - `--move`
90
+ Move files instead of copying them.
91
+
92
+ ## Interactive review mode
93
+
94
+ Use review mode to handle uncertain files before copying:
95
+
96
+ ```bash
97
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT --review
98
+ ```
99
+
100
+ For each `OTHER` file:
101
+
102
+ - Press `Enter` to keep as `OTHER`
103
+ - Type a category name (e.g. `DRUMS`, `VOX`, `KEYS`) to reassign
104
+ - Type `s` to skip the file
105
+
106
+ ## Config file format
107
+
108
+ Config adds extra keywords per category (it does not remove built-in defaults).
109
+
110
+ Example `test_config.json`:
111
+
112
+ ```json
113
+ {
114
+ "keywords": {
115
+ "DRUMS": ["room", "hh"],
116
+ "VOX": ["adlib_fx"]
117
+ }
118
+ }
119
+ ```
120
+
121
+ Run with config:
122
+
123
+ ```bash
124
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT --config test_config.json
125
+ ```
126
+
127
+ ## Move mode
128
+
129
+ Use move mode when you want files relocated from source to destination:
130
+
131
+ ```bash
132
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT --move
133
+ ```
134
+
135
+ Preview move operations without changing files:
136
+
137
+ ```bash
138
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT --move --dry-run
139
+ ```
140
+
141
+ ## Output behavior
142
+
143
+ - `--dry-run`: no files are copied or moved
144
+ - normal run: files are copied by default (metadata-preserving copy)
145
+ - `--move`: files are moved instead of copied
146
+ - destination collisions are suffixed (`_2`, `_3`, ...)
147
+
148
+ ## Development
149
+
150
+ Run tests:
151
+
152
+ ```bash
153
+ pytest -v
154
+ ```
155
+
156
+ Or use Makefile shortcuts:
157
+
158
+ ```bash
159
+ make test
160
+ make dry-run
161
+ make move-dry
162
+ make review
163
+ make summary
164
+ ```
@@ -0,0 +1,141 @@
1
+ # Setlist Organiser
2
+
3
+ CLI tool for classifying audio stem filenames into categories and organising them into folder structure via copy or move operations.
4
+
5
+ ## What it does
6
+
7
+ - Scans a source folder for audio files
8
+ - Classifies each file by filename keywords (e.g. `DRUMS`, `BASS`, `VOX`)
9
+ - Copies or moves files to `OUTPUT_ROOT/<CATEGORY>/...`
10
+ - Supports dry-run, recursive scan, custom keyword config, and interactive review for `OTHER` files
11
+
12
+ ## Requirements
13
+
14
+ - Python `>=3.11`
15
+
16
+ ## Install
17
+
18
+ From the project root:
19
+
20
+ ```bash
21
+ python -m venv .venv
22
+ source .venv/bin/activate
23
+ pip install -e .
24
+ ```
25
+
26
+ This exposes the command:
27
+
28
+ ```bash
29
+ setlist-organiser --help
30
+ ```
31
+
32
+ ## Basic usage
33
+
34
+ ```bash
35
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT
36
+ ```
37
+
38
+ Example:
39
+
40
+ ```bash
41
+ setlist-organiser ../../AUDIO_FILES_FOR_TESTING output
42
+ ```
43
+
44
+ For paths with spaces, quote them:
45
+
46
+ ```bash
47
+ setlist-organiser "/path/with spaces/Stems '24" output
48
+ ```
49
+
50
+ ## Common flags
51
+
52
+ - `--dry-run`
53
+ Show what would be copied without writing files.
54
+ - `--quiet`
55
+ Suppress per-file listing.
56
+ - `--summary-only`
57
+ Print category counts.
58
+ - `--show-other`
59
+ Print files currently classified as `OTHER`.
60
+ - `--recursive`
61
+ Scan nested folders.
62
+ - `--config PATH`
63
+ Load extra keyword mappings from a JSON config file.
64
+ - `--review`
65
+ Interactively review files in `OTHER` before execution.
66
+ - `--move`
67
+ Move files instead of copying them.
68
+
69
+ ## Interactive review mode
70
+
71
+ Use review mode to handle uncertain files before copying:
72
+
73
+ ```bash
74
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT --review
75
+ ```
76
+
77
+ For each `OTHER` file:
78
+
79
+ - Press `Enter` to keep as `OTHER`
80
+ - Type a category name (e.g. `DRUMS`, `VOX`, `KEYS`) to reassign
81
+ - Type `s` to skip the file
82
+
83
+ ## Config file format
84
+
85
+ Config adds extra keywords per category (it does not remove built-in defaults).
86
+
87
+ Example `test_config.json`:
88
+
89
+ ```json
90
+ {
91
+ "keywords": {
92
+ "DRUMS": ["room", "hh"],
93
+ "VOX": ["adlib_fx"]
94
+ }
95
+ }
96
+ ```
97
+
98
+ Run with config:
99
+
100
+ ```bash
101
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT --config test_config.json
102
+ ```
103
+
104
+ ## Move mode
105
+
106
+ Use move mode when you want files relocated from source to destination:
107
+
108
+ ```bash
109
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT --move
110
+ ```
111
+
112
+ Preview move operations without changing files:
113
+
114
+ ```bash
115
+ setlist-organiser SOURCE_DIR OUTPUT_ROOT --move --dry-run
116
+ ```
117
+
118
+ ## Output behavior
119
+
120
+ - `--dry-run`: no files are copied or moved
121
+ - normal run: files are copied by default (metadata-preserving copy)
122
+ - `--move`: files are moved instead of copied
123
+ - destination collisions are suffixed (`_2`, `_3`, ...)
124
+
125
+ ## Development
126
+
127
+ Run tests:
128
+
129
+ ```bash
130
+ pytest -v
131
+ ```
132
+
133
+ Or use Makefile shortcuts:
134
+
135
+ ```bash
136
+ make test
137
+ make dry-run
138
+ make move-dry
139
+ make review
140
+ make summary
141
+ ```
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "setlist-organiser"
7
+ version = "0.1.0"
8
+ description = "CLI tool for musical directors to classify and organise audio stem exports by instrument category."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = []
12
+ authors = [
13
+ {name = "Alex Hollingsworth", email = "alex@hsoundworks.co.uk"}
14
+ ]
15
+ license = {text = "MIT"}
16
+ keywords = ["audio", "music", "stems", "ableton", "playback", "musical-director", "cli"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: End Users/Desktop",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Multimedia :: Sound/Audio",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/alex-hollingsworth1/setlist_and_stem_organiser"
31
+ Issues = "https://github.com/alex-hollingsworth1/setlist_and_stem_organiser/issues"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ pythonpath = ["src"]
38
+ testpaths = ["tests"]
39
+
40
+ [project.scripts]
41
+ setlist-organiser = "setlist_organiser.cli:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,307 @@
1
+ """Filename classification logic for setlist organiser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import re
8
+
9
+ from .models import Category, ClassifiedFile
10
+
11
+ _SEPARATOR_RE = re.compile(r"[^a-z0-9]+")
12
+ _SPACE_RE = re.compile(r"\s+")
13
+ _DIGIT_LETTER_RE = re.compile(r"(\d)([a-zA-Z])")
14
+ _LETTER_DIGIT_RE = re.compile(r"([a-zA-Z])(\d)")
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class _CategoryMatch:
19
+ """Internal match candidate for potential category resolution."""
20
+
21
+ category: Category
22
+ matched_keyword: str
23
+
24
+
25
+ CATEGORY_KEYWORDS: dict[Category, tuple[str, ...]] = {
26
+ Category.PERC: (
27
+ "perc",
28
+ "percussion",
29
+ "shaker",
30
+ "tambourine",
31
+ "tamb",
32
+ "conga",
33
+ "congas",
34
+ "bongo",
35
+ "bongos",
36
+ "clap",
37
+ "claps",
38
+ "stomp",
39
+ "stomps",
40
+ "bells",
41
+ "triangle",
42
+ "hit",
43
+ "hits",
44
+ ),
45
+ Category.DRUMS: (
46
+ "drum",
47
+ "drums",
48
+ "kit",
49
+ "snare",
50
+ "snares",
51
+ "snr",
52
+ "kick",
53
+ "kicks",
54
+ "kck",
55
+ "kik",
56
+ "toms",
57
+ "hihat",
58
+ "hi hat",
59
+ "hh",
60
+ "cym",
61
+ "ride",
62
+ "crash",
63
+ "overhead",
64
+ "oh",
65
+ "rimshot",
66
+ "rim",
67
+ ),
68
+ Category.BASS: (
69
+ "bass",
70
+ "bassgtr",
71
+ "bass guitar",
72
+ ),
73
+ Category.SUB: (
74
+ "sub",
75
+ "808",
76
+ "low end",
77
+ "sub bass",
78
+ ),
79
+ Category.KEYS: (
80
+ "keys",
81
+ "key",
82
+ "piano",
83
+ "rhodes",
84
+ "wurli",
85
+ "organ",
86
+ "synth",
87
+ "pad",
88
+ "arp",
89
+ "synths",
90
+ "synth pad",
91
+ "sine",
92
+ "saw",
93
+ "square",
94
+ "sc",
95
+ "sidechain"
96
+ ),
97
+ Category.GTR: (
98
+ "gtr",
99
+ "guitar",
100
+ "acoustic",
101
+ "electric",
102
+ "lead gtr",
103
+ "rhythm gtr",
104
+ ),
105
+ Category.VOX: (
106
+ "vox",
107
+ "voc",
108
+ "vocal",
109
+ "lead vox",
110
+ "lead vocal",
111
+ "vocoder",
112
+ "adlib",
113
+ "adlibs",
114
+ "ad libs",
115
+ "double",
116
+ "dbl",
117
+ "doubles",
118
+ "lv",
119
+ ),
120
+ Category.BVS: (
121
+ "bv",
122
+ "bvs",
123
+ "backing vocal",
124
+ "backing vox",
125
+ "harmony",
126
+ "choir",
127
+ "stack",
128
+ "gang vox",
129
+ "harmonies",
130
+ "harms",
131
+ ),
132
+ Category.FX: (
133
+ "fx",
134
+ "sfx",
135
+ "impact",
136
+ "riser",
137
+ "sweep",
138
+ "transition",
139
+ "verb",
140
+ "reverb",
141
+ "rverb",
142
+ "rvb",
143
+ "delay",
144
+ "dly",
145
+ "throw",
146
+ "beep",
147
+ "noise",
148
+ "white noise",
149
+ "atmos",
150
+ "atmosphere"
151
+ ),
152
+ Category.CLICK: (
153
+ "click",
154
+ "metronome",
155
+ "metro",
156
+ "clk",
157
+ ),
158
+ Category.CUES: (
159
+ "cue",
160
+ "cues",
161
+ "guide",
162
+ "guide vox",
163
+ "guide vocal",
164
+ "talkback",
165
+ "count in",
166
+ "countin",
167
+ ),
168
+ Category.STRINGS: (
169
+ "strings",
170
+ "string",
171
+ "violin",
172
+ "viola",
173
+ "cello",
174
+ ),
175
+ Category.BRASS: (
176
+ "brass",
177
+ "trumpet",
178
+ "trombone",
179
+ "tuba",
180
+ "horn",
181
+ "horns",
182
+ ),
183
+ Category.WOODWIND: (
184
+ "woodwind",
185
+ "flute",
186
+ "oboe",
187
+ "clarinet",
188
+ "saxophone",
189
+ "bassoon",
190
+ ),
191
+ Category.OTHER: (),
192
+ }
193
+
194
+
195
+ CATEGORY_PRIORITY: tuple[Category, ...] = (
196
+ Category.CLICK,
197
+ Category.CUES,
198
+ Category.BVS,
199
+ Category.VOX,
200
+ Category.FX,
201
+ Category.DRUMS,
202
+ Category.PERC,
203
+ Category.SUB,
204
+ Category.BASS,
205
+ Category.KEYS,
206
+ Category.GTR,
207
+ Category.STRINGS,
208
+ Category.BRASS,
209
+ Category.WOODWIND,
210
+ Category.OTHER,
211
+ )
212
+
213
+
214
+ def _normalize_name(name: str) -> str:
215
+ name = _DIGIT_LETTER_RE.sub(r"\1 \2", name)
216
+ name = _LETTER_DIGIT_RE.sub(r"\1 \2", name)
217
+ cleaned = _SEPARATOR_RE.sub(" ", name.lower())
218
+ return _SPACE_RE.sub(" ", cleaned).strip()
219
+
220
+
221
+ def _tokenize(normalized_name: str) -> set[str]:
222
+ if not normalized_name:
223
+ return set()
224
+ return set(normalized_name.split(" "))
225
+
226
+
227
+ def _keyword_matches(
228
+ keyword: str, normalized_name: str, tokens: set[str]
229
+ ) -> bool:
230
+ normalized_keyword = _normalize_name(keyword)
231
+ if not normalized_keyword:
232
+ return False
233
+ if " " in normalized_keyword:
234
+ return normalized_keyword in normalized_name
235
+ return normalized_keyword in tokens
236
+
237
+
238
+ def _collect_candidate_matches(
239
+ name: str,
240
+ *,
241
+ keywords: dict[Category, tuple[str, ...]],
242
+ ) -> list[_CategoryMatch]:
243
+ normalized_name = _normalize_name(Path(name).stem)
244
+ tokens = _tokenize(normalized_name)
245
+ candidates: list[_CategoryMatch] = []
246
+
247
+ for category, keyword_tuple in keywords.items():
248
+ for keyword in keyword_tuple:
249
+ if _keyword_matches(keyword, normalized_name, tokens):
250
+ candidates.append(
251
+ _CategoryMatch(category=category, matched_keyword=keyword)
252
+ )
253
+ break
254
+
255
+ return candidates
256
+
257
+
258
+ def _resolve_single_category(
259
+ candidates: list[_CategoryMatch],
260
+ *,
261
+ priority: tuple[Category, ...],
262
+ ) -> _CategoryMatch:
263
+ if not candidates:
264
+ return _CategoryMatch(
265
+ category=Category.OTHER,
266
+ matched_keyword="",
267
+ )
268
+
269
+ candidate_by_category = {candidate.category: candidate for candidate in candidates}
270
+ for category in priority:
271
+ if category in candidate_by_category:
272
+ return candidate_by_category[category]
273
+
274
+ return _CategoryMatch(category=Category.OTHER, matched_keyword="")
275
+
276
+
277
+ def classify_name(
278
+ name: str,
279
+ source: Path | None = None,
280
+ *,
281
+ keywords: dict[Category, tuple[str, ...]] | None = None,
282
+ ) -> ClassifiedFile:
283
+ """Classify a filename into a single category."""
284
+ source_path = source if source is not None else Path(name)
285
+ kw = CATEGORY_KEYWORDS if keywords is None else keywords
286
+ candidates = _collect_candidate_matches(name, keywords=kw)
287
+ resolved = _resolve_single_category(candidates, priority=CATEGORY_PRIORITY)
288
+
289
+ return ClassifiedFile(
290
+ source=source_path,
291
+ category=resolved.category,
292
+ matched_keyword=resolved.matched_keyword or None,
293
+ )
294
+
295
+
296
+ def classify_path(
297
+ path: Path,
298
+ *,
299
+ keywords: dict[Category, tuple[str, ...]] | None = None,
300
+ ) -> ClassifiedFile:
301
+ """Classify a file path based on its name."""
302
+ return classify_name(name=path.name, source=path, keywords=keywords)
303
+
304
+
305
+ def _get_candidate_matches(name: str) -> tuple[_CategoryMatch, ...]:
306
+ """Return all category candidates for future interactive resolution."""
307
+ return tuple(_collect_candidate_matches(name, keywords=CATEGORY_KEYWORDS))