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.
- flac_cue_split-0.1.2/PKG-INFO +107 -0
- flac_cue_split-0.1.2/README.md +99 -0
- flac_cue_split-0.1.2/flac_cue_split.egg-info/PKG-INFO +107 -0
- flac_cue_split-0.1.2/flac_cue_split.egg-info/SOURCES.txt +9 -0
- flac_cue_split-0.1.2/flac_cue_split.egg-info/dependency_links.txt +1 -0
- flac_cue_split-0.1.2/flac_cue_split.egg-info/entry_points.txt +2 -0
- flac_cue_split-0.1.2/flac_cue_split.egg-info/requires.txt +1 -0
- flac_cue_split-0.1.2/flac_cue_split.egg-info/top_level.txt +1 -0
- flac_cue_split-0.1.2/main.py +632 -0
- flac_cue_split-0.1.2/pyproject.toml +12 -0
- flac_cue_split-0.1.2/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rich>=14.3.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
main
|
|
@@ -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"
|