From 62cb520d1e856a97d3e63adee942274629751c38 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:29:48 +0800 Subject: [PATCH 1/2] Add step 2: ytmusicapi login check, clear missing-file message and tests Task: MIL-004#1 --- README.md | 16 ++- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-014-mil-004-code.md | 61 ++++++++++ docs/sqa/traceability-matrix.md | 3 +- docs/wiki/images/step2-check-login.png | Bin 0 -> 61264 bytes src/musical_time_machine/application/ports.py | 12 ++ .../application/use_cases.py | 11 +- src/musical_time_machine/constants.py | 34 ++++++ src/musical_time_machine/domain/errors.py | 8 ++ src/musical_time_machine/domain/playlist.py | 17 +++ .../infrastructure/ytmusic.py | 90 ++++++++++++++ src/musical_time_machine/presentation/cli.py | 50 +++++++- tests/conftest.py | 16 +++ .../test_count_library_playlists.py | 25 ++++ tests/unit/infrastructure/test_ytmusic.py | 110 ++++++++++++++++++ tests/unit/presentation/test_cli.py | 57 ++++++++- 16 files changed, 500 insertions(+), 12 deletions(-) create mode 100644 docs/sqa/reviews/rc-014-mil-004-code.md create mode 100644 docs/wiki/images/step2-check-login.png create mode 100644 src/musical_time_machine/domain/playlist.py create mode 100644 src/musical_time_machine/infrastructure/ytmusic.py create mode 100644 tests/unit/application/test_count_library_playlists.py create mode 100644 tests/unit/infrastructure/test_ytmusic.py 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..ff0f8ee --- /dev/null +++ b/docs/sqa/reviews/rc-014-mil-004-code.md @@ -0,0 +1,61 @@ +# 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 | pending | + +--- + +## 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 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 0000000000000000000000000000000000000000..3b8186f61e166575bef45d0e549e4342b06c67ff GIT binary patch literal 61264 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 Zgvf+i#DYYM26cAYs!E!Q9~I04{{sN>3X=c; literal 0 HcmV?d00001 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"] -- 2.54.0 From 51da198bb5bc7b5babeb7bd5434ed49138e95312 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:29:50 +0800 Subject: [PATCH 2/2] Resolve pending commit links of the step 2 review Task: MIL-004#1 --- docs/sqa/reviews/rc-014-mil-004-code.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-014-mil-004-code.md b/docs/sqa/reviews/rc-014-mil-004-code.md index ff0f8ee..38aa1d3 100644 --- a/docs/sqa/reviews/rc-014-mil-004-code.md +++ b/docs/sqa/reviews/rc-014-mil-004-code.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [62cb520] | --- @@ -59,3 +59,4 @@ Go โ€” every Mandatory criterion passes or does not apply; no blocking finding. [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.54.0