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.
- smclipy-0.1.0/.github/workflows/publish.yml +74 -0
- smclipy-0.1.0/.gitignore +25 -0
- smclipy-0.1.0/.python-version +1 -0
- smclipy-0.1.0/LICENSE +21 -0
- smclipy-0.1.0/PKG-INFO +109 -0
- smclipy-0.1.0/README.md +96 -0
- smclipy-0.1.0/config.example.json +5 -0
- smclipy-0.1.0/pyproject.toml +48 -0
- smclipy-0.1.0/smclipy/__init__.py +3 -0
- smclipy-0.1.0/smclipy/__main__.py +4 -0
- smclipy-0.1.0/smclipy/cli.py +201 -0
- smclipy-0.1.0/smclipy/config.py +76 -0
- smclipy-0.1.0/smclipy/downloader.py +71 -0
- smclipy-0.1.0/smclipy/helpers.py +35 -0
- smclipy-0.1.0/smclipy/images.py +61 -0
- smclipy-0.1.0/smclipy/metadata.py +159 -0
- smclipy-0.1.0/smclipy/storage.py +32 -0
- smclipy-0.1.0/smclipy/ui.py +53 -0
- smclipy-0.1.0/tests/conftest.py +16 -0
- smclipy-0.1.0/tests/test_cli.py +130 -0
- smclipy-0.1.0/tests/test_downloader.py +121 -0
- smclipy-0.1.0/tests/test_helpers.py +57 -0
- smclipy-0.1.0/tests/test_images.py +40 -0
- smclipy-0.1.0/tests/test_metadata.py +220 -0
- smclipy-0.1.0/tests/test_storage.py +51 -0
- smclipy-0.1.0/uv.lock +974 -0
|
@@ -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
|
smclipy-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
```
|
smclipy-0.1.0/README.md
ADDED
|
@@ -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,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,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()
|