diff --git a/README.md b/README.md index 53f6387..dc14853 100644 --- a/README.md +++ b/README.md @@ -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 ![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. ## 🧪 Tests diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 780941b..0884746 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g. | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 | | GOV | Governance / ARB Workflow | docs/sqa/governance.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 015 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 | ## Languages diff --git a/docs/sqa/reviews/rc-015-mil-005-code.md b/docs/sqa/reviews/rc-015-mil-005-code.md new file mode 100644 index 0000000..215a664 --- /dev/null +++ b/docs/sqa/reviews/rc-015-mil-005-code.md @@ -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/ 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 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 5132e6a..729f9a9 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -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 diff --git a/docs/wiki/images/step3-create-playlist.png b/docs/wiki/images/step3-create-playlist.png new file mode 100644 index 0000000..3cdd176 Binary files /dev/null and b/docs/wiki/images/step3-create-playlist.png differ diff --git a/src/musical_time_machine/application/ports.py b/src/musical_time_machine/application/ports.py index 6e33824..19047bb 100644 --- a/src/musical_time_machine/application/ports.py +++ b/src/musical_time_machine/application/ports.py @@ -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. + """ + ... diff --git a/src/musical_time_machine/application/use_cases.py b/src/musical_time_machine/application/use_cases.py index 9f2e674..dd945d9 100644 --- a/src/musical_time_machine/application/use_cases.py +++ b/src/musical_time_machine/application/use_cases.py @@ -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) diff --git a/src/musical_time_machine/constants.py b/src/musical_time_machine/constants.py index 7cb1b0a..5e6408d 100644 --- a/src/musical_time_machine/constants.py +++ b/src/musical_time_machine/constants.py @@ -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 ' 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}" +## @} diff --git a/src/musical_time_machine/domain/errors.py b/src/musical_time_machine/domain/errors.py index 0ebf324..383844a 100644 --- a/src/musical_time_machine/domain/errors.py +++ b/src/musical_time_machine/domain/errors.py @@ -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.""" diff --git a/src/musical_time_machine/domain/playlist.py b/src/musical_time_machine/domain/playlist.py index c2c5f58..0ee509e 100644 --- a/src/musical_time_machine/domain/playlist.py +++ b/src/musical_time_machine/domain/playlist.py @@ -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)) diff --git a/src/musical_time_machine/infrastructure/ytmusic.py b/src/musical_time_machine/infrastructure/ytmusic.py index 074bc74..62c86b2 100644 --- a/src/musical_time_machine/infrastructure/ytmusic.py +++ b/src/musical_time_machine/infrastructure/ytmusic.py @@ -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 diff --git a/src/musical_time_machine/presentation/cli.py b/src/musical_time_machine/presentation/cli.py index f8c60d4..6dba04d 100644 --- a/src/musical_time_machine/presentation/cli.py +++ b/src/musical_time_machine/presentation/cli.py @@ -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)) diff --git a/tests/conftest.py b/tests/conftest.py index 3e48fdd..e5231a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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: diff --git a/tests/unit/application/test_create_playlist.py b/tests/unit/application/test_create_playlist.py new file mode 100644 index 0000000..77272b4 --- /dev/null +++ b/tests/unit/application/test_create_playlist.py @@ -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"))) diff --git a/tests/unit/domain/test_playlist.py b/tests/unit/domain/test_playlist.py new file mode 100644 index 0000000..1ebd650 --- /dev/null +++ b/tests/unit/domain/test_playlist.py @@ -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)) diff --git a/tests/unit/infrastructure/test_ytmusic.py b/tests/unit/infrastructure/test_ytmusic.py index 78b4d51..2cf7f5b 100644 --- a/tests/unit/infrastructure/test_ytmusic.py +++ b/tests/unit/infrastructure/test_ytmusic.py @@ -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") diff --git a/tests/unit/presentation/test_cli.py b/tests/unit/presentation/test_cli.py index d512c1d..3d92b0c 100644 --- a/tests/unit/presentation/test_cli.py +++ b/tests/unit/presentation/test_cli.py @@ -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"]