MIL-004: Step 2 - YouTube Music authentication #46

Merged
Tirsvad merged 2 commits from mil-004-step-2-youtube-music-auth into main 2026-10-28 07:29:57 +01:00
16 changed files with 501 additions and 12 deletions
+15 -1
View File
@@ -82,7 +82,7 @@ The program is added step by step (one pull request per lecture step):
| Step | Status | | Step | Status |
| --- | --- | | --- | --- |
| 1. Scrape the Billboard Hot 100 | done (below) | | 1. Scrape the Billboard Hot 100 | done (below) |
| 2. YouTube Music authentication | planned in milestone 4 | | 2. YouTube Music authentication | code done; live screenshot waits for `browser.json` |
| 3. Create the playlist | planned in milestone 5 | | 3. Create the playlist | planned in milestone 5 |
| 4. Search and add songs | planned in milestone 6 | | 4. Search and add songs | planned in milestone 6 |
@@ -102,6 +102,20 @@ The program's output for the same date (rendered from the real output of the run
![Output of step 1 for 2026-04-18](docs/wiki/images/step1-terminal-output.png) ![Output of step 1 for 2026-04-18](docs/wiki/images/step1-terminal-output.png)
### Step 2 - YouTube Music authentication
Create `browser.json` as described in [Setup](#-setup), then check the login:
```bash
python -m musical_time_machine --check-login
```
It prints `Found N playlists in your library.`. With no or an expired `browser.json` it prints the renewal steps and exits with code 1 instead of a stack trace. `--browser-file PATH` uses another file.
The screenshot below shows the run **without** a `browser.json` and the tests that use a fake YouTube Music service. A screenshot of a live login is added once the file exists (issue 22 stays open until then); the file holds personal cookies and is never committed.
![Login check without browser.json and the step 2 tests](docs/wiki/images/step2-check-login.png)
The screenshots and the instructions of the next steps are added with each step. The screenshots and the instructions of the next steps are added with each step.
## 🧪 Tests ## 🧪 Tests
+1 -1
View File
@@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 | | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 |
| GOV | Governance / ARB Workflow | docs/sqa/governance.md | 002 | | GOV | Governance / ARB Workflow | docs/sqa/governance.md | 002 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 015 |
## Languages ## Languages
+62
View File
@@ -0,0 +1,62 @@
# Review of the code of MIL-004
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-014 |
| CrossReference | [MIL-004], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [62cb520] |
---
## Artifact Under Review
- Instance reviewed: [MIL-004] (the code of its pull request, `src/` and `tests/`)
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PascalCase classes, snake_case functions, UPPER_SNAKE constants. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (BrowserFileMissingError, count_library_playlists). |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | ruff format and ruff check report no finding. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All functions annotated; the one YTMusic default factory is typed by the YTMusicLike protocol. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | Specific ytmusicapi errors are caught and re-raised as AuthenticationError with `from`. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | Checked in the diff. |
| 7 | Files, locks and connections are managed with context managers | N-A | The code opens no file; it checks Path.exists and passes the path to ytmusicapi. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Checked in the diff. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print in the source except the injected writer; browser.json content is never read or logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; all tasks are technical. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 47 tests, 97 % coverage, no network; the missing-file test uses tmp_path; browser.json is never created. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | mypy is not configured; no Any in the source. Optional criterion. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | ytmusicapi is now used by infrastructure. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Messages and the file name are constants defined once. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | The port is in application; only infrastructure imports ytmusicapi. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | MusicService is a narrow Protocol; the CLI depends on it. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layer folders and tests/unit/<layer> exist. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English snake_case file names. |
## Overall Verdict
Go — every Mandatory criterion passes or does not apply; no blocking finding.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-004]: ../../milestones/mil-004-step-2-youtube-music-auth.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[62cb520]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/046-scraping-the-billboard/commit/62cb520d1e856a97d3e63adee942274629751c38
+2 -1
View File
@@ -28,7 +28,7 @@ Tracks backward and forward links between artifact instances so that the traceab
| [MIL-001] | MIL | en | it | [BC-001], [PP-001] | [MIL-002] | [RC-005] | | [MIL-001] | MIL | en | it | [BC-001], [PP-001] | [MIL-002] | [RC-005] |
| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-006], [RC-012] | | [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-006], [RC-012] |
| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-007], [RC-013] | | [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-007], [RC-013] |
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-008] | | [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-008], [RC-014] |
| [MIL-005] | MIL | en | it | [MIL-004] | [MIL-006] | [RC-009] | | [MIL-005] | MIL | en | it | [MIL-004] | [MIL-006] | [RC-009] |
| [MIL-006] | MIL | en | it | [MIL-005] | [MIL-007] | [RC-010] | | [MIL-006] | MIL | en | it | [MIL-005] | [MIL-007] | [RC-010] |
| [MIL-007] | MIL | en | it | [MIL-006] | - | [RC-011] | | [MIL-007] | MIL | en | it | [MIL-006] | - | [RC-011] |
@@ -83,3 +83,4 @@ Tracks backward and forward links between artifact instances so that the traceab
[01e016e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/046-scraping-the-billboard/commit/01e016e15dd93cface4f4ae9e9e99919e0aace2b [01e016e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/046-scraping-the-billboard/commit/01e016e15dd93cface4f4ae9e9e99919e0aace2b
[RC-012]: ./reviews/rc-012-mil-002-code.md [RC-012]: ./reviews/rc-012-mil-002-code.md
[RC-013]: ./reviews/rc-013-mil-003-code.md [RC-013]: ./reviews/rc-013-mil-003-code.md
[RC-014]: ./reviews/rc-014-mil-004-code.md
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

@@ -5,6 +5,7 @@
from datetime import date from datetime import date
from typing import Protocol from typing import Protocol
from musical_time_machine.domain.playlist import Playlist
from musical_time_machine.domain.song import Song from musical_time_machine.domain.song import Song
@@ -18,3 +19,14 @@ class ChartSource(Protocol):
@return The songs in chart order. @return The songs in chart order.
""" """
... ...
class MusicService(Protocol):
"""@brief The user's music streaming account."""
def library_playlists(self) -> list[Playlist]:
"""@brief Get every playlist in the user's library.
@return The playlists of the library.
"""
...
@@ -4,7 +4,7 @@
from datetime import date from datetime import date
from musical_time_machine.application.ports import ChartSource from musical_time_machine.application.ports import ChartSource, MusicService
from musical_time_machine.domain.song import Song from musical_time_machine.domain.song import Song
@@ -26,3 +26,12 @@ def scrape_titles(chart_date: date, source: ChartSource) -> list[str]:
@return The titles in chart order. @return The titles in chart order.
""" """
return [song.title for song in scrape_songs(chart_date, source)] return [song.title for song in scrape_songs(chart_date, source)]
def count_library_playlists(service: MusicService) -> int:
"""@brief Count the playlists in the user's library.
@param service The music service to ask.
@return The number of playlists.
"""
return len(service.library_playlists())
+34
View File
@@ -92,3 +92,37 @@ SEARCH_FILTER: Final[str] = "songs"
## @brief Privacy status of the created playlist. ## @brief Privacy status of the created playlist.
PLAYLIST_PRIVACY: Final[str] = "PRIVATE" PLAYLIST_PRIVACY: Final[str] = "PRIVATE"
## @} ## @}
## @name Login check
## @{
## @brief Message with the number of library playlists; format with the count.
PLAYLIST_COUNT_MESSAGE: Final[str] = "Found {count} playlists in your library."
## @brief Message when browser.json is missing or rejected; format with reason and file.
BROWSER_RENEWAL_MESSAGE: Final[str] = (
"YouTube Music login failed: {reason}\n"
"To create or renew {file}:\n"
" 1. Open Firefox and go to https://music.youtube.com while logged in.\n"
" 2. Open the developer tools (F12) and select the Network tab.\n"
" 3. Filter the requests by 'browse' and select one POST request.\n"
" 4. Right-click it and choose Copy Request Headers.\n"
" 5. Run 'ytmusicapi browser' and paste the headers "
"(macOS: pbpaste | ytmusicapi browser)."
)
## @brief Reason when the browser file does not exist; format with the file.
BROWSER_FILE_MISSING_REASON: Final[str] = "{file} was not found."
## @brief Command line flag that checks the YouTube Music login.
CHECK_LOGIN_FLAG: Final[str] = "--check-login"
## @brief Help text of the check-login flag.
CHECK_LOGIN_HELP: Final[str] = "Log in to YouTube Music and print the number of library playlists."
## @brief Command line flag that names the browser file.
BROWSER_FILE_FLAG: Final[str] = "--browser-file"
## @brief Help text of the browser-file flag.
BROWSER_FILE_HELP: Final[str] = "Path of the ytmusicapi browser headers file."
## @}
@@ -17,3 +17,11 @@ class ChartUnavailableError(MusicalTimeMachineError):
class ChartParseError(MusicalTimeMachineError): class ChartParseError(MusicalTimeMachineError):
"""@brief The chart page holds no songs.""" """@brief The chart page holds no songs."""
class BrowserFileMissingError(MusicalTimeMachineError):
"""@brief The browser authentication file does not exist."""
class AuthenticationError(MusicalTimeMachineError):
"""@brief YouTube Music rejected the browser authentication headers."""
@@ -0,0 +1,17 @@
"""@file
@brief The Playlist value object.
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Playlist:
"""@brief One playlist of the user's library.
@param playlist_id Identifier given by the music service.
@param title Playlist title.
"""
playlist_id: str
title: str
@@ -0,0 +1,90 @@
"""@file
@brief YouTube Music (ytmusicapi) as a music service.
"""
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Any, Protocol
from ytmusicapi import YTMusic
from ytmusicapi.exceptions import YTMusicError
from musical_time_machine.constants import (
BROWSER_FILE_MISSING_REASON,
BROWSER_FILE_NAME,
BROWSER_RENEWAL_MESSAGE,
)
from musical_time_machine.domain.errors import AuthenticationError, BrowserFileMissingError
from musical_time_machine.domain.playlist import Playlist
_logger = logging.getLogger(__name__)
## ytmusicapi errors plus the parsing errors a stale or malformed headers file causes.
_CLIENT_ERRORS = (YTMusicError, KeyError, ValueError)
class YTMusicLike(Protocol):
"""@brief The part of the ytmusicapi client this adapter uses."""
def get_library_playlists(
self,
limit: int | None = ...,
) -> list[dict[str, Any]]: # ytmusicapi returns untyped JSON dictionaries
"""@brief List the library playlists as raw dictionaries."""
...
class YouTubeMusicService:
"""@brief MusicService backed by ytmusicapi and a browser headers file."""
def __init__(
self,
browser_file: Path = Path(BROWSER_FILE_NAME),
client_factory: Callable[[str], YTMusicLike] = YTMusic,
) -> None:
"""@brief Create the service; the network is not touched until first use.
@param browser_file File with the browser request headers.
@param client_factory Builds a client from the path of the browser file.
"""
self._browser_file = browser_file
self._client_factory = client_factory
self._client: YTMusicLike | None = None
def library_playlists(self) -> list[Playlist]:
"""@brief Get every playlist in the user's library.
@return The playlists of the library.
@throws BrowserFileMissingError when the browser file does not exist.
@throws AuthenticationError when YouTube Music rejects the headers.
"""
client = self._connect()
try:
raw = client.get_library_playlists(limit=None)
return [
Playlist(playlist_id=item["playlistId"], title=item["title"])
for item in raw
if item.get("playlistId") and item.get("title")
]
except _CLIENT_ERRORS as error:
raise self._rejected(error) from error
def _connect(self) -> YTMusicLike:
if self._client is not None:
return self._client
if not self._browser_file.is_file():
reason = BROWSER_FILE_MISSING_REASON.format(file=self._browser_file)
raise BrowserFileMissingError(self._renewal(reason))
try:
self._client = self._client_factory(str(self._browser_file))
except _CLIENT_ERRORS as error:
raise self._rejected(error) from error
return self._client
def _rejected(self, error: Exception) -> AuthenticationError:
_logger.debug("YouTube Music rejected the login", exc_info=error)
return AuthenticationError(self._renewal(str(error)))
def _renewal(self, reason: str) -> str:
return BROWSER_RENEWAL_MESSAGE.format(reason=reason, file=self._browser_file)
+44 -6
View File
@@ -2,32 +2,70 @@
@brief Command line entry point and composition root. @brief Command line entry point and composition root.
""" """
import argparse
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path
from musical_time_machine.application.ports import ChartSource from musical_time_machine.application.ports import ChartSource, MusicService
from musical_time_machine.application.use_cases import scrape_songs from musical_time_machine.application.use_cases import count_library_playlists, scrape_songs
from musical_time_machine.constants import DATE_PROMPT, EXIT_FAILURE, EXIT_OK, SONG_LINE_TEMPLATE from musical_time_machine.constants import (
BROWSER_FILE_FLAG,
BROWSER_FILE_HELP,
BROWSER_FILE_NAME,
CHECK_LOGIN_FLAG,
CHECK_LOGIN_HELP,
DATE_PROMPT,
EXIT_FAILURE,
EXIT_OK,
PLAYLIST_COUNT_MESSAGE,
SONG_LINE_TEMPLATE,
)
from musical_time_machine.domain.chart_date import parse_chart_date from musical_time_machine.domain.chart_date import parse_chart_date
from musical_time_machine.domain.errors import MusicalTimeMachineError from musical_time_machine.domain.errors import MusicalTimeMachineError
from musical_time_machine.infrastructure.bakeboard import BakeboardChartSource from musical_time_machine.infrastructure.bakeboard import BakeboardChartSource
from musical_time_machine.infrastructure.ytmusic import YouTubeMusicService
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="musical_time_machine")
parser.add_argument(CHECK_LOGIN_FLAG, action="store_true", help=CHECK_LOGIN_HELP)
parser.add_argument(
BROWSER_FILE_FLAG, type=Path, default=Path(BROWSER_FILE_NAME), help=BROWSER_FILE_HELP
)
return parser.parse_args(argv)
def _check_login(service: MusicService, write: Callable[[str], None]) -> int:
try:
count = count_library_playlists(service)
except MusicalTimeMachineError as error:
write(str(error))
return EXIT_FAILURE
write(PLAYLIST_COUNT_MESSAGE.format(count=count))
return EXIT_OK
def main( def main(
argv: list[str] | None = None, argv: list[str] | None = None,
*, *,
source: ChartSource | None = None, source: ChartSource | None = None,
music_service: MusicService | None = None,
read: Callable[[str], str] = input, read: Callable[[str], str] = input,
write: Callable[[str], None] = print, write: Callable[[str], None] = print,
) -> int: ) -> int:
"""@brief Ask for a date and print the songs of that chart. """@brief Ask for a date and print the songs of that chart, or check the YouTube Music login.
@param argv Command line arguments; unused, kept for the entry point contract. @param argv Command line arguments: `--check-login` and `--browser-file PATH`.
@param source Chart source; the Bakeboard site when omitted. @param source Chart source; the Bakeboard site when omitted.
@param music_service Music service for `--check-login`; YouTube Music when omitted.
@param read Function that prompts and returns the user's answer. @param read Function that prompts and returns the user's answer.
@param write Function that shows one line to the user. @param write Function that shows one line to the user.
@return Process exit code: 0 on success, 1 when a domain error stopped the run. @return Process exit code: 0 on success, 1 when a domain error stopped the run.
""" """
del argv args = _parse_args(argv)
if args.check_login:
service = music_service or YouTubeMusicService(browser_file=args.browser_file)
return _check_login(service, write)
chart_source = source if source is not None else BakeboardChartSource() chart_source = source if source is not None else BakeboardChartSource()
try: try:
chart_date = parse_chart_date(read(DATE_PROMPT)) chart_date = parse_chart_date(read(DATE_PROMPT))
+16
View File
@@ -7,6 +7,7 @@ from pathlib import Path
import pytest import pytest
from musical_time_machine.domain.playlist import Playlist
from musical_time_machine.domain.song import Song from musical_time_machine.domain.song import Song
FIXTURE_PAGE = Path(__file__).parent / "fixtures" / "bakeboard-2026-04-18.html" FIXTURE_PAGE = Path(__file__).parent / "fixtures" / "bakeboard-2026-04-18.html"
@@ -31,3 +32,18 @@ class FakeChartSource:
if self._error is not None: if self._error is not None:
raise self._error raise self._error
return self._songs return self._songs
class FakeMusicService:
"""@brief MusicService returning fixed playlists, or raising a given error."""
def __init__(
self, playlists: list[Playlist] | None = None, error: Exception | None = None
) -> None:
self._playlists = playlists or []
self._error = error
def library_playlists(self) -> list[Playlist]:
if self._error is not None:
raise self._error
return self._playlists
@@ -0,0 +1,25 @@
"""@file
@brief Tests of the count_library_playlists use case.
"""
import pytest
from conftest import FakeMusicService
from musical_time_machine.application.use_cases import count_library_playlists
from musical_time_machine.domain.errors import AuthenticationError
from musical_time_machine.domain.playlist import Playlist
def test_count_library_playlists_returns_number_of_playlists() -> None:
service = FakeMusicService([Playlist("a", "A"), Playlist("b", "B")])
assert count_library_playlists(service) == 2
def test_count_library_playlists_returns_zero_for_empty_library() -> None:
assert count_library_playlists(FakeMusicService()) == 0
def test_count_library_playlists_propagates_authentication_error() -> None:
with pytest.raises(AuthenticationError):
count_library_playlists(FakeMusicService(error=AuthenticationError("no")))
+110
View File
@@ -0,0 +1,110 @@
"""@file
@brief Tests of the YouTube Music adapter with a fake client factory.
"""
from pathlib import Path
from typing import Any
import pytest
from ytmusicapi.exceptions import YTMusicUserError
from musical_time_machine.domain.errors import AuthenticationError, BrowserFileMissingError
from musical_time_machine.domain.playlist import Playlist
from musical_time_machine.infrastructure.ytmusic import YouTubeMusicService, YTMusicLike
class _FakeClient:
def __init__(self, items: list[dict[str, Any]], error: Exception | None = None) -> None:
self._items = items
self._error = error
self.limits: list[int | None] = []
def get_library_playlists(self, limit: int | None = 25) -> list[dict[str, Any]]:
self.limits.append(limit)
if self._error is not None:
raise self._error
return self._items
def _browser_file(tmp_path: Path) -> Path:
path = tmp_path / "browser.json"
path.write_text("{}", encoding="utf-8")
return path
def test_constructor_does_not_call_the_factory(tmp_path: Path) -> None:
calls: list[str] = []
def factory(auth: str) -> YTMusicLike:
calls.append(auth)
return _FakeClient([])
YouTubeMusicService(tmp_path / "missing.json", factory)
assert calls == []
def test_library_playlists_maps_entries_and_asks_for_all(tmp_path: Path) -> None:
client = _FakeClient(
[{"playlistId": "p1", "title": "One"}, {"playlistId": "p2", "title": "Two"}]
)
service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client)
assert service.library_playlists() == [Playlist("p1", "One"), Playlist("p2", "Two")]
assert client.limits == [None]
def test_library_playlists_skips_entries_without_id_or_title(tmp_path: Path) -> None:
client = _FakeClient(
[{"title": "No id"}, {"playlistId": "p"}, {"playlistId": "ok", "title": "T"}]
)
service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client)
assert service.library_playlists() == [Playlist("ok", "T")]
def test_library_playlists_raises_when_browser_file_missing(tmp_path: Path) -> None:
service = YouTubeMusicService(tmp_path / "browser.json", lambda _auth: _FakeClient([]))
with pytest.raises(BrowserFileMissingError) as info:
service.library_playlists()
message = str(info.value)
for step in ("Firefox", "music.youtube.com", "browse", "ytmusicapi browser", "pbpaste"):
assert step in message
def test_library_playlists_raises_authentication_error_when_factory_fails(tmp_path: Path) -> None:
def factory(_auth: str) -> YTMusicLike:
raise YTMusicUserError("bad headers")
service = YouTubeMusicService(_browser_file(tmp_path), factory)
with pytest.raises(AuthenticationError, match="ytmusicapi browser") as info:
service.library_playlists()
assert isinstance(info.value.__cause__, YTMusicUserError)
@pytest.mark.parametrize("error", [YTMusicUserError("x"), KeyError("cookie"), ValueError("bad")])
def test_library_playlists_raises_authentication_error_when_listing_fails(
tmp_path: Path, error: Exception
) -> None:
client = _FakeClient([], error)
service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client)
with pytest.raises(AuthenticationError):
service.library_playlists()
def test_library_playlists_builds_the_client_once(tmp_path: Path) -> None:
calls: list[str] = []
def factory(auth: str) -> YTMusicLike:
calls.append(auth)
return _FakeClient([])
service = YouTubeMusicService(_browser_file(tmp_path), factory)
service.library_playlists()
service.library_playlists()
assert len(calls) == 1
+55 -2
View File
@@ -2,10 +2,17 @@
@brief Tests of the command line with a fake source and injected input and output. @brief Tests of the command line with a fake source and injected input and output.
""" """
from conftest import FakeChartSource from pathlib import Path
from conftest import FakeChartSource, FakeMusicService
from musical_time_machine.constants import CHART_SIZE, DATE_PROMPT from musical_time_machine.constants import CHART_SIZE, DATE_PROMPT
from musical_time_machine.domain.errors import ChartParseError, ChartUnavailableError from musical_time_machine.domain.errors import (
BrowserFileMissingError,
ChartParseError,
ChartUnavailableError,
)
from musical_time_machine.domain.playlist import Playlist
from musical_time_machine.domain.song import Song from musical_time_machine.domain.song import Song
from musical_time_machine.presentation.cli import main from musical_time_machine.presentation.cli import main
@@ -57,3 +64,49 @@ def test_main_returns_one_when_chart_has_no_songs() -> None:
assert code == 1 assert code == 1
assert lines == ["empty"] assert lines == ["empty"]
def test_main_check_login_prints_playlist_count() -> None:
lines: list[str] = []
service = FakeMusicService([Playlist("a", "A"), Playlist("b", "B"), Playlist("c", "C")])
code = main(["--check-login"], music_service=service, write=lines.append)
assert code == 0
assert lines == ["Found 3 playlists in your library."]
def test_main_check_login_returns_one_when_browser_file_missing() -> None:
lines: list[str] = []
service = FakeMusicService(error=BrowserFileMissingError("browser.json was not found."))
code = main(["--check-login"], music_service=service, write=lines.append)
assert code == 1
assert lines == ["browser.json was not found."]
def test_main_check_login_with_missing_real_file_returns_one(tmp_path: Path) -> None:
lines: list[str] = []
argv = ["--check-login", "--browser-file", str(tmp_path / "none.json")]
code = main(argv, write=lines.append)
assert code == 1
assert "ytmusicapi browser" in lines[0]
def test_main_without_flag_does_not_use_the_music_service() -> None:
lines: list[str] = []
service = FakeMusicService(error=BrowserFileMissingError("must not be called"))
code = main(
[],
source=FakeChartSource([Song("T", "A")]),
music_service=service,
read=lambda _prompt: "2026-04-18",
write=lines.append,
)
assert code == 0
assert lines == ["1. T - A"]