MIL-005: Step 3 - Create the playlist #47

Merged
Tirsvad merged 2 commits from mil-005-step-3-create-playlist into main 2026-10-28 07:32:44 +01:00
17 changed files with 432 additions and 11 deletions
Showing only changes of commit 27a060650a - Show all commits
+13 -1
View File
@@ -83,7 +83,7 @@ The program is added step by step (one pull request per lecture step):
| --- | --- | | --- | --- |
| 1. Scrape the Billboard Hot 100 | done (below) | | 1. Scrape the Billboard Hot 100 | done (below) |
| 2. YouTube Music authentication | code done; live screenshot waits for `browser.json` | | 2. YouTube Music authentication | code done; live screenshot waits for `browser.json` |
| 3. Create the playlist | planned in milestone 5 | | 3. Create the playlist | code done; live screenshot waits for `browser.json` |
| 4. Search and add songs | planned in milestone 6 | | 4. Search and add songs | planned in milestone 6 |
### Step 1 - Scrape the Billboard Hot 100 ### Step 1 - Scrape the Billboard Hot 100
@@ -116,6 +116,18 @@ The screenshot below shows the run **without** a `browser.json` and the tests th
![Login check without browser.json and the step 2 tests](docs/wiki/images/step2-check-login.png) ![Login check without browser.json and the step 2 tests](docs/wiki/images/step2-check-login.png)
### Step 3 - Create the playlist
```bash
python -m musical_time_machine --create-playlist
```
It asks for the date and creates the private playlist `YYYY-MM-DD Billboard 100` in your YouTube Music library. It first looks through `get_library_playlists()` for a playlist with the same title, so a second run for the same date creates nothing and says so.
The screenshot shows the tests of this step, which use a fake YouTube Music service (no account needed). A screenshot of the live playlist is added once `browser.json` exists (issue 27 stays open until then).
![Tests of step 3: the playlist is created once](docs/wiki/images/step3-create-playlist.png)
The screenshots and the instructions of the next steps are added with each step. The screenshots and the instructions of the next steps are added with each step.
## 🧪 Tests ## 🧪 Tests
+1 -1
View File
@@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 | | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 |
| GOV | Governance / ARB Workflow | docs/sqa/governance.md | 002 | | GOV | Governance / ARB Workflow | docs/sqa/governance.md | 002 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 015 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 |
## Languages ## Languages
+61
View File
@@ -0,0 +1,61 @@
# Review of the code of MIL-005
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-015 |
| CrossReference | [MIL-005], [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-005] (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 | Casing follows PEP 8. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (playlist_name, PlaylistCreationError). |
| 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 including -> None. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | ytmusicapi errors are re-raised with `from`; a non-id answer raises PlaylistCreationError. |
| 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 | No files or connections are opened by the new code. |
| 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 | Output goes through the injected writer; nothing secret is 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 | 65 tests, 98 % coverage, no network; first call, second call and other date are tested. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | mypy is not configured; the only dictionary-or-id return is typed in the YTMusicLike protocol. Optional criterion. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Checked in the diff. |
| 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 | Title and description templates are in constants.py and built in one place each (domain/playlist.py). |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | Domain imports only constants; application imports domain; ytmusicapi only in infrastructure. |
| 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 stays a narrow Protocol. |
| 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-005]: ../../milestones/mil-005-step-3-create-playlist.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
+2 -1
View File
@@ -29,7 +29,7 @@ Tracks backward and forward links between artifact instances so that the traceab
| [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], [RC-014] | | [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], [RC-015] |
| [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] |
| [TM-001] | TM | - | - | [BC-001] | - | - | | [TM-001] | TM | - | - | [BC-001] | - | - |
@@ -84,3 +84,4 @@ Tracks backward and forward links between artifact instances so that the traceab
[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 [RC-014]: ./reviews/rc-014-mil-004-code.md
[RC-015]: ./reviews/rc-015-mil-005-code.md
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -30,3 +30,13 @@ class MusicService(Protocol):
@return The playlists of the library. @return The playlists of the library.
""" """
... ...
def create_playlist(self, title: str, description: str, privacy: str) -> str:
"""@brief Create a playlist in the user's library.
@param title Playlist title.
@param description Playlist description.
@param privacy Privacy status, for example "PRIVATE".
@return The identifier of the new playlist.
"""
...
@@ -2,12 +2,29 @@
@brief Use cases of the Musical Time Machine. @brief Use cases of the Musical Time Machine.
""" """
from dataclasses import dataclass
from datetime import date from datetime import date
from musical_time_machine.application.ports import ChartSource, MusicService from musical_time_machine.application.ports import ChartSource, MusicService
from musical_time_machine.constants import PLAYLIST_PRIVACY
from musical_time_machine.domain.playlist import playlist_description, playlist_name
from musical_time_machine.domain.song import Song from musical_time_machine.domain.song import Song
@dataclass(frozen=True, slots=True)
class PlaylistResult:
"""@brief Outcome of create_playlist.
@param playlist_id Identifier of the new or the existing playlist.
@param title Playlist title.
@param created True when a playlist was made, False when it already existed.
"""
playlist_id: str
title: str
created: bool
def scrape_songs(chart_date: date, source: ChartSource) -> list[Song]: def scrape_songs(chart_date: date, source: ChartSource) -> list[Song]:
"""@brief Get the songs of the chart of a date. """@brief Get the songs of the chart of a date.
@@ -35,3 +52,18 @@ def count_library_playlists(service: MusicService) -> int:
@return The number of playlists. @return The number of playlists.
""" """
return len(service.library_playlists()) return len(service.library_playlists())
def create_playlist(chart_date: date, service: MusicService) -> PlaylistResult:
"""@brief Create the private playlist of a chart date unless it already exists.
@param chart_date Date of the chart.
@param service The music service to ask.
@return The new playlist, or the existing one with the same title.
"""
title = playlist_name(chart_date)
for playlist in service.library_playlists():
if playlist.title == title:
return PlaylistResult(playlist.playlist_id, title, created=False)
playlist_id = service.create_playlist(title, playlist_description(chart_date), PLAYLIST_PRIVACY)
return PlaylistResult(playlist_id, title, created=True)
+21
View File
@@ -126,3 +126,24 @@ BROWSER_FILE_FLAG: Final[str] = "--browser-file"
## @brief Help text of the browser-file flag. ## @brief Help text of the browser-file flag.
BROWSER_FILE_HELP: Final[str] = "Path of the ytmusicapi browser headers file." BROWSER_FILE_HELP: Final[str] = "Path of the ytmusicapi browser headers file."
## @} ## @}
## @name Create playlist
## @{
## @brief Command line flag that creates the dated playlist.
CREATE_PLAYLIST_FLAG: Final[str] = "--create-playlist"
## @brief Help text of the create-playlist flag.
CREATE_PLAYLIST_HELP: Final[str] = (
"Create the private playlist '<date> Billboard 100' in YouTube Music."
)
## @brief Message after a playlist was created; format with the title.
PLAYLIST_CREATED_MESSAGE: Final[str] = 'Created playlist "{title}".'
## @brief Message when the playlist already exists; format with the title.
PLAYLIST_EXISTS_MESSAGE: Final[str] = 'Playlist "{title}" already exists; nothing was created.'
## @brief Message when YouTube Music returned no playlist id; format with the answer.
PLAYLIST_CREATION_FAILED_MESSAGE: Final[str] = "The playlist could not be created: {reason}"
## @}
@@ -25,3 +25,7 @@ class BrowserFileMissingError(MusicalTimeMachineError):
class AuthenticationError(MusicalTimeMachineError): class AuthenticationError(MusicalTimeMachineError):
"""@brief YouTube Music rejected the browser authentication headers.""" """@brief YouTube Music rejected the browser authentication headers."""
class PlaylistCreationError(MusicalTimeMachineError):
"""@brief YouTube Music did not create the playlist."""
@@ -3,6 +3,13 @@
""" """
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date
from musical_time_machine.constants import (
DATE_FORMAT,
PLAYLIST_DESCRIPTION_TEMPLATE,
PLAYLIST_NAME_TEMPLATE,
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -15,3 +22,21 @@ class Playlist:
playlist_id: str playlist_id: str
title: str title: str
def playlist_name(chart_date: date) -> str:
"""@brief Get the title of the playlist for a chart date.
@param chart_date Date of the chart.
@return The title, for example "2026-04-18 Billboard 100".
"""
return PLAYLIST_NAME_TEMPLATE.format(date=chart_date.strftime(DATE_FORMAT))
def playlist_description(chart_date: date) -> str:
"""@brief Get the description of the playlist for a chart date.
@param chart_date Date of the chart.
@return The description text.
"""
return PLAYLIST_DESCRIPTION_TEMPLATE.format(date=chart_date.strftime(DATE_FORMAT))
@@ -14,8 +14,13 @@ from musical_time_machine.constants import (
BROWSER_FILE_MISSING_REASON, BROWSER_FILE_MISSING_REASON,
BROWSER_FILE_NAME, BROWSER_FILE_NAME,
BROWSER_RENEWAL_MESSAGE, BROWSER_RENEWAL_MESSAGE,
PLAYLIST_CREATION_FAILED_MESSAGE,
)
from musical_time_machine.domain.errors import (
AuthenticationError,
BrowserFileMissingError,
PlaylistCreationError,
) )
from musical_time_machine.domain.errors import AuthenticationError, BrowserFileMissingError
from musical_time_machine.domain.playlist import Playlist from musical_time_machine.domain.playlist import Playlist
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -34,6 +39,15 @@ class YTMusicLike(Protocol):
"""@brief List the library playlists as raw dictionaries.""" """@brief List the library playlists as raw dictionaries."""
... ...
def create_playlist(
self,
title: str,
description: str,
privacy_status: str = ...,
) -> str | dict[str, Any]: # the id on success, an error dictionary on failure
"""@brief Create a playlist; returns its id or an error dictionary."""
...
class YouTubeMusicService: class YouTubeMusicService:
"""@brief MusicService backed by ytmusicapi and a browser headers file.""" """@brief MusicService backed by ytmusicapi and a browser headers file."""
@@ -70,6 +84,26 @@ class YouTubeMusicService:
except _CLIENT_ERRORS as error: except _CLIENT_ERRORS as error:
raise self._rejected(error) from error raise self._rejected(error) from error
def create_playlist(self, title: str, description: str, privacy: str) -> str:
"""@brief Create a playlist in the user's library.
@param title Playlist title.
@param description Playlist description.
@param privacy Privacy status, for example "PRIVATE".
@return The identifier of the new playlist.
@throws BrowserFileMissingError when the browser file does not exist.
@throws AuthenticationError when YouTube Music rejects the headers.
@throws PlaylistCreationError when YouTube Music answers without a playlist id.
"""
client = self._connect()
try:
result = client.create_playlist(title, description, privacy_status=privacy)
except _CLIENT_ERRORS as error:
raise self._rejected(error) from error
if not isinstance(result, str):
raise PlaylistCreationError(PLAYLIST_CREATION_FAILED_MESSAGE.format(reason=result))
return result
def _connect(self) -> YTMusicLike: def _connect(self) -> YTMusicLike:
if self._client is not None: if self._client is not None:
return self._client return self._client
+30 -4
View File
@@ -7,17 +7,26 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from musical_time_machine.application.ports import ChartSource, MusicService 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.application.use_cases import (
PlaylistResult,
count_library_playlists,
create_playlist,
scrape_songs,
)
from musical_time_machine.constants import ( from musical_time_machine.constants import (
BROWSER_FILE_FLAG, BROWSER_FILE_FLAG,
BROWSER_FILE_HELP, BROWSER_FILE_HELP,
BROWSER_FILE_NAME, BROWSER_FILE_NAME,
CHECK_LOGIN_FLAG, CHECK_LOGIN_FLAG,
CHECK_LOGIN_HELP, CHECK_LOGIN_HELP,
CREATE_PLAYLIST_FLAG,
CREATE_PLAYLIST_HELP,
DATE_PROMPT, DATE_PROMPT,
EXIT_FAILURE, EXIT_FAILURE,
EXIT_OK, EXIT_OK,
PLAYLIST_COUNT_MESSAGE, PLAYLIST_COUNT_MESSAGE,
PLAYLIST_CREATED_MESSAGE,
PLAYLIST_EXISTS_MESSAGE,
SONG_LINE_TEMPLATE, 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
@@ -29,6 +38,7 @@ from musical_time_machine.infrastructure.ytmusic import YouTubeMusicService
def _parse_args(argv: list[str] | None) -> argparse.Namespace: def _parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="musical_time_machine") parser = argparse.ArgumentParser(prog="musical_time_machine")
parser.add_argument(CHECK_LOGIN_FLAG, action="store_true", help=CHECK_LOGIN_HELP) parser.add_argument(CHECK_LOGIN_FLAG, action="store_true", help=CHECK_LOGIN_HELP)
parser.add_argument(CREATE_PLAYLIST_FLAG, action="store_true", help=CREATE_PLAYLIST_HELP)
parser.add_argument( parser.add_argument(
BROWSER_FILE_FLAG, type=Path, default=Path(BROWSER_FILE_NAME), help=BROWSER_FILE_HELP BROWSER_FILE_FLAG, type=Path, default=Path(BROWSER_FILE_NAME), help=BROWSER_FILE_HELP
) )
@@ -45,6 +55,19 @@ def _check_login(service: MusicService, write: Callable[[str], None]) -> int:
return EXIT_OK return EXIT_OK
def _create_playlist(
service: MusicService, read: Callable[[str], str], write: Callable[[str], None]
) -> int:
try:
result: PlaylistResult = create_playlist(parse_chart_date(read(DATE_PROMPT)), service)
except MusicalTimeMachineError as error:
write(str(error))
return EXIT_FAILURE
message = PLAYLIST_CREATED_MESSAGE if result.created else PLAYLIST_EXISTS_MESSAGE
write(message.format(title=result.title))
return EXIT_OK
def main( def main(
argv: list[str] | None = None, argv: list[str] | None = None,
*, *,
@@ -53,11 +76,11 @@ def main(
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, or check the YouTube Music login. """@brief Ask for a date and print that chart, check the login or create a playlist.
@param argv Command line arguments: `--check-login` and `--browser-file PATH`. @param argv Arguments: `--check-login`, `--create-playlist`, `--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 music_service Music service for the YouTube Music flags; 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.
@@ -66,6 +89,9 @@ def main(
if args.check_login: if args.check_login:
service = music_service or YouTubeMusicService(browser_file=args.browser_file) service = music_service or YouTubeMusicService(browser_file=args.browser_file)
return _check_login(service, write) return _check_login(service, write)
if args.create_playlist:
service = music_service or YouTubeMusicService(browser_file=args.browser_file)
return _create_playlist(service, read, 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))
+10 -1
View File
@@ -40,8 +40,17 @@ class FakeMusicService:
def __init__( def __init__(
self, playlists: list[Playlist] | None = None, error: Exception | None = None self, playlists: list[Playlist] | None = None, error: Exception | None = None
) -> None: ) -> None:
self._playlists = playlists or [] self._playlists = list(playlists or [])
self._error = error self._error = error
self.created: list[tuple[str, str, str]] = []
def create_playlist(self, title: str, description: str, privacy: str) -> str:
if self._error is not None:
raise self._error
self.created.append((title, description, privacy))
playlist_id = f"new-{len(self._playlists) + 1}"
self._playlists.append(Playlist(playlist_id, title))
return playlist_id
def library_playlists(self) -> list[Playlist]: def library_playlists(self) -> list[Playlist]:
if self._error is not None: if self._error is not None:
@@ -0,0 +1,64 @@
"""@file
@brief Tests of the create_playlist use case.
"""
from datetime import date
import pytest
from conftest import FakeMusicService
from musical_time_machine.application.use_cases import create_playlist
from musical_time_machine.domain.errors import AuthenticationError
from musical_time_machine.domain.playlist import Playlist
TITLE = "2026-04-18 Billboard 100"
def test_create_playlist_creates_one_private_playlist() -> None:
service = FakeMusicService()
result = create_playlist(date(2026, 4, 18), service)
assert result.created is True
assert result.title == TITLE
assert len(service.created) == 1
title, description, privacy = service.created[0]
assert (title, privacy) == (TITLE, "PRIVATE")
assert "2026-04-18" in description
assert result.playlist_id == service.library_playlists()[0].playlist_id
def test_create_playlist_twice_for_same_date_creates_no_duplicate() -> None:
service = FakeMusicService()
first = create_playlist(date(2026, 4, 18), service)
second = create_playlist(date(2026, 4, 18), service)
assert second.created is False
assert second.playlist_id == first.playlist_id
assert len(service.created) == 1
assert len(service.library_playlists()) == 1
def test_create_playlist_for_other_date_creates_second_playlist() -> None:
service = FakeMusicService()
create_playlist(date(2026, 4, 18), service)
result = create_playlist(date(2026, 4, 19), service)
assert result.created is True
assert len(service.library_playlists()) == 2
def test_create_playlist_returns_existing_playlist_of_library() -> None:
service = FakeMusicService([Playlist("x", "Other"), Playlist("abc", TITLE)])
result = create_playlist(date(2026, 4, 18), service)
assert (result.playlist_id, result.created) == ("abc", False)
assert service.created == []
def test_create_playlist_propagates_authentication_error() -> None:
with pytest.raises(AuthenticationError):
create_playlist(date(2026, 4, 18), FakeMusicService(error=AuthenticationError("no")))
+19
View File
@@ -0,0 +1,19 @@
"""@file
@brief Tests of the playlist naming rules.
"""
from datetime import date
from musical_time_machine.domain.playlist import playlist_description, playlist_name
def test_playlist_name_uses_iso_date_and_suffix() -> None:
assert playlist_name(date(2026, 4, 18)) == "2026-04-18 Billboard 100"
def test_playlist_name_zero_pads_month_and_day() -> None:
assert playlist_name(date(2026, 1, 5)) == "2026-01-05 Billboard 100"
def test_playlist_description_names_the_chart_date() -> None:
assert "2026-04-18" in playlist_description(date(2026, 4, 18))
+56 -2
View File
@@ -8,16 +8,35 @@ from typing import Any
import pytest import pytest
from ytmusicapi.exceptions import YTMusicUserError from ytmusicapi.exceptions import YTMusicUserError
from musical_time_machine.domain.errors import AuthenticationError, BrowserFileMissingError from musical_time_machine.domain.errors import (
AuthenticationError,
BrowserFileMissingError,
PlaylistCreationError,
)
from musical_time_machine.domain.playlist import Playlist from musical_time_machine.domain.playlist import Playlist
from musical_time_machine.infrastructure.ytmusic import YouTubeMusicService, YTMusicLike from musical_time_machine.infrastructure.ytmusic import YouTubeMusicService, YTMusicLike
class _FakeClient: class _FakeClient:
def __init__(self, items: list[dict[str, Any]], error: Exception | None = None) -> None: def __init__(
self,
items: list[dict[str, Any]],
error: Exception | None = None,
created: str | dict[str, Any] = "new-id",
) -> None:
self._items = items self._items = items
self._error = error self._error = error
self._created = created
self.limits: list[int | None] = [] self.limits: list[int | None] = []
self.creations: list[tuple[str, str, str]] = []
def create_playlist(
self, title: str, description: str, privacy_status: str = "PRIVATE"
) -> str | dict[str, Any]:
self.creations.append((title, description, privacy_status))
if self._error is not None:
raise self._error
return self._created
def get_library_playlists(self, limit: int | None = 25) -> list[dict[str, Any]]: def get_library_playlists(self, limit: int | None = 25) -> list[dict[str, Any]]:
self.limits.append(limit) self.limits.append(limit)
@@ -108,3 +127,38 @@ def test_library_playlists_builds_the_client_once(tmp_path: Path) -> None:
service.library_playlists() service.library_playlists()
assert len(calls) == 1 assert len(calls) == 1
def test_create_playlist_returns_id_and_passes_privacy(tmp_path: Path) -> None:
client = _FakeClient([])
service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client)
assert service.create_playlist("T", "D", "PRIVATE") == "new-id"
assert client.creations == [("T", "D", "PRIVATE")]
def test_create_playlist_raises_when_result_is_not_an_id(tmp_path: Path) -> None:
client = _FakeClient([], created={"error": "quota exceeded"})
service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client)
with pytest.raises(PlaylistCreationError, match="quota exceeded"):
service.create_playlist("T", "D", "PRIVATE")
@pytest.mark.parametrize("error", [YTMusicUserError("x"), KeyError("cookie"), ValueError("bad")])
def test_create_playlist_raises_authentication_error_when_client_fails(
tmp_path: Path, error: Exception
) -> None:
client = _FakeClient([], error)
service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client)
with pytest.raises(AuthenticationError) as info:
service.create_playlist("T", "D", "PRIVATE")
assert info.value.__cause__ is error
def test_create_playlist_raises_when_browser_file_missing(tmp_path: Path) -> None:
service = YouTubeMusicService(tmp_path / "none.json", lambda _auth: _FakeClient([]))
with pytest.raises(BrowserFileMissingError):
service.create_playlist("T", "D", "PRIVATE")
+49
View File
@@ -11,6 +11,7 @@ from musical_time_machine.domain.errors import (
BrowserFileMissingError, BrowserFileMissingError,
ChartParseError, ChartParseError,
ChartUnavailableError, ChartUnavailableError,
PlaylistCreationError,
) )
from musical_time_machine.domain.playlist import Playlist from musical_time_machine.domain.playlist import Playlist
from musical_time_machine.domain.song import Song from musical_time_machine.domain.song import Song
@@ -110,3 +111,51 @@ def test_main_without_flag_does_not_use_the_music_service() -> None:
assert code == 0 assert code == 0
assert lines == ["1. T - A"] assert lines == ["1. T - A"]
def _create(answer: str, service: FakeMusicService) -> tuple[int, list[str]]:
lines: list[str] = []
code = main(
["--create-playlist"],
music_service=service,
read=lambda _prompt: answer,
write=lines.append,
)
return code, lines
def test_main_create_playlist_reports_creation() -> None:
service = FakeMusicService()
code, lines = _create("2026-04-18", service)
assert code == 0
assert lines == ['Created playlist "2026-04-18 Billboard 100".']
assert len(service.created) == 1
def test_main_create_playlist_reports_existing_playlist() -> None:
service = FakeMusicService([Playlist("a", "2026-04-18 Billboard 100")])
code, lines = _create("2026-04-18", service)
assert code == 0
assert lines == ['Playlist "2026-04-18 Billboard 100" already exists; nothing was created.']
assert service.created == []
def test_main_create_playlist_returns_one_for_invalid_date() -> None:
service = FakeMusicService()
code, lines = _create("nope", service)
assert code == 1
assert "YYYY-MM-DD" in lines[0]
assert service.created == []
def test_main_create_playlist_returns_one_on_domain_error() -> None:
code, lines = _create("2026-04-18", FakeMusicService(error=PlaylistCreationError("failed")))
assert code == 1
assert lines == ["failed"]