smclipy 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,74 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ checks-and-build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v5
16
+
17
+ - name: Lint
18
+ run: uv run ruff check
19
+
20
+ - name: Typecheck
21
+ run: uv run mypy
22
+
23
+ - name: Test
24
+ run: uv run pytest
25
+
26
+ - name: Build distributions
27
+ run: uv build
28
+
29
+ - name: Upload dist artifacts
30
+ uses: actions/upload-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+
35
+ publish-testpypi:
36
+ needs: checks-and-build
37
+ if: github.event_name == 'workflow_dispatch'
38
+ runs-on: ubuntu-latest
39
+ permissions:
40
+ id-token: write
41
+ steps:
42
+ - name: Download dist artifacts
43
+ uses: actions/download-artifact@v4
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+
48
+ - name: Install uv
49
+ uses: astral-sh/setup-uv@v5
50
+
51
+ - name: Publish to TestPyPI
52
+ run: >-
53
+ uv publish
54
+ --publish-url https://test.pypi.org/legacy/
55
+ --check-url https://test.pypi.org/simple/
56
+
57
+ publish-pypi:
58
+ needs: checks-and-build
59
+ if: github.event_name == 'release'
60
+ runs-on: ubuntu-latest
61
+ permissions:
62
+ id-token: write
63
+ steps:
64
+ - name: Download dist artifacts
65
+ uses: actions/download-artifact@v4
66
+ with:
67
+ name: dist
68
+ path: dist/
69
+
70
+ - name: Install uv
71
+ uses: astral-sh/setup-uv@v5
72
+
73
+ - name: Publish to PyPI
74
+ run: uv publish
@@ -0,0 +1,25 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Tool caches
13
+ .mypy_cache/
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ .coverage.*
18
+ htmlcov/
19
+
20
+ # smclipy
21
+ .temp/
22
+ Music/
23
+ config.json
24
+ temp.txt
25
+ roadmap.md
@@ -0,0 +1 @@
1
+ 3.12
smclipy-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joaquin Larroca
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.
smclipy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: smclipy
3
+ Version: 0.1.0
4
+ Summary: Simple music CLI to download, tag, and organize music from YouTube
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: climage>=0.2.2
8
+ Requires-Dist: mutagen>=1.47.0
9
+ Requires-Dist: pillow>=12.2.0
10
+ Requires-Dist: prompt-toolkit>=3.0.52
11
+ Requires-Dist: yt-dlp[default]>=2026.3.17
12
+ Description-Content-Type: text/markdown
13
+
14
+ # smclipy
15
+
16
+ **simple music cli py** is a Python script designed to streamline downloading, tagging, and organizing music from YouTube. It allows you to batch-download audio, manually set metadata (including multiple artists and cover art).
17
+
18
+ ## Features
19
+
20
+ - **Batch Downloading:** Queue up as many YouTube URLs as you want before processing.
21
+ - **Interactive Tagging:** Prompts you for the track Title and Artists (has autocompletion). Both come prefilled: the title from the video's title, and the artists from the channel name, corrected against your known authors.
22
+ - **Cover Art Cropping:** Easily crop your cover art to a perfect 1:1 square ratio during the tagging process.
23
+ - **Clean Cleanup:** Uses a `.temp` directory during the download and tagging process to keep your main library clean.
24
+
25
+ ## Requirements
26
+
27
+ - **Python 3.12+** (handled automatically if you use `uv`).
28
+ - **FFmpeg** — required by yt-dlp to extract the audio to MP3 and embed the cover art.
29
+ - **[Deno](https://docs.deno.com/runtime/getting_started/installation/)** — required by yt-dlp (2025.11+) to solve YouTube's JavaScript challenges during extraction. Without it, downloads may fail or have limited format availability.
30
+
31
+ ## Installation
32
+
33
+ Install it as a standalone CLI tool:
34
+
35
+ ```bash
36
+ uv tool install smclipy
37
+ ```
38
+
39
+ or, if you prefer pipx:
40
+
41
+ ```bash
42
+ pipx install smclipy
43
+ ```
44
+
45
+ ## Configuration
46
+
47
+ smclipy relies on a configuration file to know where to organize your files.
48
+
49
+ On first run, a default config file is created at `~/.config/smclipy/config.json` and the program exits so you can edit it to your liking. To store it somewhere else, set the `SMCLIPY_CONFIG` environment variable to your preferred path (respects `$XDG_CONFIG_HOME`).
50
+
51
+ ```json
52
+ {
53
+ "name": "smclipy",
54
+ "path_to_music_folder": "./Music",
55
+ "description_max_lines": 5
56
+ }
57
+ ```
58
+
59
+ ### Config Breakdown
60
+
61
+ - `name`: The master folder name (`smclipy`) that will be created inside your music folder to hold all the organized artists.
62
+ - `path_to_music_folder`: The base directory where your music library lives (default is `./Music`, relative to wherever you run the command from).
63
+ - `description_max_lines`: How many lines of the video description to show while tagging (default is 5).
64
+
65
+ ## Usage
66
+
67
+ Run it from your music library's parent directory:
68
+
69
+ ```bash
70
+ smclipy <command>
71
+ ```
72
+
73
+ ### Commands
74
+
75
+ - **`smclipy download`** — Batch download and tag songs from YouTube.
76
+ - **`smclipy crop`** — Scan saved covers for pillarboxed art, crop to 1:1, and re-embed into the matching MP3s.
77
+
78
+ ### Download flow
79
+
80
+ 1. **Queue URLs:** Paste your YouTube URLs one by one (enter an empty line to finish).
81
+ 2. **Tagging Flow (Per Track):**
82
+ - The script will download the current track to `.temp`.
83
+ - If a cover image is found, you will be prompted to crop it to a 1:1 ratio.
84
+ - Enter the **Title** of the track (prefilled with the video's title).
85
+ - Enter the **Artist/s** (prefilled from the channel name; if it matches an author already in `authors.txt` — ignoring case and spaces — your existing spelling is kept). To tag multiple artists, separate them using a backslash `\` (e.g., `Artist 1\Artist 2\Artist 3`).
86
+
87
+ 3. **Completion:** Once tagged, the final music file is moved to your designated music folder, using the following filename artist-title.mp3 (e.g., `Rick Astley-Never Gonna Give You Up.mp3`).
88
+
89
+ ## Folder Structure Example
90
+
91
+ After running the script and tagging a few songs, your output directory will look something like this:
92
+
93
+ ```text
94
+ 📁 ./.temp <-- (Used temporarily during processing)
95
+ 📁 ./Music
96
+ ├──🎵 artist1-title1.mp3
97
+ ├──🎵 artist1-title2.mp3
98
+ ├──🎵 artist2-title1.mp3
99
+ ├──🎵 artist3-title1.mp3
100
+ └── ...
101
+ ```
102
+
103
+ ## Development
104
+
105
+ Clone the repo and run it from the project root with uv (set `SMCLIPY_CONFIG` to use your existing `config.json`):
106
+
107
+ ```bash
108
+ uv run smclipy <command>
109
+ ```
@@ -0,0 +1,96 @@
1
+ # smclipy
2
+
3
+ **simple music cli py** is a Python script designed to streamline downloading, tagging, and organizing music from YouTube. It allows you to batch-download audio, manually set metadata (including multiple artists and cover art).
4
+
5
+ ## Features
6
+
7
+ - **Batch Downloading:** Queue up as many YouTube URLs as you want before processing.
8
+ - **Interactive Tagging:** Prompts you for the track Title and Artists (has autocompletion). Both come prefilled: the title from the video's title, and the artists from the channel name, corrected against your known authors.
9
+ - **Cover Art Cropping:** Easily crop your cover art to a perfect 1:1 square ratio during the tagging process.
10
+ - **Clean Cleanup:** Uses a `.temp` directory during the download and tagging process to keep your main library clean.
11
+
12
+ ## Requirements
13
+
14
+ - **Python 3.12+** (handled automatically if you use `uv`).
15
+ - **FFmpeg** — required by yt-dlp to extract the audio to MP3 and embed the cover art.
16
+ - **[Deno](https://docs.deno.com/runtime/getting_started/installation/)** — required by yt-dlp (2025.11+) to solve YouTube's JavaScript challenges during extraction. Without it, downloads may fail or have limited format availability.
17
+
18
+ ## Installation
19
+
20
+ Install it as a standalone CLI tool:
21
+
22
+ ```bash
23
+ uv tool install smclipy
24
+ ```
25
+
26
+ or, if you prefer pipx:
27
+
28
+ ```bash
29
+ pipx install smclipy
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ smclipy relies on a configuration file to know where to organize your files.
35
+
36
+ On first run, a default config file is created at `~/.config/smclipy/config.json` and the program exits so you can edit it to your liking. To store it somewhere else, set the `SMCLIPY_CONFIG` environment variable to your preferred path (respects `$XDG_CONFIG_HOME`).
37
+
38
+ ```json
39
+ {
40
+ "name": "smclipy",
41
+ "path_to_music_folder": "./Music",
42
+ "description_max_lines": 5
43
+ }
44
+ ```
45
+
46
+ ### Config Breakdown
47
+
48
+ - `name`: The master folder name (`smclipy`) that will be created inside your music folder to hold all the organized artists.
49
+ - `path_to_music_folder`: The base directory where your music library lives (default is `./Music`, relative to wherever you run the command from).
50
+ - `description_max_lines`: How many lines of the video description to show while tagging (default is 5).
51
+
52
+ ## Usage
53
+
54
+ Run it from your music library's parent directory:
55
+
56
+ ```bash
57
+ smclipy <command>
58
+ ```
59
+
60
+ ### Commands
61
+
62
+ - **`smclipy download`** — Batch download and tag songs from YouTube.
63
+ - **`smclipy crop`** — Scan saved covers for pillarboxed art, crop to 1:1, and re-embed into the matching MP3s.
64
+
65
+ ### Download flow
66
+
67
+ 1. **Queue URLs:** Paste your YouTube URLs one by one (enter an empty line to finish).
68
+ 2. **Tagging Flow (Per Track):**
69
+ - The script will download the current track to `.temp`.
70
+ - If a cover image is found, you will be prompted to crop it to a 1:1 ratio.
71
+ - Enter the **Title** of the track (prefilled with the video's title).
72
+ - Enter the **Artist/s** (prefilled from the channel name; if it matches an author already in `authors.txt` — ignoring case and spaces — your existing spelling is kept). To tag multiple artists, separate them using a backslash `\` (e.g., `Artist 1\Artist 2\Artist 3`).
73
+
74
+ 3. **Completion:** Once tagged, the final music file is moved to your designated music folder, using the following filename artist-title.mp3 (e.g., `Rick Astley-Never Gonna Give You Up.mp3`).
75
+
76
+ ## Folder Structure Example
77
+
78
+ After running the script and tagging a few songs, your output directory will look something like this:
79
+
80
+ ```text
81
+ 📁 ./.temp <-- (Used temporarily during processing)
82
+ 📁 ./Music
83
+ ├──🎵 artist1-title1.mp3
84
+ ├──🎵 artist1-title2.mp3
85
+ ├──🎵 artist2-title1.mp3
86
+ ├──🎵 artist3-title1.mp3
87
+ └── ...
88
+ ```
89
+
90
+ ## Development
91
+
92
+ Clone the repo and run it from the project root with uv (set `SMCLIPY_CONFIG` to use your existing `config.json`):
93
+
94
+ ```bash
95
+ uv run smclipy <command>
96
+ ```
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "smclipy",
3
+ "path_to_music_folder": "./Music",
4
+ "description_max_lines": 5
5
+ }
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "smclipy"
3
+ version = "0.1.0"
4
+ description = "Simple music CLI to download, tag, and organize music from YouTube"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "climage>=0.2.2",
9
+ "mutagen>=1.47.0",
10
+ "pillow>=12.2.0",
11
+ "prompt-toolkit>=3.0.52",
12
+ "yt-dlp[default]>=2026.3.17",
13
+ ]
14
+
15
+ [project.scripts]
16
+ smclipy = "smclipy.cli:main"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "mypy>=1.14.0",
21
+ "pytest>=8.3.0",
22
+ "ruff>=0.9.0",
23
+ ]
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.uv]
30
+ package = true
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["smclipy"]
34
+
35
+ [tool.ruff]
36
+ target-version = "py312"
37
+ line-length = 88
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "UP", "B", "SIM", "C4"]
41
+
42
+ [tool.mypy]
43
+ python_version = "3.12"
44
+ files = ["smclipy"]
45
+
46
+ [[tool.mypy.overrides]]
47
+ module = ["yt_dlp.*", "climage.*"]
48
+ ignore_missing_imports = true
@@ -0,0 +1,3 @@
1
+ """smclipy - simple music cli py."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from smclipy.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,201 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from smclipy.config import Settings, init, settings
5
+ from smclipy.downloader import (
6
+ VideoDownloadError,
7
+ download,
8
+ extract_video_ids,
9
+ get_yt_channel_author,
10
+ )
11
+ from smclipy.helpers import (
12
+ get_list_from_split_str,
13
+ get_unique_items,
14
+ resolve_known_authors,
15
+ )
16
+ from smclipy.images import crop_image_1_to_1, display_image, is_image_pillarbox
17
+ from smclipy.metadata import (
18
+ change_cover,
19
+ get_all_names,
20
+ get_image_from_file,
21
+ save_all_authors,
22
+ save_all_covers,
23
+ save_all_songs_info,
24
+ save_song_temp_to_main,
25
+ )
26
+ from smclipy.storage import append_unique_lines, read_lines, write_lines
27
+ from smclipy.ui import (
28
+ prompt_authors,
29
+ prompt_crop,
30
+ prompt_resume,
31
+ prompt_title,
32
+ show_yt_video_info,
33
+ )
34
+
35
+
36
+ def collect_video_ids() -> list[str]:
37
+ print("Enter '' as url to finish inputting url's")
38
+ videos_id_list: list[str] = []
39
+ while True:
40
+ try:
41
+ url = input("Enter your URL: ")
42
+ if url == "":
43
+ break
44
+ videos_id_list.extend(extract_video_ids(url))
45
+ except KeyboardInterrupt:
46
+ print("\nExiting...")
47
+ sys.exit(0)
48
+ return videos_id_list
49
+
50
+
51
+ def process_video(video_id: str, authors_list: list[str]) -> None:
52
+ s = settings()
53
+ temp_mp3 = s.temp_folder.joinpath("temp.mp3")
54
+ temp_png = s.temp_folder.joinpath("temp.png")
55
+
56
+ for temp_file in (temp_mp3, temp_png):
57
+ if temp_file.is_file():
58
+ temp_file.unlink()
59
+
60
+ info_dictionary = download(video_id)
61
+ has_cover = get_image_from_file(temp_mp3, s.temp_folder, "temp.png")
62
+
63
+ if has_cover:
64
+ print("\n\n")
65
+ display_image(temp_png)
66
+
67
+ if prompt_crop(default="False"):
68
+ crop_image_1_to_1(temp_png)
69
+
70
+ print("\n\n")
71
+ title = prompt_title(default=str(info_dictionary.get("title", "")))
72
+
73
+ print("\n\n")
74
+ show_yt_video_info(info_dictionary)
75
+
76
+ yt_artists = get_yt_channel_author(info_dictionary)
77
+ authors_default = "\\".join(resolve_known_authors(yt_artists, authors_list))
78
+ authors = prompt_authors(authors_list, default=authors_default)
79
+
80
+ current_authors_list = get_list_from_split_str(authors, "\\")
81
+ for item in get_unique_items(current_authors_list, authors_list):
82
+ authors_list.append(item)
83
+
84
+ save_song_temp_to_main(temp_mp3, temp_png, title, authors)
85
+
86
+ append_unique_lines(s.authors_file, current_authors_list)
87
+ append_unique_lines(s.songs_info, [f"{title} - {', '.join(current_authors_list)}"])
88
+
89
+
90
+ def filter_processed_ids(pending_ids: list[str], processed_ids: list[str]) -> list[str]:
91
+ processed_set = set(processed_ids)
92
+ return [video_id for video_id in pending_ids if video_id not in processed_set]
93
+
94
+
95
+ def _collect_new_queue(s: Settings) -> tuple[list[str], list[str]]:
96
+ pending_ids = collect_video_ids()
97
+ write_lines(s.pending_ids_file, pending_ids)
98
+ write_lines(s.processed_ids_file, [])
99
+ return pending_ids, []
100
+
101
+
102
+ def cmd_download(args: argparse.Namespace) -> None:
103
+ s = init()
104
+
105
+ print("Scanning music files...")
106
+ save_all_authors()
107
+ save_all_songs_info()
108
+
109
+ print("Fetching all authors names...")
110
+ authors_list: list[str] = get_all_names()
111
+
112
+ pending_ids = read_lines(s.pending_ids_file)
113
+ processed_ids = read_lines(s.processed_ids_file)
114
+ remaining_ids = filter_processed_ids(pending_ids, processed_ids)
115
+
116
+ if remaining_ids and prompt_resume():
117
+ pending_ids, processed_ids = remaining_ids, []
118
+ else:
119
+ pending_ids, processed_ids = _collect_new_queue(s)
120
+
121
+ print(f"Preparing to download {len(pending_ids)} vid/s")
122
+ skipped_ids: list[str] = []
123
+ for video_id in pending_ids:
124
+ try:
125
+ process_video(video_id, authors_list)
126
+ except VideoDownloadError as exc:
127
+ print(f"Skipping '{video_id}': {exc}")
128
+ skipped_ids.append(video_id)
129
+ continue
130
+ except KeyboardInterrupt:
131
+ print("\nInterrupted, exiting...")
132
+ break
133
+ append_unique_lines(s.processed_ids_file, [video_id])
134
+ processed_ids.append(video_id)
135
+
136
+ if skipped_ids:
137
+ print(
138
+ f"Finished with {len(skipped_ids)} skipped vid/s "
139
+ f"(still pending for a retry): {skipped_ids}"
140
+ )
141
+
142
+
143
+ def cmd_crop(args: argparse.Namespace) -> None:
144
+ s = init()
145
+
146
+ print("Scanning music files...")
147
+ save_all_covers()
148
+
149
+ print("Scanning images files...")
150
+ covers_to_crop: list[str] = [
151
+ file.stem
152
+ for file in s.covers_folder.glob("*.png")
153
+ if file.is_file() and is_image_pillarbox(file)
154
+ ]
155
+
156
+ print(f"Reading {s.false_positives_file.name}...")
157
+ false_positives = read_lines(s.false_positives_file)
158
+
159
+ if not covers_to_crop:
160
+ print("No images to crop...")
161
+ else:
162
+ for filename in covers_to_crop:
163
+ if filename.strip() in false_positives:
164
+ continue
165
+ cover = s.covers_folder.joinpath(f"{filename}.png")
166
+ display_image(cover)
167
+ if prompt_crop(default="True"):
168
+ crop_image_1_to_1(cover)
169
+ mp3_file = s.music_folder.joinpath(f"{filename}.mp3")
170
+ if mp3_file.is_file():
171
+ change_cover(cover, mp3_file)
172
+ else:
173
+ print(f"{filename}: MP3 not found, skipping cover re-embed")
174
+ else:
175
+ false_positives.append(filename)
176
+ print("\n\n")
177
+ append_unique_lines(s.false_positives_file, false_positives)
178
+
179
+
180
+ def main(argv: list[str] | None = None) -> None:
181
+ parser = argparse.ArgumentParser(
182
+ prog="smclipy",
183
+ description="Download, tag, and organize music from YouTube.",
184
+ )
185
+ subparsers = parser.add_subparsers(dest="command", required=True)
186
+
187
+ subparsers.add_parser(
188
+ "download",
189
+ help="Batch download songs from YouTube and tag them interactively.",
190
+ )
191
+ subparsers.add_parser(
192
+ "crop",
193
+ help="Crop pillarboxed cover images to a 1:1 ratio and re-embed them.",
194
+ )
195
+
196
+ args = parser.parse_args(argv)
197
+
198
+ if args.command == "download":
199
+ cmd_download(args)
200
+ elif args.command == "crop":
201
+ cmd_crop(args)
@@ -0,0 +1,76 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from smclipy.helpers import sanitize_filename
6
+
7
+ DEFAULT_CONFIG = {
8
+ "name": "smclipy",
9
+ "path_to_music_folder": "./Music",
10
+ "description_max_lines": 5,
11
+ }
12
+ CONFIG_PATH = Path(
13
+ os.environ.get(
14
+ "SMCLIPY_CONFIG",
15
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
16
+ / "smclipy"
17
+ / "config.json",
18
+ )
19
+ )
20
+
21
+
22
+ class Settings:
23
+ """Resolved, per-run configuration."""
24
+
25
+ def __init__(self, raw: dict, base_dir: Path = Path(".")) -> None:
26
+ self.raw = raw
27
+ self.music_folder = Path(str(raw.get("path_to_music_folder", ".")))
28
+ self.temp_folder = base_dir.joinpath(".temp")
29
+ self.pending_ids_file = self.temp_folder.joinpath("pending_ids.txt")
30
+ self.processed_ids_file = self.temp_folder.joinpath("processed_ids.txt")
31
+ self.script_folder = self.music_folder.joinpath(
32
+ sanitize_filename(str(raw.get("name", "smclipy")))
33
+ )
34
+ self.authors_file = self.script_folder.joinpath("authors.txt")
35
+ self.songs_info = self.script_folder.joinpath("songs_info.txt")
36
+ self.false_positives_file = self.script_folder.joinpath(
37
+ "cropping_tool_false_positives.txt"
38
+ )
39
+ self.covers_folder = self.script_folder.joinpath("covers")
40
+ self.description_max_lines = int(raw.get("description_max_lines", 5))
41
+
42
+
43
+ # Global settings singleton. This is a CLI: a single settings object lives for
44
+ # the whole process, created lazily by init()/settings() and shared across
45
+ # modules. Tests monkeypatch config._settings to isolate runs.
46
+ _settings: Settings | None = None
47
+
48
+
49
+ def _load_raw_config() -> dict:
50
+ if not CONFIG_PATH.exists():
51
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
52
+ CONFIG_PATH.write_text(json.dumps(DEFAULT_CONFIG, indent=4), encoding="utf-8")
53
+ print(
54
+ "Looks like its your first time executing the script, "
55
+ "please modify config.json to your liking! and read the README.md file"
56
+ )
57
+ raise SystemExit(0)
58
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
59
+
60
+
61
+ def setup_folders() -> None:
62
+ s = settings()
63
+ for folder in (s.script_folder, s.covers_folder, s.temp_folder):
64
+ folder.mkdir(parents=True, exist_ok=True)
65
+
66
+
67
+ def init() -> Settings:
68
+ global _settings
69
+ if _settings is None:
70
+ _settings = Settings(_load_raw_config())
71
+ setup_folders()
72
+ return _settings
73
+
74
+
75
+ def settings() -> Settings:
76
+ return _settings if _settings is not None else init()