diff --git a/README.md b/README.md index 78f1c24..53f6387 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The program is added step by step (one pull request per lecture step): | Step | Status | | --- | --- | | 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 | | 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) +### 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. ## 🧪 Tests diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index a57af3f..780941b 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g. | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 | | GOV | Governance / ARB Workflow | docs/sqa/governance.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 diff --git a/docs/sqa/reviews/rc-014-mil-004-code.md b/docs/sqa/reviews/rc-014-mil-004-code.md new file mode 100644 index 0000000..38aa1d3 --- /dev/null +++ b/docs/sqa/reviews/rc-014-mil-004-code.md @@ -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/ 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 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 256f80d..5132e6a 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -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-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-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-006] | MIL | en | it | [MIL-005] | [MIL-007] | [RC-010] | | [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 [RC-012]: ./reviews/rc-012-mil-002-code.md [RC-013]: ./reviews/rc-013-mil-003-code.md +[RC-014]: ./reviews/rc-014-mil-004-code.md diff --git a/docs/wiki/images/step2-check-login.png b/docs/wiki/images/step2-check-login.png new file mode 100644 index 0000000..3b8186f Binary files /dev/null and b/docs/wiki/images/step2-check-login.png differ diff --git a/src/musical_time_machine/application/ports.py b/src/musical_time_machine/application/ports.py index 1d48ea0..6e33824 100644 --- a/src/musical_time_machine/application/ports.py +++ b/src/musical_time_machine/application/ports.py @@ -5,6 +5,7 @@ from datetime import date from typing import Protocol +from musical_time_machine.domain.playlist import Playlist from musical_time_machine.domain.song import Song @@ -18,3 +19,14 @@ class ChartSource(Protocol): @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. + """ + ... diff --git a/src/musical_time_machine/application/use_cases.py b/src/musical_time_machine/application/use_cases.py index a6d1eaa..9f2e674 100644 --- a/src/musical_time_machine/application/use_cases.py +++ b/src/musical_time_machine/application/use_cases.py @@ -4,7 +4,7 @@ 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 @@ -26,3 +26,12 @@ def scrape_titles(chart_date: date, source: ChartSource) -> list[str]: @return The titles in chart order. """ 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()) diff --git a/src/musical_time_machine/constants.py b/src/musical_time_machine/constants.py index 58266b1..7cb1b0a 100644 --- a/src/musical_time_machine/constants.py +++ b/src/musical_time_machine/constants.py @@ -92,3 +92,37 @@ SEARCH_FILTER: Final[str] = "songs" ## @brief Privacy status of the created playlist. 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." +## @} diff --git a/src/musical_time_machine/domain/errors.py b/src/musical_time_machine/domain/errors.py index 6f382bb..0ebf324 100644 --- a/src/musical_time_machine/domain/errors.py +++ b/src/musical_time_machine/domain/errors.py @@ -17,3 +17,11 @@ class ChartUnavailableError(MusicalTimeMachineError): class ChartParseError(MusicalTimeMachineError): """@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.""" diff --git a/src/musical_time_machine/domain/playlist.py b/src/musical_time_machine/domain/playlist.py new file mode 100644 index 0000000..c2c5f58 --- /dev/null +++ b/src/musical_time_machine/domain/playlist.py @@ -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 diff --git a/src/musical_time_machine/infrastructure/ytmusic.py b/src/musical_time_machine/infrastructure/ytmusic.py new file mode 100644 index 0000000..074bc74 --- /dev/null +++ b/src/musical_time_machine/infrastructure/ytmusic.py @@ -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) diff --git a/src/musical_time_machine/presentation/cli.py b/src/musical_time_machine/presentation/cli.py index 981bc41..f8c60d4 100644 --- a/src/musical_time_machine/presentation/cli.py +++ b/src/musical_time_machine/presentation/cli.py @@ -2,32 +2,70 @@ @brief Command line entry point and composition root. """ +import argparse from collections.abc import Callable +from pathlib import Path -from musical_time_machine.application.ports import ChartSource -from musical_time_machine.application.use_cases import scrape_songs -from musical_time_machine.constants import DATE_PROMPT, EXIT_FAILURE, EXIT_OK, SONG_LINE_TEMPLATE +from musical_time_machine.application.ports import ChartSource, MusicService +from musical_time_machine.application.use_cases import count_library_playlists, scrape_songs +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.errors import MusicalTimeMachineError 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( argv: list[str] | None = None, *, source: ChartSource | None = None, + music_service: MusicService | None = None, read: Callable[[str], str] = input, write: Callable[[str], None] = print, ) -> 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 music_service Music service for `--check-login`; YouTube Music when omitted. @param read Function that prompts and returns the user's answer. @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. """ - 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() try: chart_date = parse_chart_date(read(DATE_PROMPT)) diff --git a/tests/conftest.py b/tests/conftest.py index b5d9eba..3e48fdd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest +from musical_time_machine.domain.playlist import Playlist from musical_time_machine.domain.song import Song FIXTURE_PAGE = Path(__file__).parent / "fixtures" / "bakeboard-2026-04-18.html" @@ -31,3 +32,18 @@ class FakeChartSource: if self._error is not None: raise self._error 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 diff --git a/tests/unit/application/test_count_library_playlists.py b/tests/unit/application/test_count_library_playlists.py new file mode 100644 index 0000000..0ca182c --- /dev/null +++ b/tests/unit/application/test_count_library_playlists.py @@ -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"))) diff --git a/tests/unit/infrastructure/test_ytmusic.py b/tests/unit/infrastructure/test_ytmusic.py new file mode 100644 index 0000000..78b4d51 --- /dev/null +++ b/tests/unit/infrastructure/test_ytmusic.py @@ -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 diff --git a/tests/unit/presentation/test_cli.py b/tests/unit/presentation/test_cli.py index ca7dd7c..d512c1d 100644 --- a/tests/unit/presentation/test_cli.py +++ b/tests/unit/presentation/test_cli.py @@ -2,10 +2,17 @@ @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.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.presentation.cli import main @@ -57,3 +64,49 @@ def test_main_returns_one_when_chart_has_no_songs() -> None: assert code == 1 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"]