MIL-004: Step 2 - YouTube Music authentication #46
@@ -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
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
The screenshots and the instructions of the next steps are added with each step.
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<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
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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."
|
||||
## @}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user