MIL-005: Step 3 - Create the playlist #47
@@ -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) |
|
||||
| 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 |
|
||||
|
||||
### 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
|
||||
|
||||

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

|
||||
|
||||
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 | 015 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 |
|
||||
|
||||
## Languages
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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 | [27a0606] |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
[27a0606]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/046-scraping-the-billboard/commit/27a060650a64184c9393f0cb9b9edd68fa8340c8
|
||||
@@ -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-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-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-007] | MIL | en | it | [MIL-006] | - | [RC-011] |
|
||||
| [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-013]: ./reviews/rc-013-mil-003-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.
|
||||
"""
|
||||
...
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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]:
|
||||
"""@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 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)
|
||||
|
||||
@@ -126,3 +126,24 @@ 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."
|
||||
## @}
|
||||
|
||||
## @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):
|
||||
"""@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 datetime import date
|
||||
|
||||
from musical_time_machine.constants import (
|
||||
DATE_FORMAT,
|
||||
PLAYLIST_DESCRIPTION_TEMPLATE,
|
||||
PLAYLIST_NAME_TEMPLATE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -15,3 +22,21 @@ class Playlist:
|
||||
|
||||
playlist_id: 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_NAME,
|
||||
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
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -34,6 +39,15 @@ class YTMusicLike(Protocol):
|
||||
"""@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:
|
||||
"""@brief MusicService backed by ytmusicapi and a browser headers file."""
|
||||
@@ -70,6 +84,26 @@ class YouTubeMusicService:
|
||||
except _CLIENT_ERRORS as 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:
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
|
||||
@@ -7,17 +7,26 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
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 (
|
||||
BROWSER_FILE_FLAG,
|
||||
BROWSER_FILE_HELP,
|
||||
BROWSER_FILE_NAME,
|
||||
CHECK_LOGIN_FLAG,
|
||||
CHECK_LOGIN_HELP,
|
||||
CREATE_PLAYLIST_FLAG,
|
||||
CREATE_PLAYLIST_HELP,
|
||||
DATE_PROMPT,
|
||||
EXIT_FAILURE,
|
||||
EXIT_OK,
|
||||
PLAYLIST_COUNT_MESSAGE,
|
||||
PLAYLIST_CREATED_MESSAGE,
|
||||
PLAYLIST_EXISTS_MESSAGE,
|
||||
SONG_LINE_TEMPLATE,
|
||||
)
|
||||
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:
|
||||
parser = argparse.ArgumentParser(prog="musical_time_machine")
|
||||
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(
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
argv: list[str] | None = None,
|
||||
*,
|
||||
@@ -53,11 +76,11 @@ def main(
|
||||
read: Callable[[str], str] = input,
|
||||
write: Callable[[str], None] = print,
|
||||
) -> 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 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 write Function that shows one line to the user.
|
||||
@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:
|
||||
service = music_service or YouTubeMusicService(browser_file=args.browser_file)
|
||||
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()
|
||||
try:
|
||||
chart_date = parse_chart_date(read(DATE_PROMPT))
|
||||
|
||||
+10
-1
@@ -40,8 +40,17 @@ class FakeMusicService:
|
||||
def __init__(
|
||||
self, playlists: list[Playlist] | None = None, error: Exception | None = None
|
||||
) -> None:
|
||||
self._playlists = playlists or []
|
||||
self._playlists = list(playlists or [])
|
||||
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]:
|
||||
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")))
|
||||
@@ -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))
|
||||
@@ -8,16 +8,35 @@ 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.errors import (
|
||||
AuthenticationError,
|
||||
BrowserFileMissingError,
|
||||
PlaylistCreationError,
|
||||
)
|
||||
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:
|
||||
def __init__(
|
||||
self,
|
||||
items: list[dict[str, Any]],
|
||||
error: Exception | None = None,
|
||||
created: str | dict[str, Any] = "new-id",
|
||||
) -> None:
|
||||
self._items = items
|
||||
self._error = error
|
||||
self._created = created
|
||||
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]]:
|
||||
self.limits.append(limit)
|
||||
@@ -108,3 +127,38 @@ def test_library_playlists_builds_the_client_once(tmp_path: Path) -> None:
|
||||
service.library_playlists()
|
||||
|
||||
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")
|
||||
|
||||
@@ -11,6 +11,7 @@ from musical_time_machine.domain.errors import (
|
||||
BrowserFileMissingError,
|
||||
ChartParseError,
|
||||
ChartUnavailableError,
|
||||
PlaylistCreationError,
|
||||
)
|
||||
from musical_time_machine.domain.playlist import Playlist
|
||||
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 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"]
|
||||
|
||||
Reference in New Issue
Block a user