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.
Files changed (28) hide show
  1. audio_media_utils-0.1.0/PKG-INFO +42 -0
  2. audio_media_utils-0.1.0/README.md +30 -0
  3. audio_media_utils-0.1.0/pyproject.toml +26 -0
  4. audio_media_utils-0.1.0/setup.cfg +4 -0
  5. audio_media_utils-0.1.0/src/audio_media_utils/__init__.py +6 -0
  6. audio_media_utils-0.1.0/src/audio_media_utils/audio/__init__.py +7 -0
  7. audio_media_utils-0.1.0/src/audio_media_utils/audio/files.py +16 -0
  8. audio_media_utils-0.1.0/src/audio_media_utils/audio/models.py +20 -0
  9. audio_media_utils-0.1.0/src/audio_media_utils/audio/mutagen_tags.py +64 -0
  10. audio_media_utils-0.1.0/src/audio_media_utils/audio/naming.py +39 -0
  11. audio_media_utils-0.1.0/src/audio_media_utils/exceptions.py +13 -0
  12. audio_media_utils-0.1.0/src/audio_media_utils/youtube/__init__.py +7 -0
  13. audio_media_utils-0.1.0/src/audio_media_utils/youtube/downloads.py +13 -0
  14. audio_media_utils-0.1.0/src/audio_media_utils/youtube/models.py +56 -0
  15. audio_media_utils-0.1.0/src/audio_media_utils/youtube/playlists.py +14 -0
  16. audio_media_utils-0.1.0/src/audio_media_utils/youtube/urls.py +31 -0
  17. audio_media_utils-0.1.0/src/audio_media_utils/youtube/ytdlp_runner.py +48 -0
  18. audio_media_utils-0.1.0/src/audio_media_utils.egg-info/PKG-INFO +42 -0
  19. audio_media_utils-0.1.0/src/audio_media_utils.egg-info/SOURCES.txt +26 -0
  20. audio_media_utils-0.1.0/src/audio_media_utils.egg-info/dependency_links.txt +1 -0
  21. audio_media_utils-0.1.0/src/audio_media_utils.egg-info/requires.txt +6 -0
  22. audio_media_utils-0.1.0/src/audio_media_utils.egg-info/top_level.txt +1 -0
  23. audio_media_utils-0.1.0/tests/test_audio_files.py +27 -0
  24. audio_media_utils-0.1.0/tests/test_audio_mutagen_tags.py +135 -0
  25. audio_media_utils-0.1.0/tests/test_audio_naming.py +35 -0
  26. audio_media_utils-0.1.0/tests/test_exceptions.py +22 -0
  27. audio_media_utils-0.1.0/tests/test_youtube_urls.py +60 -0
  28. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,6 @@
1
+ mutagen<2.0,>=1.47
2
+
3
+ [dev]
4
+ pytest<10,>=9
5
+ pytest-cov<7,>=6
6
+ sphinx<9,>=8
@@ -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'