audio-media-utils 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.
- audio_media_utils-0.1.0/PKG-INFO +42 -0
- audio_media_utils-0.1.0/README.md +30 -0
- audio_media_utils-0.1.0/pyproject.toml +26 -0
- audio_media_utils-0.1.0/setup.cfg +4 -0
- audio_media_utils-0.1.0/src/audio_media_utils/__init__.py +6 -0
- audio_media_utils-0.1.0/src/audio_media_utils/audio/__init__.py +7 -0
- audio_media_utils-0.1.0/src/audio_media_utils/audio/files.py +16 -0
- audio_media_utils-0.1.0/src/audio_media_utils/audio/models.py +20 -0
- audio_media_utils-0.1.0/src/audio_media_utils/audio/mutagen_tags.py +64 -0
- audio_media_utils-0.1.0/src/audio_media_utils/audio/naming.py +39 -0
- audio_media_utils-0.1.0/src/audio_media_utils/exceptions.py +13 -0
- audio_media_utils-0.1.0/src/audio_media_utils/youtube/__init__.py +7 -0
- audio_media_utils-0.1.0/src/audio_media_utils/youtube/downloads.py +13 -0
- audio_media_utils-0.1.0/src/audio_media_utils/youtube/models.py +56 -0
- audio_media_utils-0.1.0/src/audio_media_utils/youtube/playlists.py +14 -0
- audio_media_utils-0.1.0/src/audio_media_utils/youtube/urls.py +31 -0
- audio_media_utils-0.1.0/src/audio_media_utils/youtube/ytdlp_runner.py +48 -0
- audio_media_utils-0.1.0/src/audio_media_utils.egg-info/PKG-INFO +42 -0
- audio_media_utils-0.1.0/src/audio_media_utils.egg-info/SOURCES.txt +26 -0
- audio_media_utils-0.1.0/src/audio_media_utils.egg-info/dependency_links.txt +1 -0
- audio_media_utils-0.1.0/src/audio_media_utils.egg-info/requires.txt +6 -0
- audio_media_utils-0.1.0/src/audio_media_utils.egg-info/top_level.txt +1 -0
- audio_media_utils-0.1.0/tests/test_audio_files.py +27 -0
- audio_media_utils-0.1.0/tests/test_audio_mutagen_tags.py +135 -0
- audio_media_utils-0.1.0/tests/test_audio_naming.py +35 -0
- audio_media_utils-0.1.0/tests/test_exceptions.py +22 -0
- audio_media_utils-0.1.0/tests/test_youtube_urls.py +60 -0
- audio_media_utils-0.1.0/tests/test_ytdlp_runner.py +49 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: audio-media-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utilities for yt-dlp audio downloads and simple mutagen metadata workflows
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: mutagen<2.0,>=1.47
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest<10,>=9; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-cov<7,>=6; extra == "dev"
|
|
11
|
+
Requires-Dist: sphinx<9,>=8; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# audio_media_utils
|
|
14
|
+
|
|
15
|
+
Utilities for audio-oriented download workflows and simple metadata editing.
|
|
16
|
+
|
|
17
|
+
This repository is intentionally focused on reusable domain helpers for:
|
|
18
|
+
|
|
19
|
+
- YouTube and `yt-dlp` download helpers
|
|
20
|
+
- playlist detection and expansion
|
|
21
|
+
- simple audio metadata reads and writes with `mutagen`
|
|
22
|
+
- filename and path helpers for tagged audio files
|
|
23
|
+
|
|
24
|
+
It does not include worker loops, logging infrastructure, Docker wiring, or service-specific orchestration.
|
|
25
|
+
|
|
26
|
+
## Planned package layout
|
|
27
|
+
|
|
28
|
+
```txt
|
|
29
|
+
src/audio_media_utils/
|
|
30
|
+
|-- youtube/
|
|
31
|
+
| |-- models.py
|
|
32
|
+
| |-- urls.py
|
|
33
|
+
| |-- ytdlp_runner.py
|
|
34
|
+
| |-- playlists.py
|
|
35
|
+
| `-- downloads.py
|
|
36
|
+
|-- audio/
|
|
37
|
+
| |-- models.py
|
|
38
|
+
| |-- mutagen_tags.py
|
|
39
|
+
| |-- files.py
|
|
40
|
+
| `-- naming.py
|
|
41
|
+
`-- exceptions.py
|
|
42
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# audio_media_utils
|
|
2
|
+
|
|
3
|
+
Utilities for audio-oriented download workflows and simple metadata editing.
|
|
4
|
+
|
|
5
|
+
This repository is intentionally focused on reusable domain helpers for:
|
|
6
|
+
|
|
7
|
+
- YouTube and `yt-dlp` download helpers
|
|
8
|
+
- playlist detection and expansion
|
|
9
|
+
- simple audio metadata reads and writes with `mutagen`
|
|
10
|
+
- filename and path helpers for tagged audio files
|
|
11
|
+
|
|
12
|
+
It does not include worker loops, logging infrastructure, Docker wiring, or service-specific orchestration.
|
|
13
|
+
|
|
14
|
+
## Planned package layout
|
|
15
|
+
|
|
16
|
+
```txt
|
|
17
|
+
src/audio_media_utils/
|
|
18
|
+
|-- youtube/
|
|
19
|
+
| |-- models.py
|
|
20
|
+
| |-- urls.py
|
|
21
|
+
| |-- ytdlp_runner.py
|
|
22
|
+
| |-- playlists.py
|
|
23
|
+
| `-- downloads.py
|
|
24
|
+
|-- audio/
|
|
25
|
+
| |-- models.py
|
|
26
|
+
| |-- mutagen_tags.py
|
|
27
|
+
| |-- files.py
|
|
28
|
+
| `-- naming.py
|
|
29
|
+
`-- exceptions.py
|
|
30
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "audio-media-utils"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Utilities for yt-dlp audio downloads and simple mutagen metadata workflows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"mutagen>=1.47,<2.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=9,<10",
|
|
18
|
+
"pytest-cov>=6,<7",
|
|
19
|
+
"sphinx>=8,<9",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
package-dir = {"" = "src"}
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Reusable helpers for audio downloads and metadata workflows."""
|
|
2
|
+
|
|
3
|
+
from audio_media_utils.audio.models import AudioTags
|
|
4
|
+
from audio_media_utils.youtube.models import DownloadOptions, DownloadResult, PlaylistEntry, VideoMetadata
|
|
5
|
+
|
|
6
|
+
__all__ = [s for s in dir() if not s.startswith("_")]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Helpers for audio metadata and file naming."""
|
|
2
|
+
|
|
3
|
+
from audio_media_utils.audio.models import AudioTags
|
|
4
|
+
from audio_media_utils.audio.mutagen_tags import read_tags, update_tags, write_tags
|
|
5
|
+
from audio_media_utils.audio.naming import build_music_path, sanitize_path_component
|
|
6
|
+
|
|
7
|
+
__all__ = [s for s in dir() if not s.startswith("_")]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Helpers for file-level audio operations."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from mutagen import File
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_audio_duration(path: str | Path) -> float:
|
|
10
|
+
"""Return audio duration in seconds."""
|
|
11
|
+
file_path = Path(path)
|
|
12
|
+
audio = File(file_path)
|
|
13
|
+
if audio is None or not hasattr(audio, 'info') or audio.info is None:
|
|
14
|
+
raise ValueError(f"Unable to read duration from {file_path}")
|
|
15
|
+
|
|
16
|
+
return float(audio.info.length)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Typed models used by the audio helpers."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class AudioTags:
|
|
10
|
+
"""Normalized tag container for simple mutagen write operations."""
|
|
11
|
+
|
|
12
|
+
artist: str | None = None
|
|
13
|
+
albumartist: str | None = None
|
|
14
|
+
album: str | None = None
|
|
15
|
+
title: str | None = None
|
|
16
|
+
tracknumber: int | None = None
|
|
17
|
+
date: str | None = None
|
|
18
|
+
genre: list[str] = field(default_factory=list)
|
|
19
|
+
musicbrainz_trackid: str | None = None
|
|
20
|
+
musicbrainz_albumid: str | None = None
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Simple mutagen helpers for reading and writing tags."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from mutagen import File
|
|
7
|
+
from audio_media_utils.audio.models import AudioTags
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_tags(path: str | Path) -> dict[str, list[str]]:
|
|
11
|
+
"""Read easy tags from an audio file when available."""
|
|
12
|
+
file_path = Path(path)
|
|
13
|
+
audio = File(file_path, easy=True)
|
|
14
|
+
if audio is None or audio.tags is None:
|
|
15
|
+
return {}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
str(key): [str(item) for item in value]
|
|
19
|
+
for key, value in audio.tags.items()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_tags(path: str | Path, tags: AudioTags) -> None:
|
|
24
|
+
"""Overwrite common audio tags with normalized values."""
|
|
25
|
+
file_path = Path(path)
|
|
26
|
+
audio = File(file_path, easy=True)
|
|
27
|
+
if audio is None:
|
|
28
|
+
raise ValueError(f"No compatible mutagen handler for {file_path.suffix!r}")
|
|
29
|
+
|
|
30
|
+
if audio.tags is None and hasattr(audio, 'add_tags'):
|
|
31
|
+
audio.add_tags()
|
|
32
|
+
|
|
33
|
+
if audio.tags is None:
|
|
34
|
+
raise ValueError(f"Unable to initialize metadata container for {file_path}")
|
|
35
|
+
|
|
36
|
+
assignments: dict[str, list[str]] = {}
|
|
37
|
+
if tags.artist:
|
|
38
|
+
assignments['artist'] = [tags.artist]
|
|
39
|
+
if tags.albumartist:
|
|
40
|
+
assignments['albumartist'] = [tags.albumartist]
|
|
41
|
+
if tags.album:
|
|
42
|
+
assignments['album'] = [tags.album]
|
|
43
|
+
if tags.title:
|
|
44
|
+
assignments['title'] = [tags.title]
|
|
45
|
+
if tags.tracknumber is not None:
|
|
46
|
+
assignments['tracknumber'] = [str(tags.tracknumber)]
|
|
47
|
+
if tags.date:
|
|
48
|
+
assignments['date'] = [tags.date]
|
|
49
|
+
if tags.genre:
|
|
50
|
+
assignments['genre'] = list(tags.genre)
|
|
51
|
+
if tags.musicbrainz_trackid:
|
|
52
|
+
assignments['musicbrainz_trackid'] = [tags.musicbrainz_trackid]
|
|
53
|
+
if tags.musicbrainz_albumid:
|
|
54
|
+
assignments['musicbrainz_albumid'] = [tags.musicbrainz_albumid]
|
|
55
|
+
|
|
56
|
+
audio.delete()
|
|
57
|
+
for key, value in assignments.items():
|
|
58
|
+
audio.tags[key] = value
|
|
59
|
+
audio.save()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def update_tags(path: str | Path, tags: AudioTags) -> None:
|
|
63
|
+
"""Alias kept for a friendlier public API while bootstrapping the package."""
|
|
64
|
+
write_tags(path, tags)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Helpers for building safe music file paths."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_INVALID_PATH_CHARS = re.compile(r'[<>:"/\\|?*\n\r\t]+')
|
|
10
|
+
_MULTISPACE_CHARS = re.compile(r'\s+')
|
|
11
|
+
_MULTI_UNDERSCORES = re.compile(r'_+')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sanitize_path_component(value: str) -> str:
|
|
15
|
+
"""Normalize a filesystem path component for music libraries."""
|
|
16
|
+
normalized = unicodedata.normalize('NFKC', value)
|
|
17
|
+
cleaned = _INVALID_PATH_CHARS.sub('_', normalized)
|
|
18
|
+
cleaned = _MULTISPACE_CHARS.sub(' ', cleaned)
|
|
19
|
+
cleaned = _MULTI_UNDERSCORES.sub('_', cleaned)
|
|
20
|
+
cleaned = cleaned.strip(' .')
|
|
21
|
+
return cleaned or 'Unknown'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_music_path(
|
|
25
|
+
base_dir: str | Path,
|
|
26
|
+
*,
|
|
27
|
+
artist: str,
|
|
28
|
+
album: str,
|
|
29
|
+
title: str,
|
|
30
|
+
track_number: int | None = None,
|
|
31
|
+
suffix: str = '.mp3',
|
|
32
|
+
) -> Path:
|
|
33
|
+
"""Build a normalized destination path for a tagged music file."""
|
|
34
|
+
root = Path(base_dir)
|
|
35
|
+
safe_artist = sanitize_path_component(artist)
|
|
36
|
+
safe_album = sanitize_path_component(album)
|
|
37
|
+
safe_title = sanitize_path_component(title)
|
|
38
|
+
prefix = f'{track_number:02d} - ' if track_number is not None else ''
|
|
39
|
+
return root / safe_artist / safe_album / f'{prefix}{safe_title}{suffix}'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Custom exceptions used across the package."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AudioMediaUtilsError(Exception):
|
|
5
|
+
"""Base exception for the package."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class YtDlpError(AudioMediaUtilsError):
|
|
9
|
+
"""Raised when a yt-dlp command fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MetadataError(AudioMediaUtilsError):
|
|
13
|
+
"""Raised when audio metadata cannot be processed."""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Helpers for YouTube URL inspection and yt-dlp workflows."""
|
|
2
|
+
|
|
3
|
+
from audio_media_utils.youtube.downloads import download_audio
|
|
4
|
+
from audio_media_utils.youtube.playlists import expand_playlist
|
|
5
|
+
from audio_media_utils.youtube.urls import extract_playlist_id, extract_video_id, is_playlist_url, is_video_url
|
|
6
|
+
|
|
7
|
+
__all__ = [s for s in dir() if not s.startswith("_")]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Helpers for audio downloads through yt-dlp."""
|
|
4
|
+
|
|
5
|
+
from audio_media_utils.youtube.models import DownloadOptions, DownloadResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def download_audio(url: str, *, options: DownloadOptions) -> DownloadResult:
|
|
9
|
+
"""Download audio from ``url`` using the provided options.
|
|
10
|
+
|
|
11
|
+
This is a placeholder implementation that will be filled in next.
|
|
12
|
+
"""
|
|
13
|
+
return DownloadResult(success=False, url=url, error_reason="not_implemented")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Typed models used by the YouTube helpers."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PlaylistEntry:
|
|
11
|
+
"""Single entry extracted from a playlist."""
|
|
12
|
+
|
|
13
|
+
video_id: str
|
|
14
|
+
url: str
|
|
15
|
+
title: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class VideoMetadata:
|
|
20
|
+
"""Subset of metadata commonly returned by yt-dlp."""
|
|
21
|
+
|
|
22
|
+
video_id: str | None
|
|
23
|
+
url: str
|
|
24
|
+
title: str | None = None
|
|
25
|
+
upload_date: str | None = None
|
|
26
|
+
duration_seconds: int | None = None
|
|
27
|
+
live_status: str | None = None
|
|
28
|
+
was_live: bool = False
|
|
29
|
+
release_timestamp: int | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class DownloadOptions:
|
|
34
|
+
"""Options used when downloading audio with yt-dlp."""
|
|
35
|
+
|
|
36
|
+
output_template: str
|
|
37
|
+
audio_format: str = "mp3"
|
|
38
|
+
audio_quality: str | None = None
|
|
39
|
+
archive_file: Path | None = None
|
|
40
|
+
cookies_file: Path | None = None
|
|
41
|
+
timeout_seconds: int = 120
|
|
42
|
+
extra_args: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class DownloadResult:
|
|
47
|
+
"""Result of a download attempt."""
|
|
48
|
+
|
|
49
|
+
success: bool
|
|
50
|
+
url: str
|
|
51
|
+
file_path: Path | None = None
|
|
52
|
+
title: str | None = None
|
|
53
|
+
already_downloaded: bool = False
|
|
54
|
+
stdout: str = ""
|
|
55
|
+
stderr: str = ""
|
|
56
|
+
error_reason: str | None = None
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Helpers for playlist inspection and expansion."""
|
|
4
|
+
|
|
5
|
+
from audio_media_utils.youtube.models import PlaylistEntry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def expand_playlist(url: str, *, flat: bool = True) -> list[PlaylistEntry]:
|
|
9
|
+
"""Expand a playlist URL into entries.
|
|
10
|
+
|
|
11
|
+
This is a placeholder implementation that will be filled in next.
|
|
12
|
+
"""
|
|
13
|
+
_ = (url, flat)
|
|
14
|
+
return []
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""URL helpers for YouTube video and playlist links."""
|
|
4
|
+
|
|
5
|
+
from urllib.parse import parse_qs, urlparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_playlist_url(url: str) -> bool:
|
|
9
|
+
"""Return whether ``url`` looks like a YouTube playlist URL."""
|
|
10
|
+
parsed = urlparse(url)
|
|
11
|
+
query = parse_qs(parsed.query)
|
|
12
|
+
return parsed.path == "/playlist" or "list" in query
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_video_url(url: str) -> bool:
|
|
16
|
+
"""Return whether ``url`` looks like a YouTube video URL."""
|
|
17
|
+
parsed = urlparse(url)
|
|
18
|
+
query = parse_qs(parsed.query)
|
|
19
|
+
return parsed.path == "/watch" and "v" in query
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_video_id(url: str) -> str | None:
|
|
23
|
+
"""Extract the ``v`` parameter from a YouTube watch URL."""
|
|
24
|
+
parsed = urlparse(url)
|
|
25
|
+
return parse_qs(parsed.query).get("v", [None])[0]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_playlist_id(url: str) -> str | None:
|
|
29
|
+
"""Extract the ``list`` parameter from a YouTube playlist URL."""
|
|
30
|
+
parsed = urlparse(url)
|
|
31
|
+
return parse_qs(parsed.query).get("list", [None])[0]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Thin subprocess wrapper around yt-dlp."""
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class YtDlpCommandResult:
|
|
11
|
+
"""Raw result of a yt-dlp invocation."""
|
|
12
|
+
|
|
13
|
+
returncode: int
|
|
14
|
+
stdout: str
|
|
15
|
+
stderr: str
|
|
16
|
+
reason: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_ytdlp(command: list[str], timeout_seconds: int) -> YtDlpCommandResult:
|
|
20
|
+
"""Run a yt-dlp command and normalize timeout and OS errors."""
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
command,
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
timeout=timeout_seconds,
|
|
28
|
+
)
|
|
29
|
+
except subprocess.TimeoutExpired as error:
|
|
30
|
+
return YtDlpCommandResult(
|
|
31
|
+
returncode=-1,
|
|
32
|
+
stdout=error.stdout or "",
|
|
33
|
+
stderr=error.stderr or "",
|
|
34
|
+
reason="timeout",
|
|
35
|
+
)
|
|
36
|
+
except OSError as error:
|
|
37
|
+
return YtDlpCommandResult(
|
|
38
|
+
returncode=-1,
|
|
39
|
+
stdout="",
|
|
40
|
+
stderr=str(error),
|
|
41
|
+
reason="os_error",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return YtDlpCommandResult(
|
|
45
|
+
returncode=result.returncode,
|
|
46
|
+
stdout=result.stdout,
|
|
47
|
+
stderr=result.stderr,
|
|
48
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: audio-media-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utilities for yt-dlp audio downloads and simple mutagen metadata workflows
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: mutagen<2.0,>=1.47
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest<10,>=9; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-cov<7,>=6; extra == "dev"
|
|
11
|
+
Requires-Dist: sphinx<9,>=8; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# audio_media_utils
|
|
14
|
+
|
|
15
|
+
Utilities for audio-oriented download workflows and simple metadata editing.
|
|
16
|
+
|
|
17
|
+
This repository is intentionally focused on reusable domain helpers for:
|
|
18
|
+
|
|
19
|
+
- YouTube and `yt-dlp` download helpers
|
|
20
|
+
- playlist detection and expansion
|
|
21
|
+
- simple audio metadata reads and writes with `mutagen`
|
|
22
|
+
- filename and path helpers for tagged audio files
|
|
23
|
+
|
|
24
|
+
It does not include worker loops, logging infrastructure, Docker wiring, or service-specific orchestration.
|
|
25
|
+
|
|
26
|
+
## Planned package layout
|
|
27
|
+
|
|
28
|
+
```txt
|
|
29
|
+
src/audio_media_utils/
|
|
30
|
+
|-- youtube/
|
|
31
|
+
| |-- models.py
|
|
32
|
+
| |-- urls.py
|
|
33
|
+
| |-- ytdlp_runner.py
|
|
34
|
+
| |-- playlists.py
|
|
35
|
+
| `-- downloads.py
|
|
36
|
+
|-- audio/
|
|
37
|
+
| |-- models.py
|
|
38
|
+
| |-- mutagen_tags.py
|
|
39
|
+
| |-- files.py
|
|
40
|
+
| `-- naming.py
|
|
41
|
+
`-- exceptions.py
|
|
42
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/audio_media_utils/__init__.py
|
|
4
|
+
src/audio_media_utils/exceptions.py
|
|
5
|
+
src/audio_media_utils.egg-info/PKG-INFO
|
|
6
|
+
src/audio_media_utils.egg-info/SOURCES.txt
|
|
7
|
+
src/audio_media_utils.egg-info/dependency_links.txt
|
|
8
|
+
src/audio_media_utils.egg-info/requires.txt
|
|
9
|
+
src/audio_media_utils.egg-info/top_level.txt
|
|
10
|
+
src/audio_media_utils/audio/__init__.py
|
|
11
|
+
src/audio_media_utils/audio/files.py
|
|
12
|
+
src/audio_media_utils/audio/models.py
|
|
13
|
+
src/audio_media_utils/audio/mutagen_tags.py
|
|
14
|
+
src/audio_media_utils/audio/naming.py
|
|
15
|
+
src/audio_media_utils/youtube/__init__.py
|
|
16
|
+
src/audio_media_utils/youtube/downloads.py
|
|
17
|
+
src/audio_media_utils/youtube/models.py
|
|
18
|
+
src/audio_media_utils/youtube/playlists.py
|
|
19
|
+
src/audio_media_utils/youtube/urls.py
|
|
20
|
+
src/audio_media_utils/youtube/ytdlp_runner.py
|
|
21
|
+
tests/test_audio_files.py
|
|
22
|
+
tests/test_audio_mutagen_tags.py
|
|
23
|
+
tests/test_audio_naming.py
|
|
24
|
+
tests/test_exceptions.py
|
|
25
|
+
tests/test_youtube_urls.py
|
|
26
|
+
tests/test_ytdlp_runner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
audio_media_utils
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from audio_media_utils.audio.files import read_audio_duration
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_read_audio_duration_returns_length(monkeypatch, tmp_path) -> None:
|
|
9
|
+
audio_file = tmp_path / 'song.mp3'
|
|
10
|
+
audio_file.write_bytes(b'data')
|
|
11
|
+
|
|
12
|
+
def _fake_file(path):
|
|
13
|
+
return SimpleNamespace(info=SimpleNamespace(length=123.45))
|
|
14
|
+
|
|
15
|
+
monkeypatch.setattr('audio_media_utils.audio.files.File', _fake_file)
|
|
16
|
+
|
|
17
|
+
assert read_audio_duration(audio_file) == 123.45
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_read_audio_duration_raises_for_missing_metadata(monkeypatch, tmp_path) -> None:
|
|
21
|
+
audio_file = tmp_path / 'song.mp3'
|
|
22
|
+
audio_file.write_bytes(b'data')
|
|
23
|
+
|
|
24
|
+
monkeypatch.setattr('audio_media_utils.audio.files.File', lambda path: None)
|
|
25
|
+
|
|
26
|
+
with pytest.raises(ValueError, match='Unable to read duration'):
|
|
27
|
+
read_audio_duration(audio_file)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from audio_media_utils.audio.models import AudioTags
|
|
4
|
+
from audio_media_utils.audio.mutagen_tags import read_tags, update_tags, write_tags
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _FakeAudio:
|
|
8
|
+
def __init__(self, tags=None, allow_add_tags: bool = True) -> None:
|
|
9
|
+
self.tags = tags
|
|
10
|
+
self._allow_add_tags = allow_add_tags
|
|
11
|
+
self.delete_called = False
|
|
12
|
+
self.save_called = False
|
|
13
|
+
|
|
14
|
+
def add_tags(self) -> None:
|
|
15
|
+
if self._allow_add_tags:
|
|
16
|
+
self.tags = {}
|
|
17
|
+
|
|
18
|
+
def delete(self) -> None:
|
|
19
|
+
self.delete_called = True
|
|
20
|
+
self.tags = {}
|
|
21
|
+
|
|
22
|
+
def save(self) -> None:
|
|
23
|
+
self.save_called = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _FakeAudioWithoutAddTags:
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self.tags = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_read_tags_returns_empty_dict_when_handler_is_missing(monkeypatch, tmp_path) -> None:
|
|
32
|
+
audio_file = tmp_path / 'song.mp3'
|
|
33
|
+
audio_file.write_bytes(b'data')
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr('audio_media_utils.audio.mutagen_tags.File', lambda path, easy=True: None)
|
|
36
|
+
|
|
37
|
+
assert read_tags(audio_file) == {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_read_tags_returns_normalized_string_mapping(monkeypatch, tmp_path) -> None:
|
|
41
|
+
audio_file = tmp_path / 'song.mp3'
|
|
42
|
+
audio_file.write_bytes(b'data')
|
|
43
|
+
|
|
44
|
+
fake_audio = _FakeAudio(tags={'artist': ['Artist'], 'tracknumber': [3]})
|
|
45
|
+
monkeypatch.setattr('audio_media_utils.audio.mutagen_tags.File', lambda path, easy=True: fake_audio)
|
|
46
|
+
|
|
47
|
+
assert read_tags(audio_file) == {'artist': ['Artist'], 'tracknumber': ['3']}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_write_tags_overwrites_common_fields(monkeypatch, tmp_path) -> None:
|
|
51
|
+
audio_file = tmp_path / 'song.mp3'
|
|
52
|
+
audio_file.write_bytes(b'data')
|
|
53
|
+
|
|
54
|
+
fake_audio = _FakeAudio(tags={'artist': ['Old']})
|
|
55
|
+
monkeypatch.setattr('audio_media_utils.audio.mutagen_tags.File', lambda path, easy=True: fake_audio)
|
|
56
|
+
|
|
57
|
+
write_tags(
|
|
58
|
+
audio_file,
|
|
59
|
+
AudioTags(
|
|
60
|
+
artist='Artist',
|
|
61
|
+
albumartist='Album Artist',
|
|
62
|
+
album='Album',
|
|
63
|
+
title='Song',
|
|
64
|
+
tracknumber=7,
|
|
65
|
+
date='2024',
|
|
66
|
+
genre=['Rock'],
|
|
67
|
+
musicbrainz_trackid='track-id',
|
|
68
|
+
musicbrainz_albumid='album-id',
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
assert fake_audio.delete_called is True
|
|
73
|
+
assert fake_audio.save_called is True
|
|
74
|
+
assert fake_audio.tags == {
|
|
75
|
+
'artist': ['Artist'],
|
|
76
|
+
'albumartist': ['Album Artist'],
|
|
77
|
+
'album': ['Album'],
|
|
78
|
+
'title': ['Song'],
|
|
79
|
+
'tracknumber': ['7'],
|
|
80
|
+
'date': ['2024'],
|
|
81
|
+
'genre': ['Rock'],
|
|
82
|
+
'musicbrainz_trackid': ['track-id'],
|
|
83
|
+
'musicbrainz_albumid': ['album-id'],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_write_tags_initializes_tags_when_missing(monkeypatch, tmp_path) -> None:
|
|
88
|
+
audio_file = tmp_path / 'song.mp3'
|
|
89
|
+
audio_file.write_bytes(b'data')
|
|
90
|
+
|
|
91
|
+
fake_audio = _FakeAudio(tags=None)
|
|
92
|
+
monkeypatch.setattr('audio_media_utils.audio.mutagen_tags.File', lambda path, easy=True: fake_audio)
|
|
93
|
+
|
|
94
|
+
write_tags(audio_file, AudioTags(artist='Artist'))
|
|
95
|
+
|
|
96
|
+
assert fake_audio.tags == {'artist': ['Artist']}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_write_tags_raises_when_no_handler_exists(monkeypatch, tmp_path) -> None:
|
|
100
|
+
audio_file = tmp_path / 'song.unknown'
|
|
101
|
+
audio_file.write_bytes(b'data')
|
|
102
|
+
|
|
103
|
+
monkeypatch.setattr('audio_media_utils.audio.mutagen_tags.File', lambda path, easy=True: None)
|
|
104
|
+
|
|
105
|
+
with pytest.raises(ValueError, match='No compatible mutagen handler'):
|
|
106
|
+
write_tags(audio_file, AudioTags(artist='Artist'))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_write_tags_raises_when_tag_container_cannot_be_initialized(monkeypatch, tmp_path) -> None:
|
|
110
|
+
audio_file = tmp_path / 'song.mp3'
|
|
111
|
+
audio_file.write_bytes(b'data')
|
|
112
|
+
|
|
113
|
+
fake_audio = _FakeAudioWithoutAddTags()
|
|
114
|
+
monkeypatch.setattr('audio_media_utils.audio.mutagen_tags.File', lambda path, easy=True: fake_audio)
|
|
115
|
+
|
|
116
|
+
with pytest.raises(ValueError, match='Unable to initialize metadata container'):
|
|
117
|
+
write_tags(audio_file, AudioTags(artist='Artist'))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_update_tags_delegates_to_write_tags(monkeypatch, tmp_path) -> None:
|
|
121
|
+
audio_file = tmp_path / 'song.mp3'
|
|
122
|
+
audio_file.write_bytes(b'data')
|
|
123
|
+
|
|
124
|
+
captured = {}
|
|
125
|
+
|
|
126
|
+
def _fake_write_tags(path, tags):
|
|
127
|
+
captured['path'] = path
|
|
128
|
+
captured['tags'] = tags
|
|
129
|
+
|
|
130
|
+
monkeypatch.setattr('audio_media_utils.audio.mutagen_tags.write_tags', _fake_write_tags)
|
|
131
|
+
|
|
132
|
+
tags = AudioTags(artist='Artist')
|
|
133
|
+
update_tags(audio_file, tags)
|
|
134
|
+
|
|
135
|
+
assert captured == {'path': audio_file, 'tags': tags}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from audio_media_utils.audio.naming import build_music_path, sanitize_path_component
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_sanitize_path_component_normalizes_invalid_characters() -> None:
|
|
7
|
+
assert sanitize_path_component(' AC/DC: Live? ') == 'AC_DC_ Live_'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_sanitize_path_component_returns_unknown_for_empty_result() -> None:
|
|
11
|
+
assert sanitize_path_component(' ... ') == 'Unknown'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_build_music_path_includes_track_prefix_when_present() -> None:
|
|
15
|
+
path = build_music_path(
|
|
16
|
+
Path('library'),
|
|
17
|
+
artist='Artist',
|
|
18
|
+
album='Album',
|
|
19
|
+
title='Song',
|
|
20
|
+
track_number=3,
|
|
21
|
+
suffix='.flac',
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
assert path == Path('library/Artist/Album/03 - Song.flac')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_build_music_path_omits_track_prefix_when_missing() -> None:
|
|
28
|
+
path = build_music_path(
|
|
29
|
+
Path('library'),
|
|
30
|
+
artist='Artist',
|
|
31
|
+
album='Album',
|
|
32
|
+
title='Song',
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert path == Path('library/Artist/Album/Song.mp3')
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from audio_media_utils.exceptions import AudioMediaUtilsError, MetadataError, YtDlpError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_audio_media_utils_error_is_package_base_exception() -> None:
|
|
5
|
+
error = AudioMediaUtilsError("base error")
|
|
6
|
+
|
|
7
|
+
assert str(error) == "base error"
|
|
8
|
+
assert isinstance(error, Exception)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_ytdlp_error_inherits_from_package_base_exception() -> None:
|
|
12
|
+
error = YtDlpError("yt-dlp failed")
|
|
13
|
+
|
|
14
|
+
assert str(error) == "yt-dlp failed"
|
|
15
|
+
assert isinstance(error, AudioMediaUtilsError)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_metadata_error_inherits_from_package_base_exception() -> None:
|
|
19
|
+
error = MetadataError("metadata failed")
|
|
20
|
+
|
|
21
|
+
assert str(error) == "metadata failed"
|
|
22
|
+
assert isinstance(error, AudioMediaUtilsError)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from audio_media_utils.youtube.downloads import download_audio
|
|
2
|
+
from audio_media_utils.youtube.models import DownloadOptions
|
|
3
|
+
from audio_media_utils.youtube.playlists import expand_playlist
|
|
4
|
+
from audio_media_utils.youtube.urls import (
|
|
5
|
+
extract_playlist_id,
|
|
6
|
+
extract_video_id,
|
|
7
|
+
is_playlist_url,
|
|
8
|
+
is_video_url,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_is_playlist_url_detects_standard_playlist_url() -> None:
|
|
13
|
+
assert is_playlist_url('https://www.youtube.com/playlist?list=PL123') is True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_is_playlist_url_detects_watch_url_with_list_parameter() -> None:
|
|
17
|
+
assert is_playlist_url('https://www.youtube.com/watch?v=abc123&list=PL123') is True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_is_playlist_url_rejects_plain_video_url() -> None:
|
|
21
|
+
assert is_playlist_url('https://www.youtube.com/watch?v=abc123') is False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_is_video_url_detects_watch_url() -> None:
|
|
25
|
+
assert is_video_url('https://www.youtube.com/watch?v=abc123') is True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_is_video_url_rejects_playlist_only_url() -> None:
|
|
29
|
+
assert is_video_url('https://www.youtube.com/playlist?list=PL123') is False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_extract_video_id_returns_identifier() -> None:
|
|
33
|
+
assert extract_video_id('https://www.youtube.com/watch?v=abc123&list=PL123') == 'abc123'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_extract_video_id_returns_none_when_missing() -> None:
|
|
37
|
+
assert extract_video_id('https://www.youtube.com/playlist?list=PL123') is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_extract_playlist_id_returns_identifier() -> None:
|
|
41
|
+
assert extract_playlist_id('https://www.youtube.com/playlist?list=PL123') == 'PL123'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_extract_playlist_id_returns_none_when_missing() -> None:
|
|
45
|
+
assert extract_playlist_id('https://www.youtube.com/watch?v=abc123') is None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_expand_playlist_placeholder_returns_empty_list() -> None:
|
|
49
|
+
assert expand_playlist('https://www.youtube.com/playlist?list=PL123') == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_download_audio_placeholder_returns_not_implemented_result() -> None:
|
|
53
|
+
result = download_audio(
|
|
54
|
+
'https://www.youtube.com/watch?v=abc123',
|
|
55
|
+
options=DownloadOptions(output_template='%(title)s.%(ext)s'),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert result.success is False
|
|
59
|
+
assert result.url == 'https://www.youtube.com/watch?v=abc123'
|
|
60
|
+
assert result.error_reason == 'not_implemented'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
from audio_media_utils.youtube.ytdlp_runner import YtDlpCommandResult, run_ytdlp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _CompletedProcess:
|
|
7
|
+
def __init__(self, returncode: int, stdout: str, stderr: str) -> None:
|
|
8
|
+
self.returncode = returncode
|
|
9
|
+
self.stdout = stdout
|
|
10
|
+
self.stderr = stderr
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_run_ytdlp_returns_normalized_result_on_success(monkeypatch) -> None:
|
|
14
|
+
def _fake_run(*args, **kwargs):
|
|
15
|
+
return _CompletedProcess(returncode=0, stdout='ok', stderr='')
|
|
16
|
+
|
|
17
|
+
monkeypatch.setattr(subprocess, 'run', _fake_run)
|
|
18
|
+
|
|
19
|
+
result = run_ytdlp(['yt-dlp', '--version'], timeout_seconds=10)
|
|
20
|
+
|
|
21
|
+
assert result == YtDlpCommandResult(returncode=0, stdout='ok', stderr='', reason=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_run_ytdlp_returns_timeout_result(monkeypatch) -> None:
|
|
25
|
+
def _fake_run(*args, **kwargs):
|
|
26
|
+
raise subprocess.TimeoutExpired(cmd='yt-dlp', timeout=10, output='partial', stderr='late stderr')
|
|
27
|
+
|
|
28
|
+
monkeypatch.setattr(subprocess, 'run', _fake_run)
|
|
29
|
+
|
|
30
|
+
result = run_ytdlp(['yt-dlp', '--version'], timeout_seconds=10)
|
|
31
|
+
|
|
32
|
+
assert result.returncode == -1
|
|
33
|
+
assert result.stdout == 'partial'
|
|
34
|
+
assert result.stderr == 'late stderr'
|
|
35
|
+
assert result.reason == 'timeout'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_run_ytdlp_returns_os_error_result(monkeypatch) -> None:
|
|
39
|
+
def _fake_run(*args, **kwargs):
|
|
40
|
+
raise OSError('yt-dlp not found')
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(subprocess, 'run', _fake_run)
|
|
43
|
+
|
|
44
|
+
result = run_ytdlp(['yt-dlp', '--version'], timeout_seconds=10)
|
|
45
|
+
|
|
46
|
+
assert result.returncode == -1
|
|
47
|
+
assert result.stdout == ''
|
|
48
|
+
assert result.stderr == 'yt-dlp not found'
|
|
49
|
+
assert result.reason == 'os_error'
|