flac-cue-split 0.1.2__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,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: flac-cue-split
3
+ Version: 0.1.2
4
+ Summary: Split FLAC files into individual tracks based on CUE sheets
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: rich>=14.3.1
8
+
9
+ # flac-cue-split
10
+
11
+ Split single-file FLAC albums into individual tracks using CUE sheets.
12
+
13
+ **Perfect for Plex users**: Plex and other media servers don't support CUE sheets, so albums ripped as a single FLAC file won't display individual tracks. This tool splits them into separate files with proper metadata so Plex can index each track correctly.
14
+
15
+ ## Features
16
+
17
+ - Recursively searches directories for FLAC + CUE pairs
18
+ - Automatic CUE file encoding detection (UTF-8, Latin-1, Shift-JIS, etc.)
19
+ - Skips albums that are already split
20
+ - Safe dry-run by default - see what would happen before committing
21
+ - Prompts before deleting source files
22
+ - Preserves CUE files after splitting
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.12+
27
+ - [ffmpeg](https://ffmpeg.org/download.html) installed and in PATH
28
+ - **Ubuntu/Debian**: `sudo apt install ffmpeg`
29
+ - **macOS**: `brew install ffmpeg`
30
+ - **Windows**: Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to PATH
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ # Using uv (recommended)
36
+ uv tool install flac-cue-split
37
+
38
+ # Using pipx
39
+ pipx install flac-cue-split
40
+
41
+ # Using pip
42
+ pip install flac-cue-split
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ # Dry run - see what would be split
49
+ flac-cue-split /path/to/music
50
+
51
+ # Actually split the files
52
+ flac-cue-split /path/to/music --execute
53
+
54
+ # Split and delete original FLAC files (prompts for each)
55
+ flac-cue-split /path/to/music --execute --delete
56
+
57
+ # Split and delete without prompts
58
+ flac-cue-split /path/to/music --execute --delete --yes
59
+
60
+ # Delete sources for already-split albums
61
+ flac-cue-split /path/to/music --delete --yes
62
+ ```
63
+
64
+ ### Example Output
65
+
66
+ ```
67
+ Found 2 album(s) in Music/
68
+
69
+ 1. The Elder Scrolls V: Skyrim - Atmospheres
70
+ Jeremy Soule | 2 tracks | 42m 54s
71
+ _Game OST/_Elder Scrolls/Jeremy Soule - Skyrim - Atmospheres/
72
+ 2 tracks extracted
73
+ Delete original FLAC? [Y/n]
74
+
75
+ 2. Dark Side of the Moon
76
+ Pink Floyd | 10 tracks | 42m 59s
77
+ Pink Floyd/1973 - Dark Side of the Moon/
78
+ 10 tracks extracted
79
+ Delete original FLAC? [Y/n]
80
+
81
+ Done
82
+ ```
83
+
84
+ ### Output Format
85
+
86
+ Tracks are saved as `{nn}. {track_name}.flac` where the number is zero-padded based on total track count (e.g., `01.` for <100 tracks, `001.` for 100-999, etc).
87
+
88
+ ## Options
89
+
90
+ | Option | Description |
91
+ |--------|-------------|
92
+ | `--execute` | Actually perform the split (default is dry-run) |
93
+ | `--delete` | Delete original FLAC files after splitting (prompts for confirmation) |
94
+ | `--output`, `-o` | Output directory for split files (default: same as source) |
95
+ | `--verbose`, `-v` | Show detailed track listings |
96
+ | `--yes`, `-y` | Auto-confirm prompts (use with `--delete`) |
97
+
98
+ ## Safety
99
+
100
+ - **Dry-run by default**: Nothing is modified unless you pass `--execute`
101
+ - **Deletion prompts**: `--delete` asks for confirmation unless `--yes` is specified
102
+ - **Error protection**: Source files are kept if any extraction errors occur
103
+ - **Permanent deletion**: `--delete` permanently removes files (not moved to trash)
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,99 @@
1
+ # flac-cue-split
2
+
3
+ Split single-file FLAC albums into individual tracks using CUE sheets.
4
+
5
+ **Perfect for Plex users**: Plex and other media servers don't support CUE sheets, so albums ripped as a single FLAC file won't display individual tracks. This tool splits them into separate files with proper metadata so Plex can index each track correctly.
6
+
7
+ ## Features
8
+
9
+ - Recursively searches directories for FLAC + CUE pairs
10
+ - Automatic CUE file encoding detection (UTF-8, Latin-1, Shift-JIS, etc.)
11
+ - Skips albums that are already split
12
+ - Safe dry-run by default - see what would happen before committing
13
+ - Prompts before deleting source files
14
+ - Preserves CUE files after splitting
15
+
16
+ ## Requirements
17
+
18
+ - Python 3.12+
19
+ - [ffmpeg](https://ffmpeg.org/download.html) installed and in PATH
20
+ - **Ubuntu/Debian**: `sudo apt install ffmpeg`
21
+ - **macOS**: `brew install ffmpeg`
22
+ - **Windows**: Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to PATH
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ # Using uv (recommended)
28
+ uv tool install flac-cue-split
29
+
30
+ # Using pipx
31
+ pipx install flac-cue-split
32
+
33
+ # Using pip
34
+ pip install flac-cue-split
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ # Dry run - see what would be split
41
+ flac-cue-split /path/to/music
42
+
43
+ # Actually split the files
44
+ flac-cue-split /path/to/music --execute
45
+
46
+ # Split and delete original FLAC files (prompts for each)
47
+ flac-cue-split /path/to/music --execute --delete
48
+
49
+ # Split and delete without prompts
50
+ flac-cue-split /path/to/music --execute --delete --yes
51
+
52
+ # Delete sources for already-split albums
53
+ flac-cue-split /path/to/music --delete --yes
54
+ ```
55
+
56
+ ### Example Output
57
+
58
+ ```
59
+ Found 2 album(s) in Music/
60
+
61
+ 1. The Elder Scrolls V: Skyrim - Atmospheres
62
+ Jeremy Soule | 2 tracks | 42m 54s
63
+ _Game OST/_Elder Scrolls/Jeremy Soule - Skyrim - Atmospheres/
64
+ 2 tracks extracted
65
+ Delete original FLAC? [Y/n]
66
+
67
+ 2. Dark Side of the Moon
68
+ Pink Floyd | 10 tracks | 42m 59s
69
+ Pink Floyd/1973 - Dark Side of the Moon/
70
+ 10 tracks extracted
71
+ Delete original FLAC? [Y/n]
72
+
73
+ Done
74
+ ```
75
+
76
+ ### Output Format
77
+
78
+ Tracks are saved as `{nn}. {track_name}.flac` where the number is zero-padded based on total track count (e.g., `01.` for <100 tracks, `001.` for 100-999, etc).
79
+
80
+ ## Options
81
+
82
+ | Option | Description |
83
+ |--------|-------------|
84
+ | `--execute` | Actually perform the split (default is dry-run) |
85
+ | `--delete` | Delete original FLAC files after splitting (prompts for confirmation) |
86
+ | `--output`, `-o` | Output directory for split files (default: same as source) |
87
+ | `--verbose`, `-v` | Show detailed track listings |
88
+ | `--yes`, `-y` | Auto-confirm prompts (use with `--delete`) |
89
+
90
+ ## Safety
91
+
92
+ - **Dry-run by default**: Nothing is modified unless you pass `--execute`
93
+ - **Deletion prompts**: `--delete` asks for confirmation unless `--yes` is specified
94
+ - **Error protection**: Source files are kept if any extraction errors occur
95
+ - **Permanent deletion**: `--delete` permanently removes files (not moved to trash)
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: flac-cue-split
3
+ Version: 0.1.2
4
+ Summary: Split FLAC files into individual tracks based on CUE sheets
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: rich>=14.3.1
8
+
9
+ # flac-cue-split
10
+
11
+ Split single-file FLAC albums into individual tracks using CUE sheets.
12
+
13
+ **Perfect for Plex users**: Plex and other media servers don't support CUE sheets, so albums ripped as a single FLAC file won't display individual tracks. This tool splits them into separate files with proper metadata so Plex can index each track correctly.
14
+
15
+ ## Features
16
+
17
+ - Recursively searches directories for FLAC + CUE pairs
18
+ - Automatic CUE file encoding detection (UTF-8, Latin-1, Shift-JIS, etc.)
19
+ - Skips albums that are already split
20
+ - Safe dry-run by default - see what would happen before committing
21
+ - Prompts before deleting source files
22
+ - Preserves CUE files after splitting
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.12+
27
+ - [ffmpeg](https://ffmpeg.org/download.html) installed and in PATH
28
+ - **Ubuntu/Debian**: `sudo apt install ffmpeg`
29
+ - **macOS**: `brew install ffmpeg`
30
+ - **Windows**: Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to PATH
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ # Using uv (recommended)
36
+ uv tool install flac-cue-split
37
+
38
+ # Using pipx
39
+ pipx install flac-cue-split
40
+
41
+ # Using pip
42
+ pip install flac-cue-split
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ # Dry run - see what would be split
49
+ flac-cue-split /path/to/music
50
+
51
+ # Actually split the files
52
+ flac-cue-split /path/to/music --execute
53
+
54
+ # Split and delete original FLAC files (prompts for each)
55
+ flac-cue-split /path/to/music --execute --delete
56
+
57
+ # Split and delete without prompts
58
+ flac-cue-split /path/to/music --execute --delete --yes
59
+
60
+ # Delete sources for already-split albums
61
+ flac-cue-split /path/to/music --delete --yes
62
+ ```
63
+
64
+ ### Example Output
65
+
66
+ ```
67
+ Found 2 album(s) in Music/
68
+
69
+ 1. The Elder Scrolls V: Skyrim - Atmospheres
70
+ Jeremy Soule | 2 tracks | 42m 54s
71
+ _Game OST/_Elder Scrolls/Jeremy Soule - Skyrim - Atmospheres/
72
+ 2 tracks extracted
73
+ Delete original FLAC? [Y/n]
74
+
75
+ 2. Dark Side of the Moon
76
+ Pink Floyd | 10 tracks | 42m 59s
77
+ Pink Floyd/1973 - Dark Side of the Moon/
78
+ 10 tracks extracted
79
+ Delete original FLAC? [Y/n]
80
+
81
+ Done
82
+ ```
83
+
84
+ ### Output Format
85
+
86
+ Tracks are saved as `{nn}. {track_name}.flac` where the number is zero-padded based on total track count (e.g., `01.` for <100 tracks, `001.` for 100-999, etc).
87
+
88
+ ## Options
89
+
90
+ | Option | Description |
91
+ |--------|-------------|
92
+ | `--execute` | Actually perform the split (default is dry-run) |
93
+ | `--delete` | Delete original FLAC files after splitting (prompts for confirmation) |
94
+ | `--output`, `-o` | Output directory for split files (default: same as source) |
95
+ | `--verbose`, `-v` | Show detailed track listings |
96
+ | `--yes`, `-y` | Auto-confirm prompts (use with `--delete`) |
97
+
98
+ ## Safety
99
+
100
+ - **Dry-run by default**: Nothing is modified unless you pass `--execute`
101
+ - **Deletion prompts**: `--delete` asks for confirmation unless `--yes` is specified
102
+ - **Error protection**: Source files are kept if any extraction errors occur
103
+ - **Permanent deletion**: `--delete` permanently removes files (not moved to trash)
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,9 @@
1
+ README.md
2
+ main.py
3
+ pyproject.toml
4
+ flac_cue_split.egg-info/PKG-INFO
5
+ flac_cue_split.egg-info/SOURCES.txt
6
+ flac_cue_split.egg-info/dependency_links.txt
7
+ flac_cue_split.egg-info/entry_points.txt
8
+ flac_cue_split.egg-info/requires.txt
9
+ flac_cue_split.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flac-cue-split = main:main
@@ -0,0 +1 @@
1
+ rich>=14.3.1
@@ -0,0 +1,632 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ FLAC + CUE Splitter
4
+
5
+ Recursively searches for FLAC + CUE file pairs and splits the FLAC files
6
+ into individual tracks based on the CUE sheet information.
7
+
8
+ Requires: ffmpeg to be installed and available in PATH
9
+ """
10
+
11
+ import argparse
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from rich.console import Console
19
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
20
+ from rich.theme import Theme
21
+
22
+ theme = Theme({
23
+ "album": "bold cyan",
24
+ "artist": "dim",
25
+ "info": "dim",
26
+ "folder": "blue",
27
+ "done": "green",
28
+ "pending": "yellow",
29
+ "error": "bold red",
30
+ })
31
+ console = Console(theme=theme)
32
+
33
+
34
+ @dataclass
35
+ class Track:
36
+ """Represents a single track from a CUE sheet."""
37
+ number: int
38
+ title: str
39
+ performer: str
40
+ start_time: str # MM:SS:FF format (frames are 1/75 second)
41
+
42
+ def start_seconds(self) -> float:
43
+ """Convert CUE timestamp to seconds."""
44
+ parts = self.start_time.split(":")
45
+ minutes = int(parts[0])
46
+ seconds = int(parts[1])
47
+ frames = int(parts[2]) if len(parts) > 2 else 0
48
+ return minutes * 60 + seconds + frames / 75.0
49
+
50
+ def safe_filename(self, total_tracks: int = 99) -> str:
51
+ """Generate a safe filename for this track."""
52
+ # Remove or replace characters that are problematic in filenames
53
+ safe_title = re.sub(r'[<>:"/\\|?*]', '_', self.title)
54
+ safe_title = safe_title.strip('. ')
55
+ # Pad track number based on total tracks (minimum 2 digits)
56
+ width = max(2, len(str(total_tracks)))
57
+ return f"{self.number:0{width}d}. {safe_title}.flac"
58
+
59
+
60
+ @dataclass
61
+ class CueSheet:
62
+ """Represents a parsed CUE sheet."""
63
+ album_title: str
64
+ album_performer: str
65
+ file_name: str
66
+ tracks: list[Track]
67
+
68
+
69
+ def parse_cue_file(cue_path: Path) -> CueSheet | None:
70
+ """Parse a CUE file and extract track information."""
71
+ try:
72
+ # Try different encodings commonly used in CUE files
73
+ content = None
74
+ for encoding in ['utf-8', 'utf-8-sig', 'latin-1', 'cp1252', 'shift-jis']:
75
+ try:
76
+ content = cue_path.read_text(encoding=encoding)
77
+ break
78
+ except UnicodeDecodeError:
79
+ continue
80
+
81
+ if content is None:
82
+ console.print(f"[error]Warning: Could not decode {cue_path}[/]")
83
+ return None
84
+
85
+ except OSError as e:
86
+ console.print(f"[error]Warning: Could not read {cue_path}: {e}[/]")
87
+ return None
88
+
89
+ album_title = ""
90
+ album_performer = ""
91
+ file_name = ""
92
+ tracks: list[Track] = []
93
+
94
+ current_track_num = 0
95
+ current_track_title = ""
96
+ current_track_performer = ""
97
+ current_track_index = ""
98
+ in_track = False
99
+
100
+ # Patterns for parsing
101
+ title_pattern = re.compile(r'TITLE\s+"([^"]*)"', re.IGNORECASE)
102
+ performer_pattern = re.compile(r'PERFORMER\s+"([^"]*)"', re.IGNORECASE)
103
+ file_pattern = re.compile(r'FILE\s+"([^"]+)"\s+\w+', re.IGNORECASE)
104
+ track_pattern = re.compile(r'TRACK\s+(\d+)\s+AUDIO', re.IGNORECASE)
105
+ index_pattern = re.compile(r'INDEX\s+01\s+(\d+:\d+:\d+)', re.IGNORECASE)
106
+
107
+ for line in content.splitlines():
108
+ line = line.strip()
109
+
110
+ # Check for FILE (before any TRACK)
111
+ file_match = file_pattern.match(line)
112
+ if file_match and not in_track:
113
+ file_name = file_match.group(1)
114
+ continue
115
+
116
+ # Check for TRACK
117
+ track_match = track_pattern.match(line)
118
+ if track_match:
119
+ # Save previous track if exists
120
+ if in_track and current_track_index:
121
+ tracks.append(Track(
122
+ number=current_track_num,
123
+ title=current_track_title or f"Track {current_track_num}",
124
+ performer=current_track_performer or album_performer,
125
+ start_time=current_track_index
126
+ ))
127
+
128
+ in_track = True
129
+ current_track_num = int(track_match.group(1))
130
+ current_track_title = ""
131
+ current_track_performer = ""
132
+ current_track_index = ""
133
+ continue
134
+
135
+ # Check for TITLE
136
+ title_match = title_pattern.search(line)
137
+ if title_match:
138
+ if in_track:
139
+ current_track_title = title_match.group(1)
140
+ else:
141
+ album_title = title_match.group(1)
142
+ continue
143
+
144
+ # Check for PERFORMER
145
+ performer_match = performer_pattern.search(line)
146
+ if performer_match:
147
+ if in_track:
148
+ current_track_performer = performer_match.group(1)
149
+ else:
150
+ album_performer = performer_match.group(1)
151
+ continue
152
+
153
+ # Check for INDEX 01
154
+ index_match = index_pattern.search(line)
155
+ if index_match and in_track:
156
+ current_track_index = index_match.group(1)
157
+ continue
158
+
159
+ # Don't forget the last track
160
+ if in_track and current_track_index:
161
+ tracks.append(Track(
162
+ number=current_track_num,
163
+ title=current_track_title or f"Track {current_track_num}",
164
+ performer=current_track_performer or album_performer,
165
+ start_time=current_track_index
166
+ ))
167
+
168
+ if not tracks:
169
+ return None
170
+
171
+ return CueSheet(
172
+ album_title=album_title,
173
+ album_performer=album_performer,
174
+ file_name=file_name,
175
+ tracks=tracks
176
+ )
177
+
178
+
179
+ def looks_like_track_file(filename: str) -> bool:
180
+ """Check if a FLAC filename looks like an individual track (vs full album)."""
181
+ # Common track naming patterns: "01.", "01 -", "Track 01", etc.
182
+ track_patterns = [
183
+ r'^\d{1,2}\.', # Starts with 1-2 digits and a period
184
+ r'^\d{1,2}\s*-', # Starts with 1-2 digits and a dash
185
+ r'^Track\s*\d+', # Starts with "Track" followed by number
186
+ r'^\d{1,2}\s+\w+', # Starts with 1-2 digits and space then word
187
+ ]
188
+ for pattern in track_patterns:
189
+ if re.match(pattern, filename, re.IGNORECASE):
190
+ return True
191
+ return False
192
+
193
+
194
+ def find_flac_cue_pairs(directory: Path) -> list[tuple[Path, Path]]:
195
+ """Recursively find all FLAC + CUE file pairs in a directory."""
196
+ pairs: list[tuple[Path, Path]] = []
197
+
198
+ # Group CUE files by directory
199
+ cue_by_dir: dict[Path, list[Path]] = {}
200
+ for cue_path in directory.rglob("*.cue"):
201
+ cue_dir = cue_path.parent
202
+ if cue_dir not in cue_by_dir:
203
+ cue_by_dir[cue_dir] = []
204
+ cue_by_dir[cue_dir].append(cue_path)
205
+
206
+ # Process each directory
207
+ for cue_dir, cue_files in cue_by_dir.items():
208
+ # If multiple CUE files, prioritize ones with 'flac' in filename
209
+ if len(cue_files) > 1:
210
+ flac_cues = [c for c in cue_files if 'flac' in c.name.lower()]
211
+ if flac_cues:
212
+ cue_files = flac_cues
213
+ else:
214
+ # Warn and use first
215
+ try:
216
+ rel_path = cue_dir.resolve().relative_to(directory.resolve())
217
+ except ValueError:
218
+ rel_path = cue_dir.name
219
+ cue_names = ", ".join(c.name for c in cue_files)
220
+ console.print(f"[info]Warning: Multiple CUE files in '{rel_path}/'[/]")
221
+ console.print(f"[info] Found: {cue_names}[/]")
222
+ console.print(f"[info] Using: {cue_files[0].name}[/]")
223
+ cue_files = [cue_files[0]]
224
+
225
+ # Find FLAC files in directory, excluding files that look like individual tracks
226
+ all_flac_files = list(cue_dir.glob("*.flac")) + list(cue_dir.glob("*.FLAC"))
227
+ album_flac_files = [f for f in all_flac_files if not looks_like_track_file(f.name)]
228
+
229
+ for cue_path in cue_files:
230
+ cue_stem = cue_path.stem
231
+ flac_path = None
232
+
233
+ # First try exact name match (CUE and FLAC have same stem)
234
+ # Skip if it looks like an individual track file
235
+ exact_match = cue_dir / f"{cue_stem}.flac"
236
+ if exact_match.exists() and not looks_like_track_file(exact_match.name):
237
+ flac_path = exact_match
238
+ else:
239
+ exact_match = cue_dir / f"{cue_stem}.FLAC"
240
+ if exact_match.exists() and not looks_like_track_file(exact_match.name):
241
+ flac_path = exact_match
242
+
243
+ # If no exact match, try to find from CUE content
244
+ # Skip if the referenced file looks like an individual track
245
+ if not flac_path:
246
+ cue_sheet = parse_cue_file(cue_path)
247
+ if cue_sheet and cue_sheet.file_name:
248
+ referenced_flac = cue_dir / cue_sheet.file_name
249
+ if referenced_flac.exists() and not looks_like_track_file(referenced_flac.name):
250
+ flac_path = referenced_flac
251
+
252
+ # If still no match, look for album FLAC whose stem is contained in the CUE filename
253
+ # Only consider files that don't look like individual tracks
254
+ if not flac_path and album_flac_files:
255
+ cue_name_lower = cue_path.name.lower()
256
+ for flac_file in album_flac_files:
257
+ flac_stem_lower = flac_file.stem.lower()
258
+ # Check if FLAC stem is contained in CUE filename
259
+ if flac_stem_lower in cue_name_lower:
260
+ flac_path = flac_file
261
+ break
262
+
263
+ if flac_path:
264
+ pairs.append((flac_path, cue_path))
265
+
266
+ return pairs
267
+
268
+
269
+ def is_already_split(cue_sheet: CueSheet, output_dir: Path) -> bool:
270
+ """Check if all expected track files already exist in the output directory."""
271
+ if not output_dir.exists():
272
+ return False
273
+ total_tracks = len(cue_sheet.tracks)
274
+ for track in cue_sheet.tracks:
275
+ expected_file = output_dir / track.safe_filename(total_tracks)
276
+ if not expected_file.exists():
277
+ return False
278
+ return True
279
+
280
+
281
+ def check_ffmpeg() -> bool:
282
+ """Check if ffmpeg is available."""
283
+ try:
284
+ result = subprocess.run(
285
+ ["ffmpeg", "-version"],
286
+ capture_output=True,
287
+ text=True
288
+ )
289
+ return result.returncode == 0
290
+ except FileNotFoundError:
291
+ return False
292
+
293
+
294
+ def get_flac_duration(flac_path: Path) -> float | None:
295
+ """Get the duration of a FLAC file in seconds using ffprobe."""
296
+ try:
297
+ result = subprocess.run(
298
+ [
299
+ "ffprobe",
300
+ "-v", "error",
301
+ "-show_entries", "format=duration",
302
+ "-of", "default=noprint_wrappers=1:nokey=1",
303
+ str(flac_path)
304
+ ],
305
+ capture_output=True,
306
+ text=True
307
+ )
308
+ if result.returncode == 0 and result.stdout.strip():
309
+ return float(result.stdout.strip())
310
+ except (FileNotFoundError, ValueError):
311
+ pass
312
+ return None
313
+
314
+
315
+ def split_flac(
316
+ flac_path: Path,
317
+ cue_sheet: CueSheet,
318
+ output_dir: Path,
319
+ execute: bool = False,
320
+ progress: Progress | None = None,
321
+ task_id: int | None = None,
322
+ ) -> tuple[int, int]:
323
+ """
324
+ Split a FLAC file into individual tracks.
325
+
326
+ Returns (success_count, error_count).
327
+ """
328
+ output_dir.mkdir(parents=True, exist_ok=True)
329
+ tracks = cue_sheet.tracks
330
+ total_tracks = len(tracks)
331
+ success = 0
332
+ errors = 0
333
+
334
+ for i, track in enumerate(tracks):
335
+ output_file = output_dir / track.safe_filename(total_tracks)
336
+ start_time = track.start_seconds()
337
+
338
+ # Determine end time (start of next track, or end of file)
339
+ if i + 1 < len(tracks):
340
+ end_time = tracks[i + 1].start_seconds()
341
+ duration = end_time - start_time
342
+ duration_args = ["-t", f"{duration:.3f}"]
343
+ else:
344
+ duration_args = []
345
+
346
+ cmd = [
347
+ "ffmpeg",
348
+ "-i", str(flac_path),
349
+ "-ss", f"{start_time:.3f}",
350
+ *duration_args,
351
+ "-c:a", "flac",
352
+ "-compression_level", "8",
353
+ "-metadata", f"title={track.title}",
354
+ "-metadata", f"artist={track.performer}",
355
+ "-metadata", f"album={cue_sheet.album_title}",
356
+ "-metadata", f"track={track.number}/{len(tracks)}",
357
+ "-y",
358
+ str(output_file)
359
+ ]
360
+
361
+ if execute:
362
+ result = subprocess.run(cmd, capture_output=True, text=True)
363
+ if result.returncode != 0:
364
+ errors += 1
365
+ else:
366
+ success += 1
367
+
368
+ if progress and task_id is not None:
369
+ progress.update(task_id, advance=1)
370
+
371
+ return success, errors
372
+
373
+
374
+ def path_arg(value: str) -> Path:
375
+ """Convert path string to Path, handling Git Bash /c/... style paths."""
376
+ if len(value) >= 3 and value[0] == '/' and value[2] == '/':
377
+ value = f"{value[1].upper()}:{value[2:]}"
378
+ return Path(value).expanduser()
379
+
380
+
381
+ def format_duration_seconds(total_secs: float) -> str:
382
+ """Format seconds as human-readable duration."""
383
+ total_secs = int(total_secs)
384
+ if total_secs < 60:
385
+ return f"{total_secs}s"
386
+ mins, secs = divmod(total_secs, 60)
387
+ if mins >= 60:
388
+ hrs, mins = divmod(mins, 60)
389
+ return f"{hrs}h {mins}m {secs}s"
390
+ return f"{mins}m {secs}s"
391
+
392
+
393
+ def format_duration(tracks: list[Track], total_duration: float | None = None) -> str:
394
+ """Format total duration. Uses actual duration if provided, otherwise estimates from last track."""
395
+ if total_duration is not None:
396
+ return format_duration_seconds(total_duration)
397
+ if not tracks:
398
+ return "?"
399
+ last = tracks[-1]
400
+ total_secs = int(last.start_seconds())
401
+ if total_secs < 60:
402
+ return "?"
403
+ return "~" + format_duration_seconds(total_secs)
404
+
405
+
406
+ def relative_path(path: Path, base: Path) -> str:
407
+ """Get relative path string, or just the name if not under base."""
408
+ try:
409
+ return str(path.resolve().relative_to(base.resolve()))
410
+ except ValueError:
411
+ return path.name
412
+
413
+
414
+ def main() -> int:
415
+ parser = argparse.ArgumentParser(
416
+ description="Split FLAC files based on CUE sheets",
417
+ formatter_class=argparse.RawDescriptionHelpFormatter,
418
+ epilog="""
419
+ Examples:
420
+ %(prog)s /path/to/music # Dry run - show what would be done
421
+ %(prog)s /path/to/music --execute # Actually split the files
422
+ %(prog)s . --execute --delete # Split and delete originals
423
+ %(prog)s . --delete --yes # Delete already-split sources (no prompts)
424
+ """
425
+ )
426
+ parser.add_argument(
427
+ "directory",
428
+ type=path_arg,
429
+ help="Directory to search for FLAC + CUE pairs"
430
+ )
431
+ parser.add_argument(
432
+ "--execute",
433
+ action="store_true",
434
+ help="Actually perform the split (default is dry-run)"
435
+ )
436
+ parser.add_argument(
437
+ "--output", "-o",
438
+ type=path_arg,
439
+ default=None,
440
+ help="Output directory for split files (default: same as source)"
441
+ )
442
+ parser.add_argument(
443
+ "--delete",
444
+ action="store_true",
445
+ help="Delete original FLAC files after splitting (keeps .cue files)"
446
+ )
447
+ parser.add_argument(
448
+ "--verbose", "-v",
449
+ action="store_true",
450
+ help="Show track listings"
451
+ )
452
+ parser.add_argument(
453
+ "--yes", "-y",
454
+ action="store_true",
455
+ help="Auto-select default option for prompts"
456
+ )
457
+
458
+ args = parser.parse_args()
459
+
460
+ # Validate directory
461
+ if not args.directory.exists():
462
+ console.print(f"[error]Error:[/] Directory does not exist: {args.directory}")
463
+ return 1
464
+
465
+ if not args.directory.is_dir():
466
+ console.print(f"[error]Error:[/] Not a directory: {args.directory}")
467
+ return 1
468
+
469
+ # Check for ffmpeg
470
+ if not check_ffmpeg():
471
+ console.print("[error]Error:[/] ffmpeg is not installed or not in PATH")
472
+ console.print("[info]Install: https://ffmpeg.org/download.html[/]")
473
+ return 1
474
+
475
+ # Find pairs
476
+ with console.status("[info]Scanning for albums...[/]", spinner="dots"):
477
+ pairs = find_flac_cue_pairs(args.directory)
478
+
479
+ if not pairs:
480
+ console.print("[info]No FLAC + CUE pairs found.[/]")
481
+ return 0
482
+
483
+ base_dir = args.directory.resolve()
484
+
485
+ # Build album info
486
+ albums = []
487
+ for flac_path, cue_path in pairs:
488
+ cue_sheet = parse_cue_file(cue_path)
489
+ if args.output:
490
+ try:
491
+ rel = flac_path.parent.resolve().relative_to(base_dir)
492
+ output_dir = args.output.resolve() / rel
493
+ except ValueError:
494
+ output_dir = args.output.resolve()
495
+ else:
496
+ output_dir = flac_path.parent
497
+ already_split = is_already_split(cue_sheet, output_dir) if cue_sheet else False
498
+ albums.append((flac_path, cue_path, cue_sheet, output_dir, already_split))
499
+
500
+ pending = sum(1 for *_, done in albums if not done)
501
+ done_count = len(albums) - pending
502
+
503
+ # Header
504
+ console.print()
505
+ console.print(f"[bold]Found {len(albums)} album(s)[/] in [folder]{base_dir.name}/[/]")
506
+ if done_count > 0 and not args.execute:
507
+ console.print(f"[done]{done_count} already split[/], [pending]{pending} pending[/]")
508
+ console.print()
509
+
510
+ # Display albums
511
+ for i, (flac_path, cue_path, cue_sheet, output_dir, already_split) in enumerate(albums, 1):
512
+ folder = relative_path(flac_path.parent, base_dir)
513
+
514
+ if not cue_sheet:
515
+ console.print(f"[info]{i:2}.[/] [folder]{folder}/[/]")
516
+ console.print(f" [error]Could not parse CUE file, skipping[/]")
517
+ console.print()
518
+ continue
519
+
520
+ album = cue_sheet.album_title or "(unknown album)"
521
+ artist = cue_sheet.album_performer or "(unknown artist)"
522
+ n_tracks = len(cue_sheet.tracks)
523
+
524
+ # Get actual FLAC duration for accurate album + last track duration
525
+ flac_duration = get_flac_duration(flac_path)
526
+ duration = format_duration(cue_sheet.tracks, flac_duration)
527
+
528
+ # Use green styling for already-split albums
529
+ if already_split and not args.execute:
530
+ console.print(f"[done]{i:2}. {album}[/]")
531
+ console.print(f" [done]{artist} | {n_tracks} tracks | {duration}[/]")
532
+ console.print(f" [done]{folder}/[/]")
533
+ else:
534
+ console.print(f"[info]{i:2}.[/] [album]{album}[/]")
535
+ console.print(f" [artist]{artist}[/] [info]|[/] {n_tracks} tracks [info]|[/] {duration}")
536
+ console.print(f" [folder]{folder}/[/]")
537
+
538
+ if args.verbose:
539
+ console.print(f" [info]FLAC: {flac_path.name}[/]")
540
+ console.print(f" [info]CUE: {cue_path.name}[/]")
541
+ tracks = cue_sheet.tracks
542
+ # Calculate max lengths for alignment
543
+ max_title_len = max(len(t.title) for t in tracks)
544
+ max_time_len = max(len(t.start_time) for t in tracks)
545
+ for j, track in enumerate(tracks):
546
+ # Calculate track duration
547
+ if j + 1 < len(tracks):
548
+ duration_secs = tracks[j + 1].start_seconds() - track.start_seconds()
549
+ track_dur = format_duration_seconds(duration_secs)
550
+ elif flac_duration is not None:
551
+ # Last track: use FLAC duration to calculate
552
+ duration_secs = flac_duration - track.start_seconds()
553
+ track_dur = format_duration_seconds(duration_secs)
554
+ else:
555
+ track_dur = "?"
556
+ padded_title = track.title.ljust(max_title_len)
557
+ padded_time = track.start_time.rjust(max_time_len)
558
+ console.print(f" [info]{track.number:2}.[/] {padded_title} [info]{padded_time} {track_dur:>8}[/]")
559
+
560
+ # Track if split had errors (to prevent deletion on failure)
561
+ split_had_errors = False
562
+
563
+ if args.execute and not already_split:
564
+ with Progress(
565
+ SpinnerColumn(),
566
+ TextColumn("[progress.description]{task.description}"),
567
+ BarColumn(bar_width=20),
568
+ TaskProgressColumn(),
569
+ console=console,
570
+ transient=True,
571
+ ) as progress:
572
+ task = progress.add_task("Splitting", total=n_tracks)
573
+ success, errors = split_flac(flac_path, cue_sheet, output_dir, execute=True, progress=progress, task_id=task)
574
+ if errors:
575
+ split_had_errors = True
576
+ console.print(f" [done]{success} tracks[/] [error]({errors} errors)[/]")
577
+ else:
578
+ console.print(f" [done]{n_tracks} tracks extracted[/]")
579
+ elif args.execute and already_split:
580
+ console.print(f" [info]Already split, skipping[/]")
581
+
582
+ # Handle --delete
583
+ if args.delete and flac_path.exists():
584
+ if args.execute:
585
+ if split_had_errors:
586
+ console.print(f" [error]Source kept due to extraction errors[/]")
587
+ else:
588
+ # Default is Y (delete) for successfully split albums
589
+ if args.yes:
590
+ response = 'y'
591
+ else:
592
+ response = console.input(" Delete original FLAC? [Y/n] ").strip().lower()
593
+ if response != 'n':
594
+ flac_path.unlink()
595
+ console.print(f" [info]Source deleted[/]")
596
+ elif already_split:
597
+ # Default is Y (delete) for already-split albums
598
+ if args.yes:
599
+ response = 'y'
600
+ else:
601
+ response = console.input(" Delete original FLAC? [Y/n] ").strip().lower()
602
+ if response != 'n':
603
+ flac_path.unlink()
604
+ console.print(f" [done]Deleted[/]")
605
+ else:
606
+ # Default is N (keep) for not-yet-split albums
607
+ if args.yes:
608
+ response = 'n'
609
+ console.print(f" [info]Not yet split, keeping source[/]")
610
+ else:
611
+ console.print(f" [pending]Not yet split[/]")
612
+ response = console.input(" Delete anyway? [y/N] ").strip().lower()
613
+ if response == 'y':
614
+ flac_path.unlink()
615
+ console.print(f" [done]Deleted[/]")
616
+
617
+ console.print()
618
+
619
+ # Footer
620
+ if not args.execute:
621
+ if pending > 0:
622
+ console.print(f"[info]Dry run complete.[/] Run with [bold]--execute[/] to split {pending} album(s).")
623
+ else:
624
+ console.print("[done]All albums already split.[/]")
625
+ else:
626
+ console.print("[done]Done.[/]")
627
+
628
+ return 0
629
+
630
+
631
+ if __name__ == "__main__":
632
+ sys.exit(main())
@@ -0,0 +1,12 @@
1
+ [project]
2
+ name = "flac-cue-split"
3
+ version = "0.1.2"
4
+ description = "Split FLAC files into individual tracks based on CUE sheets"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "rich>=14.3.1",
9
+ ]
10
+
11
+ [project.scripts]
12
+ flac-cue-split = "main:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+