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

|

|
||||||
|
|
||||||
|
### 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.
|
The screenshots and the instructions of the next steps are added with each step.
|
||||||
|
|
||||||
## 🧪 Tests
|
## 🧪 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 |
|
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -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-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())
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")))
|
||||||
@@ -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.
|
@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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user