From 56c23605a06e17f8f71482ea3f09be287f2246a4 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Mon, 14 Oct 2024 15:28:08 +0200 Subject: [PATCH 01/16] Add Selenium project code --- python-selenium/README.md | 42 +++++++++++++ python-selenium/pyproject.toml | 21 +++++++ python-selenium/requirements.txt | 22 +++++++ python-selenium/src/bandcamp/__init__.py | 0 python-selenium/src/bandcamp/__main__.py | 11 ++++ python-selenium/src/bandcamp/app/__init__.py | 0 python-selenium/src/bandcamp/app/player.py | 45 +++++++++++++ python-selenium/src/bandcamp/app/tui.py | 66 ++++++++++++++++++++ python-selenium/src/bandcamp/web/__init__.py | 0 python-selenium/src/bandcamp/web/base.py | 27 ++++++++ python-selenium/src/bandcamp/web/element.py | 55 ++++++++++++++++ python-selenium/src/bandcamp/web/locators.py | 14 +++++ python-selenium/src/bandcamp/web/page.py | 15 +++++ 13 files changed, 318 insertions(+) create mode 100644 python-selenium/README.md create mode 100644 python-selenium/pyproject.toml create mode 100644 python-selenium/requirements.txt create mode 100644 python-selenium/src/bandcamp/__init__.py create mode 100644 python-selenium/src/bandcamp/__main__.py create mode 100644 python-selenium/src/bandcamp/app/__init__.py create mode 100644 python-selenium/src/bandcamp/app/player.py create mode 100644 python-selenium/src/bandcamp/app/tui.py create mode 100644 python-selenium/src/bandcamp/web/__init__.py create mode 100644 python-selenium/src/bandcamp/web/base.py create mode 100644 python-selenium/src/bandcamp/web/element.py create mode 100644 python-selenium/src/bandcamp/web/locators.py create mode 100644 python-selenium/src/bandcamp/web/page.py diff --git a/python-selenium/README.md b/python-selenium/README.md new file mode 100644 index 0000000000..89b052fa65 --- /dev/null +++ b/python-selenium/README.md @@ -0,0 +1,42 @@ +# Modern Web Automation With Python and Selenium + +This repository contains the module `bandcamp`, which is the sample app built in the Real Python tutorial [Modern Web Automation With Python and Selenium](https://realpython.com/modern-web-automation-with-python-and-selenium/). + +## Installation and Setup + +Create and activate a [Python virtual environment](https://realpython.com/python-virtual-environments-a-primer/). + +Then, install the requirements: + +```sh +(venv) $ python -m pip install -r requirements.txt +``` + +The only direct dependency for this project is [Selenium](https://selenium-python.readthedocs.io/). You should use a Python version of at least 3.8. + +## Run the Bandcamp Discover Player + +To run the music placer, navigate to the `src/` folder, then execute the module from your command-line: + +```sh +(venv) $ cd src/ +(venv) $ python -m bandcamp +``` + +You'll see a text-based user interface that allows you to interact with the music player: + +``` +Type: [play ], [tracks], [more], [exit] +> +``` + +Type one of the available commands to interact with Bandcamp's Discover section through your headless browser. Listen to songs with `play`, list available tracks with `tracks`, and load more songs using `more`. You can exit the music player by typing `exit`. + +## About the Authors + +Martin Breuss - Email: martin@realpython.com +Bartosz Zaczyński - Email: bartosz@realpython.com + +## License + +Distributed under the MIT license. See ``LICENSE`` for more information. diff --git a/python-selenium/pyproject.toml b/python-selenium/pyproject.toml new file mode 100644 index 0000000000..5b778c3420 --- /dev/null +++ b/python-selenium/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "bandcamp_player" +version = "0.1.0" +requires-python = ">=3.8" +description = "A web player for Bandcamp using Selenium" +authors = [ + { name = "Martin Breuss", email = "martin@realpython.com" }, + { name = "Bartosz Zaczyński", email = "bartosz@realpython.com" }, +] +dependencies = [ + "selenium", +] +[project.scripts] +bandcamp-player = "bandcamp.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/python-selenium/requirements.txt b/python-selenium/requirements.txt new file mode 100644 index 0000000000..dd7582c170 --- /dev/null +++ b/python-selenium/requirements.txt @@ -0,0 +1,22 @@ +appdirs==1.4.4 +attrs==24.2.0 +certifi==2024.7.4 +h11==0.14.0 +idna==3.7 +jedi==0.19.1 +outcome==1.3.0.post0 +parso==0.8.4 +prompt_toolkit==3.0.47 +ptpython==3.0.29 +Pygments==2.18.0 +PySocks==1.7.1 +selenium==4.23.1 +sniffio==1.3.1 +sortedcontainers==2.4.0 +trio==0.26.1 +trio-websocket==0.11.1 +typing_extensions==4.12.2 +urllib3==2.2.2 +wcwidth==0.2.13 +websocket-client==1.8.0 +wsproto==1.2.0 diff --git a/python-selenium/src/bandcamp/__init__.py b/python-selenium/src/bandcamp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-selenium/src/bandcamp/__main__.py b/python-selenium/src/bandcamp/__main__.py new file mode 100644 index 0000000000..87e2e7d73a --- /dev/null +++ b/python-selenium/src/bandcamp/__main__.py @@ -0,0 +1,11 @@ +from bandcamp.app.tui import TUI + + +def main(): + """Provides the main entry point for the app.""" + tui = TUI() + tui.interact() + + +if __name__ == "__main__": + main() diff --git a/python-selenium/src/bandcamp/app/__init__.py b/python-selenium/src/bandcamp/app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-selenium/src/bandcamp/app/player.py b/python-selenium/src/bandcamp/app/player.py new file mode 100644 index 0000000000..26078fbbb5 --- /dev/null +++ b/python-selenium/src/bandcamp/app/player.py @@ -0,0 +1,45 @@ +from selenium.webdriver import Firefox +from selenium.webdriver.firefox.options import Options + +from bandcamp.web.element import TrackElement +from bandcamp.web.page import HomePage + +BANDCAMP_FRONTPAGE = "https://bandcamp.com/" + + +class Player: + """Plays tracks from Bandcamp's Discover section.""" + + def __init__(self) -> None: + self._driver = self._set_up_driver() + self.home = HomePage(self._driver) + self.discover = self.home.discover_tracklist + self._current_track = TrackElement( + self.home.discover_tracklist.available_tracks[0], self._driver + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + """Closes the headless browser.""" + self._driver.close() + + def play(self, track_number=None): + """Plays the first track, or one of the available numbered tracks.""" + if track_number: + self._current_track = TrackElement( + self.home.discover_tracklist.available_tracks[ + track_number - 1 + ], + self._driver, + ) + self._current_track.play() + + def _set_up_driver(self): + """Creates a headless browser pointing to Bandcamp.""" + options = Options() + options.add_argument("--headless") + browser = Firefox(options=options) + browser.get(BANDCAMP_FRONTPAGE) + return browser diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py new file mode 100644 index 0000000000..89329ae8c5 --- /dev/null +++ b/python-selenium/src/bandcamp/app/tui.py @@ -0,0 +1,66 @@ +from bandcamp.app.player import Player + + +class TUI: + """Provides a text-based user interface for a Bandcamp music player.""" + + COLUMN_WIDTH = CW = 30 + + def interact(self): + """Controls the player through user interactions.""" + with Player() as player: + while True: + print( + "\nType: [play ], [tracks], [more], [exit]" + ) + command = input("> ").strip().lower() + + if command.startswith("play"): + try: + track_number = int(command.split()[1]) + self.play(player, track_number) + except IndexError: # Play first track. + self.play(player) + except ValueError: + print("Please provide a valid track number.") + elif command == "tracks": + self.tracks(player) + elif command == "more": + player.discover.load_more() + self.tracks(player) + elif command == "exit": + print("Exiting the player...") + break + else: + print("Unknown command. Try again.") + + def play(self, player, track_number=None): + """Plays a track and shows info about the track.""" + player.play(track_number) + print(player._current_track._get_track_info()) + + def tracks(self, player): + """Displays information about the currently playable tracks.""" + header = ( + f"{'#':<5} {'Album':<{self.CW}} " + f"{'Artist':<{self.CW}} " + f"{'Genre':<{self.CW}}" + ) + print(header) + print("-" * 100) + for track_number, track in enumerate( + player.discover.available_tracks, start=1 + ): + album, artist, *genre = track.text.split("\n") + album = self._truncate(album, self.CW) + artist = self._truncate(artist, self.CW) + genre = self._truncate(genre[0], self.CW) if genre else "" + print( + f"{track_number:<5} {album:<{self.CW}} " + f"{artist:<{self.CW}} {genre:<{self.CW}}" + ) + + @staticmethod + def _truncate(text, width): + """Truncates track information.""" + return text[: width - 3] + "..." if len(text) > width else text diff --git a/python-selenium/src/bandcamp/web/__init__.py b/python-selenium/src/bandcamp/web/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-selenium/src/bandcamp/web/base.py b/python-selenium/src/bandcamp/web/base.py new file mode 100644 index 0000000000..92d2f3a032 --- /dev/null +++ b/python-selenium/src/bandcamp/web/base.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support.wait import WebDriverWait + +MAX_WAIT_SECONDS = 10.0 + + +@dataclass +class Track: + album: str + artist: str + genre: str + url: str + + +class WebPage: + def __init__(self, driver: WebDriver) -> None: + self._driver = driver + self._wait = WebDriverWait(driver, MAX_WAIT_SECONDS) + + +class WebComponent(WebPage): + def __init__(self, parent: WebElement, driver: WebDriver) -> None: + super().__init__(driver) + self._parent = parent diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py new file mode 100644 index 0000000000..e8ab553816 --- /dev/null +++ b/python-selenium/src/bandcamp/web/element.py @@ -0,0 +1,55 @@ +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement + +from bandcamp.web.base import Track, WebComponent +from bandcamp.web.locators import HomePageLocatorMixin, TrackLocatorMixin + + +class TrackElement(WebComponent, TrackLocatorMixin): + """Models a playable track in Bandcamp's Discover section.""" + + def play(self) -> None: + """Plays the track.""" + if not self.is_playing(): + self._get_play_button().click() + self._wait.until(lambda _: self.is_playing()) + + def is_playing(self) -> bool: + return "playing" in self._get_play_button().get_attribute("class") + + def _get_track_info(self) -> Track: + """Creates a representation of the track's relevant information.""" + full_url = self._parent.find_element(*self.ALBUM).get_attribute("href") + # Cut off the referrer query parameter + clean_url = full_url.split("?")[0] if full_url else "" + return Track( + album=self._parent.find_element(*self.ALBUM).text, + artist=self._parent.find_element(*self.ARTIST).text, + genre=self._parent.find_element(*self.GENRE).text, + url=clean_url, + ) + + def _get_play_button(self): + return self._parent.find_element(*self.PLAY_BUTTON) + + +class DiscoverTrackList(WebComponent, HomePageLocatorMixin): + """Models the track list in Bandcamp's Discover section.""" + + def __init__(self, parent: WebElement, driver: WebDriver = None) -> None: + super().__init__(parent, driver) + self.available_tracks = self._get_available_tracks() + + def load_more(self) -> None: + """Loads additional tracks in the Discover section.""" + self._get_next_page_button().click() + self.available_tracks = self._get_available_tracks() + + def _get_available_tracks(self) -> list: + """Finds all currently available tracks in the Discover section.""" + all_tracks = self._driver.find_elements(*self.TRACK) + return [track for track in all_tracks if track.is_displayed()] + + def _get_next_page_button(self): + """Locates and returns the 'Next' button that loads more results.""" + return self._driver.find_elements(*self.PAGINATION_BUTTON)[-1] diff --git a/python-selenium/src/bandcamp/web/locators.py b/python-selenium/src/bandcamp/web/locators.py new file mode 100644 index 0000000000..7238f79fca --- /dev/null +++ b/python-selenium/src/bandcamp/web/locators.py @@ -0,0 +1,14 @@ +from selenium.webdriver.common.by import By + + +class HomePageLocatorMixin: + DISCOVER_RESULTS = (By.CLASS_NAME, "discover-results") + TRACK = (By.CLASS_NAME, "discover-item") + PAGINATION_BUTTON = (By.CLASS_NAME, "item-page") + + +class TrackLocatorMixin: + PLAY_BUTTON = (By.CSS_SELECTOR, "a") + ALBUM = (By.CLASS_NAME, "item-title") + GENRE = (By.CLASS_NAME, "item-genre") + ARTIST = (By.CLASS_NAME, "item-artist") diff --git a/python-selenium/src/bandcamp/web/page.py b/python-selenium/src/bandcamp/web/page.py new file mode 100644 index 0000000000..c8fa37581d --- /dev/null +++ b/python-selenium/src/bandcamp/web/page.py @@ -0,0 +1,15 @@ +from selenium.webdriver.remote.webdriver import WebDriver + +from bandcamp.web.base import WebPage +from bandcamp.web.element import DiscoverTrackList +from bandcamp.web.locators import HomePageLocatorMixin + + +class HomePage(WebPage, HomePageLocatorMixin): + """Models the relevant parts of the Bandcamp home page.""" + + def __init__(self, driver: WebDriver) -> None: + super().__init__(driver) + self.discover_tracklist = DiscoverTrackList( + self._driver.find_element(*self.DISCOVER_RESULTS), self._driver + ) From 648edc96c7105926612d8bb145c33bc87a681cba Mon Sep 17 00:00:00 2001 From: martin-martin Date: Mon, 28 Oct 2024 13:57:00 +0100 Subject: [PATCH 02/16] Apply Technical Review suggestions Co-authored-by: Bartosz --- python-selenium/README.md | 14 +-- python-selenium/pyproject.toml | 5 +- python-selenium/requirements.txt | 17 +--- python-selenium/src/bandcamp/__main__.py | 11 +-- python-selenium/src/bandcamp/app/player.py | 12 +-- python-selenium/src/bandcamp/app/tui.py | 97 ++++++++++---------- python-selenium/src/bandcamp/web/element.py | 20 ++-- python-selenium/src/bandcamp/web/locators.py | 4 +- python-selenium/src/bandcamp/web/page.py | 6 +- 9 files changed, 84 insertions(+), 102 deletions(-) diff --git a/python-selenium/README.md b/python-selenium/README.md index 89b052fa65..99f3604943 100644 --- a/python-selenium/README.md +++ b/python-selenium/README.md @@ -12,21 +12,23 @@ Then, install the requirements: (venv) $ python -m pip install -r requirements.txt ``` -The only direct dependency for this project is [Selenium](https://selenium-python.readthedocs.io/). You should use a Python version of at least 3.8. +The only direct dependency for this project is [Selenium](https://selenium-python.readthedocs.io/). You should use a Python version of at least 3.10, which is necessary to support [structural pattern matching](https://realpython.com/structural-pattern-matching/). + +You'll need a [Firefox Selenium driver](https://selenium-python.readthedocs.io/installation.html#drivers) called `geckodriver` to run the project as-is. Make sure to [download and install](https://github.com/mozilla/geckodriver/releases) it before running the project. ## Run the Bandcamp Discover Player -To run the music placer, navigate to the `src/` folder, then execute the module from your command-line: +To run the music player, install the package, then use the entry point command from your command-line: ```sh -(venv) $ cd src/ -(venv) $ python -m bandcamp +(venv) $ python -m pip install . +(venv) $ bandcamp-player ``` You'll see a text-based user interface that allows you to interact with the music player: ``` -Type: [play ], [tracks], [more], [exit] +Type: play [] | tracks | more | exit > ``` @@ -39,4 +41,4 @@ Bartosz Zaczyński - Email: bartosz@realpython.com ## License -Distributed under the MIT license. See ``LICENSE`` for more information. +Distributed under the MIT license. See `LICENSE` for more information. diff --git a/python-selenium/pyproject.toml b/python-selenium/pyproject.toml index 5b778c3420..b675214a12 100644 --- a/python-selenium/pyproject.toml +++ b/python-selenium/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "bandcamp_player" version = "0.1.0" -requires-python = ">=3.8" +requires-python = ">=3.10" description = "A web player for Bandcamp using Selenium" authors = [ { name = "Martin Breuss", email = "martin@realpython.com" }, @@ -16,6 +16,3 @@ dependencies = [ ] [project.scripts] bandcamp-player = "bandcamp.__main__:main" - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/python-selenium/requirements.txt b/python-selenium/requirements.txt index dd7582c170..7b46f7c892 100644 --- a/python-selenium/requirements.txt +++ b/python-selenium/requirements.txt @@ -1,22 +1,15 @@ -appdirs==1.4.4 attrs==24.2.0 -certifi==2024.7.4 +certifi==2024.8.30 h11==0.14.0 -idna==3.7 -jedi==0.19.1 +idna==3.10 outcome==1.3.0.post0 -parso==0.8.4 -prompt_toolkit==3.0.47 -ptpython==3.0.29 -Pygments==2.18.0 PySocks==1.7.1 -selenium==4.23.1 +selenium==4.25.0 sniffio==1.3.1 sortedcontainers==2.4.0 -trio==0.26.1 +trio==0.27.0 trio-websocket==0.11.1 typing_extensions==4.12.2 -urllib3==2.2.2 -wcwidth==0.2.13 +urllib3==2.2.3 websocket-client==1.8.0 wsproto==1.2.0 diff --git a/python-selenium/src/bandcamp/__main__.py b/python-selenium/src/bandcamp/__main__.py index 87e2e7d73a..f0cba06399 100644 --- a/python-selenium/src/bandcamp/__main__.py +++ b/python-selenium/src/bandcamp/__main__.py @@ -1,11 +1,6 @@ -from bandcamp.app.tui import TUI +from bandcamp.app.tui import interact def main(): - """Provides the main entry point for the app.""" - tui = TUI() - tui.interact() - - -if __name__ == "__main__": - main() + """Provide the main entry point for the app.""" + interact() diff --git a/python-selenium/src/bandcamp/app/player.py b/python-selenium/src/bandcamp/app/player.py index 26078fbbb5..f74be6574a 100644 --- a/python-selenium/src/bandcamp/app/player.py +++ b/python-selenium/src/bandcamp/app/player.py @@ -4,11 +4,11 @@ from bandcamp.web.element import TrackElement from bandcamp.web.page import HomePage -BANDCAMP_FRONTPAGE = "https://bandcamp.com/" +BANDCAMP_FRONTPAGE_URL = "https://bandcamp.com/" class Player: - """Plays tracks from Bandcamp's Discover section.""" + """Play tracks from Bandcamp's Discover section.""" def __init__(self) -> None: self._driver = self._set_up_driver() @@ -22,11 +22,11 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, exc_tb): - """Closes the headless browser.""" + """Close the headless browser.""" self._driver.close() def play(self, track_number=None): - """Plays the first track, or one of the available numbered tracks.""" + """Play the first track, or one of the available numbered tracks.""" if track_number: self._current_track = TrackElement( self.home.discover_tracklist.available_tracks[ @@ -37,9 +37,9 @@ def play(self, track_number=None): self._current_track.play() def _set_up_driver(self): - """Creates a headless browser pointing to Bandcamp.""" + """Create a headless browser pointing to Bandcamp.""" options = Options() options.add_argument("--headless") browser = Firefox(options=options) - browser.get(BANDCAMP_FRONTPAGE) + browser.get(BANDCAMP_FRONTPAGE_URL) return browser diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index 89329ae8c5..d5913f33c8 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -1,66 +1,61 @@ -from bandcamp.app.player import Player - +"""Provide a text-based user interface for a Bandcamp music player.""" -class TUI: - """Provides a text-based user interface for a Bandcamp music player.""" +from bandcamp.app.player import Player - COLUMN_WIDTH = CW = 30 +COLUMN_WIDTH = CW = 30 - def interact(self): - """Controls the player through user interactions.""" - with Player() as player: - while True: - print( - "\nType: [play ], [tracks], [more], [exit]" - ) - command = input("> ").strip().lower() - if command.startswith("play"): +def interact(): + """Control the player through user interactions.""" + with Player() as player: + while True: + print("\nType: play [] | tracks | more | exit") + match input("> ").strip().lower().split(): + case ["play"]: + play(player) + case ["play", track]: try: - track_number = int(command.split()[1]) - self.play(player, track_number) - except IndexError: # Play first track. - self.play(player) + track_number = int(track) + play(player, track_number) except ValueError: print("Please provide a valid track number.") - elif command == "tracks": - self.tracks(player) - elif command == "more": + case ["tracks"]: + display_tracks(player) + case ["more"]: player.discover.load_more() - self.tracks(player) - elif command == "exit": + display_tracks(player) + case ["exit"]: print("Exiting the player...") break - else: + case _: print("Unknown command. Try again.") - def play(self, player, track_number=None): - """Plays a track and shows info about the track.""" - player.play(track_number) - print(player._current_track._get_track_info()) - def tracks(self, player): - """Displays information about the currently playable tracks.""" - header = ( - f"{'#':<5} {'Album':<{self.CW}} " - f"{'Artist':<{self.CW}} " - f"{'Genre':<{self.CW}}" +def play(player, track_number=None): + """Play a track and show info about the track.""" + player.play(track_number) + print(player._current_track._get_track_info()) + + +def display_tracks(player): + """Display information about the currently playable tracks.""" + header = ( + f"{'#':<5} {'Album':<{CW}} " f"{'Artist':<{CW}} " f"{'Genre':<{CW}}" + ) + print(header) + print("-" * 100) + for track_number, track in enumerate( + player.discover.available_tracks, start=1 + ): + album, artist, *genre = track.text.split("\n") + album = _truncate(album, CW) + artist = _truncate(artist, CW) + genre = _truncate(genre[0], CW) if genre else "" + print( + f"{track_number:<5} {album:<{CW}} " f"{artist:<{CW}} {genre:<{CW}}" ) - print(header) - print("-" * 100) - for track_number, track in enumerate( - player.discover.available_tracks, start=1 - ): - album, artist, *genre = track.text.split("\n") - album = self._truncate(album, self.CW) - artist = self._truncate(artist, self.CW) - genre = self._truncate(genre[0], self.CW) if genre else "" - print( - f"{track_number:<5} {album:<{self.CW}} " - f"{artist:<{self.CW}} {genre:<{self.CW}}" - ) - @staticmethod - def _truncate(text, width): - """Truncates track information.""" - return text[: width - 3] + "..." if len(text) > width else text + +def _truncate(text, width): + """Truncate track information.""" + return text[: width - 3] + "..." if len(text) > width else text diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py index e8ab553816..90d44cb59d 100644 --- a/python-selenium/src/bandcamp/web/element.py +++ b/python-selenium/src/bandcamp/web/element.py @@ -2,14 +2,14 @@ from selenium.webdriver.remote.webelement import WebElement from bandcamp.web.base import Track, WebComponent -from bandcamp.web.locators import HomePageLocatorMixin, TrackLocatorMixin +from bandcamp.web.locators import HomePageLocator, TrackLocator -class TrackElement(WebComponent, TrackLocatorMixin): - """Models a playable track in Bandcamp's Discover section.""" +class TrackElement(WebComponent, TrackLocator): + """Model a playable track in Bandcamp's Discover section.""" def play(self) -> None: - """Plays the track.""" + """Play the track.""" if not self.is_playing(): self._get_play_button().click() self._wait.until(lambda _: self.is_playing()) @@ -18,7 +18,7 @@ def is_playing(self) -> bool: return "playing" in self._get_play_button().get_attribute("class") def _get_track_info(self) -> Track: - """Creates a representation of the track's relevant information.""" + """Create a representation of the track's relevant information.""" full_url = self._parent.find_element(*self.ALBUM).get_attribute("href") # Cut off the referrer query parameter clean_url = full_url.split("?")[0] if full_url else "" @@ -33,23 +33,23 @@ def _get_play_button(self): return self._parent.find_element(*self.PLAY_BUTTON) -class DiscoverTrackList(WebComponent, HomePageLocatorMixin): - """Models the track list in Bandcamp's Discover section.""" +class DiscoverTrackList(WebComponent, HomePageLocator): + """Model the track list in Bandcamp's Discover section.""" def __init__(self, parent: WebElement, driver: WebDriver = None) -> None: super().__init__(parent, driver) self.available_tracks = self._get_available_tracks() def load_more(self) -> None: - """Loads additional tracks in the Discover section.""" + """Load additional tracks in the Discover section.""" self._get_next_page_button().click() self.available_tracks = self._get_available_tracks() def _get_available_tracks(self) -> list: - """Finds all currently available tracks in the Discover section.""" + """Find all currently available tracks in the Discover section.""" all_tracks = self._driver.find_elements(*self.TRACK) return [track for track in all_tracks if track.is_displayed()] def _get_next_page_button(self): - """Locates and returns the 'Next' button that loads more results.""" + """Locate and return the 'Next' button that loads more results.""" return self._driver.find_elements(*self.PAGINATION_BUTTON)[-1] diff --git a/python-selenium/src/bandcamp/web/locators.py b/python-selenium/src/bandcamp/web/locators.py index 7238f79fca..524e6ba9f9 100644 --- a/python-selenium/src/bandcamp/web/locators.py +++ b/python-selenium/src/bandcamp/web/locators.py @@ -1,13 +1,13 @@ from selenium.webdriver.common.by import By -class HomePageLocatorMixin: +class HomePageLocator: DISCOVER_RESULTS = (By.CLASS_NAME, "discover-results") TRACK = (By.CLASS_NAME, "discover-item") PAGINATION_BUTTON = (By.CLASS_NAME, "item-page") -class TrackLocatorMixin: +class TrackLocator: PLAY_BUTTON = (By.CSS_SELECTOR, "a") ALBUM = (By.CLASS_NAME, "item-title") GENRE = (By.CLASS_NAME, "item-genre") diff --git a/python-selenium/src/bandcamp/web/page.py b/python-selenium/src/bandcamp/web/page.py index c8fa37581d..ead8b30a04 100644 --- a/python-selenium/src/bandcamp/web/page.py +++ b/python-selenium/src/bandcamp/web/page.py @@ -2,11 +2,11 @@ from bandcamp.web.base import WebPage from bandcamp.web.element import DiscoverTrackList -from bandcamp.web.locators import HomePageLocatorMixin +from bandcamp.web.locators import HomePageLocator -class HomePage(WebPage, HomePageLocatorMixin): - """Models the relevant parts of the Bandcamp home page.""" +class HomePage(WebPage, HomePageLocator): + """Model the relevant parts of the Bandcamp home page.""" def __init__(self, driver: WebDriver) -> None: super().__init__(driver) From a0fd07245a56be702db0536e13529fd9554c6100 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Wed, 12 Mar 2025 19:43:09 +0100 Subject: [PATCH 03/16] Restructures project to work with /discover page Bandcamp removed the "Discover" section from their main page since we wrote this code. Now, the tracks are only available at the dedicated /discover URL. I restructured the code to target the /discover site instead, which required a couple of changes. Still, the existing POM structure was helpful :) I also (re)introduced a new "pause" option, because that's a bit easier in this new structure. Finally, I only allow loading more songs once, which gives a total of 120 songs to pick from with the default screen setting. Attempting to load more resulted in errors, I think because they'd be outside of the viewport and I didn't want to expand the code more and introduce scrolling more of them into view. Also, not sure whether that'd take earlier ones out of the viewport (etc) so I just decided not to open that can of worms for this "Intro to Selenium" tutorial. LMK if you disagree, otherwise of course you can tackle that in your longer one that builds on top of this one :) --- python-selenium/pyproject.toml | 3 + python-selenium/requirements.txt | 16 +- python-selenium/src/bandcamp/__main__.py | 11 +- python-selenium/src/bandcamp/app/player.py | 30 +-- python-selenium/src/bandcamp/app/tui.py | 109 +++++---- python-selenium/src/bandcamp/web/base.py | 2 + python-selenium/src/bandcamp/web/element.py | 67 ++++-- python-selenium/src/bandcamp/web/locators.py | 23 +- python-selenium/src/bandcamp/web/page.py | 11 +- python-selenium/uv.lock | 220 +++++++++++++++++++ 10 files changed, 388 insertions(+), 104 deletions(-) create mode 100644 python-selenium/uv.lock diff --git a/python-selenium/pyproject.toml b/python-selenium/pyproject.toml index b675214a12..4cd277e93e 100644 --- a/python-selenium/pyproject.toml +++ b/python-selenium/pyproject.toml @@ -16,3 +16,6 @@ dependencies = [ ] [project.scripts] bandcamp-player = "bandcamp.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/python-selenium/requirements.txt b/python-selenium/requirements.txt index 7b46f7c892..42fd421e96 100644 --- a/python-selenium/requirements.txt +++ b/python-selenium/requirements.txt @@ -1,15 +1,15 @@ -attrs==24.2.0 -certifi==2024.8.30 +attrs==25.2.0 +certifi==2025.1.31 h11==0.14.0 idna==3.10 outcome==1.3.0.post0 -PySocks==1.7.1 -selenium==4.25.0 +pysocks==1.7.1 +selenium==4.29.0 sniffio==1.3.1 sortedcontainers==2.4.0 -trio==0.27.0 -trio-websocket==0.11.1 -typing_extensions==4.12.2 -urllib3==2.2.3 +trio==0.29.0 +trio-websocket==0.12.2 +typing-extensions==4.12.2 +urllib3==2.3.0 websocket-client==1.8.0 wsproto==1.2.0 diff --git a/python-selenium/src/bandcamp/__main__.py b/python-selenium/src/bandcamp/__main__.py index f0cba06399..87e2e7d73a 100644 --- a/python-selenium/src/bandcamp/__main__.py +++ b/python-selenium/src/bandcamp/__main__.py @@ -1,6 +1,11 @@ -from bandcamp.app.tui import interact +from bandcamp.app.tui import TUI def main(): - """Provide the main entry point for the app.""" - interact() + """Provides the main entry point for the app.""" + tui = TUI() + tui.interact() + + +if __name__ == "__main__": + main() diff --git a/python-selenium/src/bandcamp/app/player.py b/python-selenium/src/bandcamp/app/player.py index f74be6574a..d4f5c3a7de 100644 --- a/python-selenium/src/bandcamp/app/player.py +++ b/python-selenium/src/bandcamp/app/player.py @@ -2,44 +2,46 @@ from selenium.webdriver.firefox.options import Options from bandcamp.web.element import TrackElement -from bandcamp.web.page import HomePage +from bandcamp.web.page import DiscoverPage -BANDCAMP_FRONTPAGE_URL = "https://bandcamp.com/" +BANDCAMP_DISCOVER = "https://bandcamp.com/discover/" class Player: - """Play tracks from Bandcamp's Discover section.""" + """Plays tracks from Bandcamp's Discover page.""" def __init__(self) -> None: self._driver = self._set_up_driver() - self.home = HomePage(self._driver) - self.discover = self.home.discover_tracklist + self.page = DiscoverPage(self._driver) + self.tracklist = self.page.discover_tracklist self._current_track = TrackElement( - self.home.discover_tracklist.available_tracks[0], self._driver + self.tracklist.available_tracks[0], self._driver ) def __enter__(self): return self def __exit__(self, exc_type, exc_value, exc_tb): - """Close the headless browser.""" - self._driver.close() + """Closes the headless browser.""" + self._driver.quit() def play(self, track_number=None): - """Play the first track, or one of the available numbered tracks.""" + """Plays the first track, or one of the available numbered tracks.""" if track_number: self._current_track = TrackElement( - self.home.discover_tracklist.available_tracks[ - track_number - 1 - ], + self.tracklist.available_tracks[track_number - 1], self._driver, ) self._current_track.play() + def pause(self): + """Pauses the current track.""" + self._current_track.pause() + def _set_up_driver(self): - """Create a headless browser pointing to Bandcamp.""" + """Creates a headless browser pointing to Bandcamp.""" options = Options() options.add_argument("--headless") browser = Firefox(options=options) - browser.get(BANDCAMP_FRONTPAGE_URL) + browser.get(BANDCAMP_DISCOVER) return browser diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index d5913f33c8..32110f7235 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -1,61 +1,76 @@ -"""Provide a text-based user interface for a Bandcamp music player.""" - from bandcamp.app.player import Player -COLUMN_WIDTH = CW = 30 +class TUI: + """Provides a text-based user interface for a Bandcamp music player.""" + + COLUMN_WIDTH = CW = 30 + MAX_TRACKS = 120 # Twice the number of tracks that fit in the viewport. + + def interact(self): + """Controls the player through user interactions.""" + with Player() as player: + while True: + print( + "\nType: [play ], [pause], [tracks], [more], [exit]" + ) + command = input("> ").strip().lower() -def interact(): - """Control the player through user interactions.""" - with Player() as player: - while True: - print("\nType: play [] | tracks | more | exit") - match input("> ").strip().lower().split(): - case ["play"]: - play(player) - case ["play", track]: + if command.startswith("play"): try: - track_number = int(track) - play(player, track_number) + track_number = int(command.split()[1]) + self.play(player, track_number) + except IndexError: # Play first track. + self.play(player) except ValueError: print("Please provide a valid track number.") - case ["tracks"]: - display_tracks(player) - case ["more"]: - player.discover.load_more() - display_tracks(player) - case ["exit"]: + elif command == "pause": + self.pause(player) + elif command == "tracks": + self.tracks(player) + elif command == "more": + # A higher number of tracks can't be clicked without scrolling. + if len(player.tracklist.available_tracks) >= self.MAX_TRACKS: + print("Can't load more tracks. Pick one from the track list.") + else: + player.tracklist.load_more() + self.tracks(player) + elif command == "exit": print("Exiting the player...") break - case _: + else: print("Unknown command. Try again.") + def play(self, player, track_number=None): + """Plays a track and shows info about the track.""" + player.play(track_number) + print(player._current_track._get_track_info()) -def play(player, track_number=None): - """Play a track and show info about the track.""" - player.play(track_number) - print(player._current_track._get_track_info()) - - -def display_tracks(player): - """Display information about the currently playable tracks.""" - header = ( - f"{'#':<5} {'Album':<{CW}} " f"{'Artist':<{CW}} " f"{'Genre':<{CW}}" - ) - print(header) - print("-" * 100) - for track_number, track in enumerate( - player.discover.available_tracks, start=1 - ): - album, artist, *genre = track.text.split("\n") - album = _truncate(album, CW) - artist = _truncate(artist, CW) - genre = _truncate(genre[0], CW) if genre else "" - print( - f"{track_number:<5} {album:<{CW}} " f"{artist:<{CW}} {genre:<{CW}}" - ) + def pause(self, player): + """Pauses the current track.""" + player.pause() + def tracks(self, player): + """Displays information about the currently playable tracks.""" + header = ( + f"{'#':<5} {'Album':<{self.CW}} {'Artist':<{self.CW}} {'Genre':<{self.CW}}" + ) + print(header) + print("-" * 100) + for track_number, track in enumerate( + player.tracklist.available_tracks, start=1 + ): + if track.text: + album, artist, *genre = track.text.split("\n") + album = self._truncate(album, self.CW) + artist = self._truncate(artist, self.CW) + genre = self._truncate(genre[0], self.CW) if genre else "" + print( + f"{track_number:<5} {album:<{self.CW}} " + f"{artist:<{self.CW}} {genre:<{self.CW}}" + ) -def _truncate(text, width): - """Truncate track information.""" - return text[: width - 3] + "..." if len(text) > width else text + @staticmethod + def _truncate(text, width): + """Truncates track information.""" + return text[: width - 3] + "..." if len(text) > width else text diff --git a/python-selenium/src/bandcamp/web/base.py b/python-selenium/src/bandcamp/web/base.py index 92d2f3a032..b7c2215218 100644 --- a/python-selenium/src/bandcamp/web/base.py +++ b/python-selenium/src/bandcamp/web/base.py @@ -5,6 +5,7 @@ from selenium.webdriver.support.wait import WebDriverWait MAX_WAIT_SECONDS = 10.0 +DEFAULT_WINDOW_SIZE = (1920, 3000) # Shows 60 tracks @dataclass @@ -18,6 +19,7 @@ class Track: class WebPage: def __init__(self, driver: WebDriver) -> None: self._driver = driver + self._driver.set_window_size(*DEFAULT_WINDOW_SIZE) self._wait = WebDriverWait(driver, MAX_WAIT_SECONDS) diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py index 90d44cb59d..7941b6739e 100644 --- a/python-selenium/src/bandcamp/web/element.py +++ b/python-selenium/src/bandcamp/web/element.py @@ -1,31 +1,43 @@ +from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support import expected_conditions as EC from bandcamp.web.base import Track, WebComponent -from bandcamp.web.locators import HomePageLocator, TrackLocator +from bandcamp.web.locators import DiscoverPageLocatorMixin, TrackLocatorMixin -class TrackElement(WebComponent, TrackLocator): - """Model a playable track in Bandcamp's Discover section.""" +class TrackElement(WebComponent, TrackLocatorMixin): + """Models a playable track in Bandcamp's Discover section.""" def play(self) -> None: - """Play the track.""" - if not self.is_playing(): + """Plays the track.""" + if not self.is_playing: self._get_play_button().click() - self._wait.until(lambda _: self.is_playing()) + def pause(self) -> None: + """Pauses the track.""" + if self.is_playing: + self._get_play_button().click() + + @property def is_playing(self) -> bool: - return "playing" in self._get_play_button().get_attribute("class") + return "Pause" in self._get_play_button().get_attribute("aria-label") def _get_track_info(self) -> Track: - """Create a representation of the track's relevant information.""" - full_url = self._parent.find_element(*self.ALBUM).get_attribute("href") + """Creates a representation of the track's relevant information.""" + full_url = self._parent.find_element(*self.URL).get_attribute("href") # Cut off the referrer query parameter clean_url = full_url.split("?")[0] if full_url else "" + # Some tracks don't have a genre + try: + genre = self._parent.find_element(*self.GENRE).text + except NoSuchElementException: + genre = "" return Track( album=self._parent.find_element(*self.ALBUM).text, artist=self._parent.find_element(*self.ARTIST).text, - genre=self._parent.find_element(*self.GENRE).text, + genre=genre, url=clean_url, ) @@ -33,23 +45,38 @@ def _get_play_button(self): return self._parent.find_element(*self.PLAY_BUTTON) -class DiscoverTrackList(WebComponent, HomePageLocator): - """Model the track list in Bandcamp's Discover section.""" +class DiscoverTrackList(WebComponent, DiscoverPageLocatorMixin, TrackLocatorMixin): + """Models the track list in Bandcamp's Discover section.""" def __init__(self, parent: WebElement, driver: WebDriver = None) -> None: super().__init__(parent, driver) self.available_tracks = self._get_available_tracks() def load_more(self) -> None: - """Load additional tracks in the Discover section.""" - self._get_next_page_button().click() + """Loads additional tracks in the Discover section.""" + view_more_button = self._driver.find_element(*self.PAGINATION_BUTTON) + view_more_button.click() + # The button is disabled until all new tracks are loaded. + self._wait.until(EC.element_to_be_clickable(self.PAGINATION_BUTTON)) self.available_tracks = self._get_available_tracks() def _get_available_tracks(self) -> list: - """Find all currently available tracks in the Discover section.""" - all_tracks = self._driver.find_elements(*self.TRACK) - return [track for track in all_tracks if track.is_displayed()] + """Finds all currently available tracks in the Discover section.""" + self._wait.until( + lambda driver: any( + e.is_displayed() and e.text.strip() + for e in driver.find_elements(*self.ITEM) + ), + message="Timeout waiting for track text to load", + ) + + all_items = self._driver.find_elements(*self.ITEM) + all_tracks = [] + for item in all_items: + if item.find_element(*self.PLAY_BUTTON): + all_tracks.append(item) - def _get_next_page_button(self): - """Locate and return the 'Next' button that loads more results.""" - return self._driver.find_elements(*self.PAGINATION_BUTTON)[-1] + # Filter tracks that are displayed and have text. + return [ + track for track in all_tracks if track.is_displayed() and track.text.strip() + ] diff --git a/python-selenium/src/bandcamp/web/locators.py b/python-selenium/src/bandcamp/web/locators.py index 524e6ba9f9..c9c6cda011 100644 --- a/python-selenium/src/bandcamp/web/locators.py +++ b/python-selenium/src/bandcamp/web/locators.py @@ -1,14 +1,19 @@ from selenium.webdriver.common.by import By -class HomePageLocator: - DISCOVER_RESULTS = (By.CLASS_NAME, "discover-results") - TRACK = (By.CLASS_NAME, "discover-item") - PAGINATION_BUTTON = (By.CLASS_NAME, "item-page") +class DiscoverPageLocatorMixin: + DISCOVER_RESULTS = (By.CLASS_NAME, "results-grid") + ITEM = (By.CLASS_NAME, "results-grid-item") + PAGINATION_BUTTON = (By.ID, "view-more") + COOKIE_ACCEPT_NECESSARY = ( + By.CSS_SELECTOR, + "#cookie-control-dialog button.g-button.outline", + ) -class TrackLocator: - PLAY_BUTTON = (By.CSS_SELECTOR, "a") - ALBUM = (By.CLASS_NAME, "item-title") - GENRE = (By.CLASS_NAME, "item-genre") - ARTIST = (By.CLASS_NAME, "item-artist") +class TrackLocatorMixin: + PLAY_BUTTON = (By.CSS_SELECTOR, "button.play-pause-button") + URL = (By.CSS_SELECTOR, "div.meta p a") + ALBUM = (By.CSS_SELECTOR, "div.meta p a strong") + GENRE = (By.CSS_SELECTOR, "div.meta p.genre") + ARTIST = (By.CSS_SELECTOR, "div.meta p a span") diff --git a/python-selenium/src/bandcamp/web/page.py b/python-selenium/src/bandcamp/web/page.py index ead8b30a04..ed6442faa1 100644 --- a/python-selenium/src/bandcamp/web/page.py +++ b/python-selenium/src/bandcamp/web/page.py @@ -2,14 +2,19 @@ from bandcamp.web.base import WebPage from bandcamp.web.element import DiscoverTrackList -from bandcamp.web.locators import HomePageLocator +from bandcamp.web.locators import DiscoverPageLocatorMixin -class HomePage(WebPage, HomePageLocator): - """Model the relevant parts of the Bandcamp home page.""" +class DiscoverPage(WebPage, DiscoverPageLocatorMixin): + """Models the relevant parts of the Bandcamp Discover page.""" def __init__(self, driver: WebDriver) -> None: super().__init__(driver) + self._accept_cookie_consent() self.discover_tracklist = DiscoverTrackList( self._driver.find_element(*self.DISCOVER_RESULTS), self._driver ) + + def _accept_cookie_consent(self) -> None: + """Accepts the necessary cookie consent.""" + self._driver.find_element(*self.COOKIE_ACCEPT_NECESSARY).click() diff --git a/python-selenium/uv.lock b/python-selenium/uv.lock new file mode 100644 index 0000000000..f3e7cae0d9 --- /dev/null +++ b/python-selenium/uv.lock @@ -0,0 +1,220 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "attrs" +version = "25.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/82/3c4e1d44f3cbaa2a578127d641fe385ba3bff6c38b789447ae11a21fa413/attrs-25.2.0.tar.gz", hash = "sha256:18a06db706db43ac232cce80443fcd9f2500702059ecf53489e3c5a3f417acaf", size = 812038 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/33/7a7388b9ef94aab40539939d94461ec682afbd895458945ed25be07f03f6/attrs-25.2.0-py3-none-any.whl", hash = "sha256:611344ff0a5fed735d86d7784610c84f8126b95e549bcad9ff61b4242f2d386b", size = 64016 }, +] + +[[package]] +name = "bandcamp-player" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "selenium" }, +] + +[package.metadata] +requires-dist = [{ name = "selenium" }] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, +] + +[[package]] +name = "selenium" +version = "4.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/09de87ef66a10a7d40417d4e93449eb892154d2dc6385187aa9298a2c09d/selenium-4.29.0.tar.gz", hash = "sha256:3a62f7ec33e669364a6c0562a701deb69745b569c50d55f1a912bf8eb33358ba", size = 985717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/a6/fc66ea71ec0769f72abdf15cb9ec9269517abe68a160839383ddff7478f1/selenium-4.29.0-py3-none-any.whl", hash = "sha256:ce5d26f1ddc1111641113653af33694c13947dd36c2df09cdd33f554351d372e", size = 9536642 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + +[[package]] +name = "trio" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/47/f62e62a1a6f37909aed0bf8f5d5411e06fa03846cfcb64540cd1180ccc9f/trio-0.29.0.tar.gz", hash = "sha256:ea0d3967159fc130acb6939a0be0e558e364fee26b5deeecc893a6b08c361bdf", size = 588952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/55/c4d9bea8b3d7937901958f65124123512419ab0eb73695e5f382521abbfb/trio-0.29.0-py3-none-any.whl", hash = "sha256:d8c463f1a9cc776ff63e331aba44c125f423a5a13c684307e828d930e625ba66", size = 492920 }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +] + +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 }, +] From ae7cdf57c55471d7e17f3b8c030f9afda4391d9d Mon Sep 17 00:00:00 2001 From: martin-martin Date: Thu, 13 Mar 2025 20:46:16 +0100 Subject: [PATCH 04/16] Re-apply old TR feedback Co-authored-by: Bartosz --- python-selenium/README.md | 2 +- python-selenium/pyproject.toml | 3 - python-selenium/src/bandcamp/__main__.py | 10 +- python-selenium/src/bandcamp/app/player.py | 14 +-- python-selenium/src/bandcamp/app/tui.py | 118 +++++++++---------- python-selenium/src/bandcamp/web/element.py | 20 ++-- python-selenium/src/bandcamp/web/locators.py | 4 +- python-selenium/src/bandcamp/web/page.py | 8 +- 8 files changed, 84 insertions(+), 95 deletions(-) diff --git a/python-selenium/README.md b/python-selenium/README.md index 99f3604943..75d0ae24e5 100644 --- a/python-selenium/README.md +++ b/python-selenium/README.md @@ -28,7 +28,7 @@ To run the music player, install the package, then use the entry point command f You'll see a text-based user interface that allows you to interact with the music player: ``` -Type: play [] | tracks | more | exit +Type: play [] | pause | tracks | more | exit > ``` diff --git a/python-selenium/pyproject.toml b/python-selenium/pyproject.toml index 4cd277e93e..b675214a12 100644 --- a/python-selenium/pyproject.toml +++ b/python-selenium/pyproject.toml @@ -16,6 +16,3 @@ dependencies = [ ] [project.scripts] bandcamp-player = "bandcamp.__main__:main" - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/python-selenium/src/bandcamp/__main__.py b/python-selenium/src/bandcamp/__main__.py index 87e2e7d73a..ac3589931a 100644 --- a/python-selenium/src/bandcamp/__main__.py +++ b/python-selenium/src/bandcamp/__main__.py @@ -1,11 +1,9 @@ -from bandcamp.app.tui import TUI +from bandcamp.app.tui import interact def main(): - """Provides the main entry point for the app.""" - tui = TUI() - tui.interact() + """Provide the main entry point for the app.""" + interact() -if __name__ == "__main__": - main() +main() diff --git a/python-selenium/src/bandcamp/app/player.py b/python-selenium/src/bandcamp/app/player.py index d4f5c3a7de..92defb056e 100644 --- a/python-selenium/src/bandcamp/app/player.py +++ b/python-selenium/src/bandcamp/app/player.py @@ -4,11 +4,11 @@ from bandcamp.web.element import TrackElement from bandcamp.web.page import DiscoverPage -BANDCAMP_DISCOVER = "https://bandcamp.com/discover/" +BANDCAMP_DISCOVER_URL = "https://bandcamp.com/discover/" class Player: - """Plays tracks from Bandcamp's Discover page.""" + """Play tracks from Bandcamp's Discover page.""" def __init__(self) -> None: self._driver = self._set_up_driver() @@ -22,11 +22,11 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, exc_tb): - """Closes the headless browser.""" + """Close the headless browser.""" self._driver.quit() def play(self, track_number=None): - """Plays the first track, or one of the available numbered tracks.""" + """Play the first track, or one of the available numbered tracks.""" if track_number: self._current_track = TrackElement( self.tracklist.available_tracks[track_number - 1], @@ -35,13 +35,13 @@ def play(self, track_number=None): self._current_track.play() def pause(self): - """Pauses the current track.""" + """Pause the current track.""" self._current_track.pause() def _set_up_driver(self): - """Creates a headless browser pointing to Bandcamp.""" + """Create a headless browser pointing to Bandcamp.""" options = Options() options.add_argument("--headless") browser = Firefox(options=options) - browser.get(BANDCAMP_DISCOVER) + browser.get(BANDCAMP_DISCOVER_URL) return browser diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index 32110f7235..a97f086943 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -1,76 +1,70 @@ from bandcamp.app.player import Player -class TUI: - """Provides a text-based user interface for a Bandcamp music player.""" +COLUMN_WIDTH = CW = 30 +MAX_TRACKS = 100 # Allows to load more tracks once. - COLUMN_WIDTH = CW = 30 - MAX_TRACKS = 120 # Twice the number of tracks that fit in the viewport. - - def interact(self): - """Controls the player through user interactions.""" - with Player() as player: - while True: - print( - "\nType: [play ], [pause], [tracks], [more], [exit]" - ) - command = input("> ").strip().lower() - - if command.startswith("play"): +def interact(): + """Control the player through user interactions.""" + with Player() as player: + while True: + print( + "\nType: play [] | pause | tracks | more | exit" + ) + match input("> ").strip().lower().split(): + case ["play"]: + play(player) + case ["play", track]: try: - track_number = int(command.split()[1]) - self.play(player, track_number) - except IndexError: # Play first track. - self.play(player) + track_number = int(track) + play(player, track_number) except ValueError: print("Please provide a valid track number.") - elif command == "pause": - self.pause(player) - elif command == "tracks": - self.tracks(player) - elif command == "more": - # A higher number of tracks can't be clicked without scrolling. - if len(player.tracklist.available_tracks) >= self.MAX_TRACKS: - print("Can't load more tracks. Pick one from the track list.") - else: - player.tracklist.load_more() - self.tracks(player) - elif command == "exit": + case ["pause"]: + pause(player) + case ["tracks"]: + display_tracks(player) + case ["more"] if len(player.tracklist.available_tracks) >= MAX_TRACKS: + print("Can't load more tracks. Pick one from the track list.") + case ["more"]: + player.tracklist.load_more() + display_tracks(player) + case ["exit"]: print("Exiting the player...") break - else: + case _: print("Unknown command. Try again.") - def play(self, player, track_number=None): - """Plays a track and shows info about the track.""" - player.play(track_number) - print(player._current_track._get_track_info()) - def pause(self, player): - """Pauses the current track.""" - player.pause() +def play(player, track_number=None): + """Play a track and shows info about the track.""" + player.play(track_number) + print(player._current_track._get_track_info()) + +def pause(player): + """Pause the current track.""" + player.pause() - def tracks(self, player): - """Displays information about the currently playable tracks.""" - header = ( - f"{'#':<5} {'Album':<{self.CW}} {'Artist':<{self.CW}} {'Genre':<{self.CW}}" - ) - print(header) - print("-" * 100) - for track_number, track in enumerate( - player.tracklist.available_tracks, start=1 - ): - if track.text: - album, artist, *genre = track.text.split("\n") - album = self._truncate(album, self.CW) - artist = self._truncate(artist, self.CW) - genre = self._truncate(genre[0], self.CW) if genre else "" - print( - f"{track_number:<5} {album:<{self.CW}} " - f"{artist:<{self.CW}} {genre:<{self.CW}}" - ) +def display_tracks(player): + """Display information about the currently playable tracks.""" + header = ( + f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" + ) + print(header) + print("-" * 100) + for track_number, track in enumerate( + player.tracklist.available_tracks, start=1 + ): + if track.text: + album, artist, *genre = track.text.split("\n") + album = _truncate(album, CW) + artist = _truncate(artist, CW) + genre = _truncate(genre[0], CW) if genre else "" + print( + f"{track_number:<5} {album:<{CW}} " + f"{artist:<{CW}} {genre:<{CW}}" + ) - @staticmethod - def _truncate(text, width): - """Truncates track information.""" - return text[: width - 3] + "..." if len(text) > width else text +def _truncate(text, width): + """Truncate track information.""" + return text[: width - 3] + "..." if len(text) > width else text diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py index 7941b6739e..ff42098c9c 100644 --- a/python-selenium/src/bandcamp/web/element.py +++ b/python-selenium/src/bandcamp/web/element.py @@ -4,19 +4,19 @@ from selenium.webdriver.support import expected_conditions as EC from bandcamp.web.base import Track, WebComponent -from bandcamp.web.locators import DiscoverPageLocatorMixin, TrackLocatorMixin +from bandcamp.web.locators import DiscoverPageLocator, TrackLocator -class TrackElement(WebComponent, TrackLocatorMixin): - """Models a playable track in Bandcamp's Discover section.""" +class TrackElement(WebComponent, TrackLocator): + """Model a playable track in Bandcamp's Discover section.""" def play(self) -> None: - """Plays the track.""" + """Play the track.""" if not self.is_playing: self._get_play_button().click() def pause(self) -> None: - """Pauses the track.""" + """Pause the track.""" if self.is_playing: self._get_play_button().click() @@ -25,7 +25,7 @@ def is_playing(self) -> bool: return "Pause" in self._get_play_button().get_attribute("aria-label") def _get_track_info(self) -> Track: - """Creates a representation of the track's relevant information.""" + """Create a representation of the track's relevant information.""" full_url = self._parent.find_element(*self.URL).get_attribute("href") # Cut off the referrer query parameter clean_url = full_url.split("?")[0] if full_url else "" @@ -45,15 +45,15 @@ def _get_play_button(self): return self._parent.find_element(*self.PLAY_BUTTON) -class DiscoverTrackList(WebComponent, DiscoverPageLocatorMixin, TrackLocatorMixin): - """Models the track list in Bandcamp's Discover section.""" +class DiscoverTrackList(WebComponent, DiscoverPageLocator, TrackLocator): + """Model the track list in Bandcamp's Discover section.""" def __init__(self, parent: WebElement, driver: WebDriver = None) -> None: super().__init__(parent, driver) self.available_tracks = self._get_available_tracks() def load_more(self) -> None: - """Loads additional tracks in the Discover section.""" + """Load additional tracks in the Discover section.""" view_more_button = self._driver.find_element(*self.PAGINATION_BUTTON) view_more_button.click() # The button is disabled until all new tracks are loaded. @@ -61,7 +61,7 @@ def load_more(self) -> None: self.available_tracks = self._get_available_tracks() def _get_available_tracks(self) -> list: - """Finds all currently available tracks in the Discover section.""" + """Find all currently available tracks in the Discover section.""" self._wait.until( lambda driver: any( e.is_displayed() and e.text.strip() diff --git a/python-selenium/src/bandcamp/web/locators.py b/python-selenium/src/bandcamp/web/locators.py index c9c6cda011..f5ef352fac 100644 --- a/python-selenium/src/bandcamp/web/locators.py +++ b/python-selenium/src/bandcamp/web/locators.py @@ -1,7 +1,7 @@ from selenium.webdriver.common.by import By -class DiscoverPageLocatorMixin: +class DiscoverPageLocator: DISCOVER_RESULTS = (By.CLASS_NAME, "results-grid") ITEM = (By.CLASS_NAME, "results-grid-item") PAGINATION_BUTTON = (By.ID, "view-more") @@ -11,7 +11,7 @@ class DiscoverPageLocatorMixin: ) -class TrackLocatorMixin: +class TrackLocator: PLAY_BUTTON = (By.CSS_SELECTOR, "button.play-pause-button") URL = (By.CSS_SELECTOR, "div.meta p a") ALBUM = (By.CSS_SELECTOR, "div.meta p a strong") diff --git a/python-selenium/src/bandcamp/web/page.py b/python-selenium/src/bandcamp/web/page.py index ed6442faa1..1488ff5714 100644 --- a/python-selenium/src/bandcamp/web/page.py +++ b/python-selenium/src/bandcamp/web/page.py @@ -2,11 +2,11 @@ from bandcamp.web.base import WebPage from bandcamp.web.element import DiscoverTrackList -from bandcamp.web.locators import DiscoverPageLocatorMixin +from bandcamp.web.locators import DiscoverPageLocator -class DiscoverPage(WebPage, DiscoverPageLocatorMixin): - """Models the relevant parts of the Bandcamp Discover page.""" +class DiscoverPage(WebPage, DiscoverPageLocator): + """Model the relevant parts of the Bandcamp Discover page.""" def __init__(self, driver: WebDriver) -> None: super().__init__(driver) @@ -16,5 +16,5 @@ def __init__(self, driver: WebDriver) -> None: ) def _accept_cookie_consent(self) -> None: - """Accepts the necessary cookie consent.""" + """Accept the necessary cookie consent.""" self._driver.find_element(*self.COOKIE_ACCEPT_NECESSARY).click() From 07280d0f5078ab61b1d83ac189946d406ccbb878 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Thu, 13 Mar 2025 20:50:29 +0100 Subject: [PATCH 05/16] Fix linter error --- python-selenium/src/bandcamp/app/tui.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index a97f086943..f6c8f5dc43 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -1,16 +1,14 @@ from bandcamp.app.player import Player - COLUMN_WIDTH = CW = 30 MAX_TRACKS = 100 # Allows to load more tracks once. + def interact(): """Control the player through user interactions.""" with Player() as player: while True: - print( - "\nType: play [] | pause | tracks | more | exit" - ) + print("\nType: play [] | pause | tracks | more | exit") match input("> ").strip().lower().split(): case ["play"]: play(player) @@ -41,29 +39,25 @@ def play(player, track_number=None): player.play(track_number) print(player._current_track._get_track_info()) + def pause(player): """Pause the current track.""" player.pause() + def display_tracks(player): """Display information about the currently playable tracks.""" - header = ( - f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" - ) + header = f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" print(header) print("-" * 100) - for track_number, track in enumerate( - player.tracklist.available_tracks, start=1 - ): + for track_number, track in enumerate(player.tracklist.available_tracks, start=1): if track.text: album, artist, *genre = track.text.split("\n") album = _truncate(album, CW) artist = _truncate(artist, CW) genre = _truncate(genre[0], CW) if genre else "" - print( - f"{track_number:<5} {album:<{CW}} " - f"{artist:<{CW}} {genre:<{CW}}" - ) + print(f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}") + def _truncate(text, width): """Truncate track information.""" From a0a3391ab6b66c885e6569f950db21c218436318 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Thu, 13 Mar 2025 20:52:34 +0100 Subject: [PATCH 06/16] Fix black formatting --- python-selenium/src/bandcamp/app/tui.py | 20 +++++++++++++++----- python-selenium/src/bandcamp/web/element.py | 4 +++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index f6c8f5dc43..94bb5c5b7e 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -8,7 +8,9 @@ def interact(): """Control the player through user interactions.""" with Player() as player: while True: - print("\nType: play [] | pause | tracks | more | exit") + print( + "\nType: play [] | pause | tracks | more | exit" + ) match input("> ").strip().lower().split(): case ["play"]: play(player) @@ -22,8 +24,12 @@ def interact(): pause(player) case ["tracks"]: display_tracks(player) - case ["more"] if len(player.tracklist.available_tracks) >= MAX_TRACKS: - print("Can't load more tracks. Pick one from the track list.") + case ["more"] if len( + player.tracklist.available_tracks + ) >= MAX_TRACKS: + print( + "Can't load more tracks. Pick one from the track list." + ) case ["more"]: player.tracklist.load_more() display_tracks(player) @@ -50,13 +56,17 @@ def display_tracks(player): header = f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" print(header) print("-" * 100) - for track_number, track in enumerate(player.tracklist.available_tracks, start=1): + for track_number, track in enumerate( + player.tracklist.available_tracks, start=1 + ): if track.text: album, artist, *genre = track.text.split("\n") album = _truncate(album, CW) artist = _truncate(artist, CW) genre = _truncate(genre[0], CW) if genre else "" - print(f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}") + print( + f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}" + ) def _truncate(text, width): diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py index ff42098c9c..7736010862 100644 --- a/python-selenium/src/bandcamp/web/element.py +++ b/python-selenium/src/bandcamp/web/element.py @@ -78,5 +78,7 @@ def _get_available_tracks(self) -> list: # Filter tracks that are displayed and have text. return [ - track for track in all_tracks if track.is_displayed() and track.text.strip() + track + for track in all_tracks + if track.is_displayed() and track.text.strip() ] From 21d505c4b8b7368abf3f3f6e8b40feb6cfcdc433 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Sun, 16 Mar 2025 11:00:03 +0100 Subject: [PATCH 07/16] Apply TR feedback Co-authored-by: Bartosz --- python-selenium/README.md | 22 +++++++++++- python-selenium/src/bandcamp/app/tui.py | 32 ++++++++--------- python-selenium/src/bandcamp/web/element.py | 40 +++++++++++---------- python-selenium/src/bandcamp/web/page.py | 7 ++-- 4 files changed, 61 insertions(+), 40 deletions(-) diff --git a/python-selenium/README.md b/python-selenium/README.md index 75d0ae24e5..11989404c4 100644 --- a/python-selenium/README.md +++ b/python-selenium/README.md @@ -32,7 +32,27 @@ Type: play [] | pause | tracks | more | exit > ``` -Type one of the available commands to interact with Bandcamp's Discover section through your headless browser. Listen to songs with `play`, list available tracks with `tracks`, and load more songs using `more`. You can exit the music player by typing `exit`. +Type one of the available commands to interact with Bandcamp's Discover section through your headless browser. Listen to songs with `play`, pause the current song with `pause` and restart it with `play`. List available tracks with `tracks`, and load more songs using `more`. You can exit the music player by typing `exit`. + +## Troubleshooting + +If the music player seems to hang when you run the script, confirm whether you've correctly set up your webdriver based on the following points. + +### Version Compatibility + +Confirm that your browser and corresponding webdriver are in sync. If you followed the previous suggestion, then you should be using Firefox and geckodriver. If that doesn't work for any reason, you may need to switch browser _and_ webdriver. + +For example, if you're using Chrome, then you need to install ChromeDriver and it must match your Chrome version. Otherwise, you may run into errors like `SessionNotCreatedException`. +For more details, refer to the official [ChromeDriver documentation](https://sites.google.com/chromium.org/driver/) or [geckodriver releases](https://github.com/mozilla/geckodriver/releases). + +### Driver Installation and Path Issues + +Once you've confirmed that your browser and driver match, make sure that the webdriver executable is properly installed: + +- **Path Configuration:** The driver must be in your system's PATH, or you need to specify its full path in your code. +- **Permissions:** Ensure the driver file has the necessary execution permissions. + +If you're still running into issues executing the script, then consult the [Selenium Documentation](https://www.selenium.dev/documentation/) for additional troubleshooting tips or leave a comment on the tutorial. ## About the Authors diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index 94bb5c5b7e..060e956055 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -8,9 +8,7 @@ def interact(): """Control the player through user interactions.""" with Player() as player: while True: - print( - "\nType: play [] | pause | tracks | more | exit" - ) + print("\nType: play [] | pause | tracks | more | exit") match input("> ").strip().lower().split(): case ["play"]: play(player) @@ -24,12 +22,8 @@ def interact(): pause(player) case ["tracks"]: display_tracks(player) - case ["more"] if len( - player.tracklist.available_tracks - ) >= MAX_TRACKS: - print( - "Can't load more tracks. Pick one from the track list." - ) + case ["more"] if len(player.tracklist.available_tracks) >= MAX_TRACKS: + print("Can't load more tracks. Pick one from the track list.") case ["more"]: player.tracklist.load_more() display_tracks(player) @@ -41,9 +35,15 @@ def interact(): def play(player, track_number=None): - """Play a track and shows info about the track.""" - player.play(track_number) - print(player._current_track._get_track_info()) + """Play a track and show info about the track.""" + try: + player.play(track_number) + print(player._current_track._get_track_info()) + except IndexError: + print( + "Please provide a valid track number. " + "You can list available tracks with `tracks`." + ) def pause(player): @@ -56,17 +56,13 @@ def display_tracks(player): header = f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" print(header) print("-" * 100) - for track_number, track in enumerate( - player.tracklist.available_tracks, start=1 - ): + for track_number, track in enumerate(player.tracklist.available_tracks, start=1): if track.text: album, artist, *genre = track.text.split("\n") album = _truncate(album, CW) artist = _truncate(artist, CW) genre = _truncate(genre[0], CW) if genre else "" - print( - f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}" - ) + print(f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}") def _truncate(text, width): diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py index 7736010862..56362a95bf 100644 --- a/python-selenium/src/bandcamp/web/element.py +++ b/python-selenium/src/bandcamp/web/element.py @@ -7,7 +7,7 @@ from bandcamp.web.locators import DiscoverPageLocator, TrackLocator -class TrackElement(WebComponent, TrackLocator): +class TrackElement(WebComponent): """Model a playable track in Bandcamp's Discover section.""" def play(self) -> None: @@ -26,26 +26,26 @@ def is_playing(self) -> bool: def _get_track_info(self) -> Track: """Create a representation of the track's relevant information.""" - full_url = self._parent.find_element(*self.URL).get_attribute("href") + full_url = self._parent.find_element(*TrackLocator.URL).get_attribute("href") # Cut off the referrer query parameter clean_url = full_url.split("?")[0] if full_url else "" # Some tracks don't have a genre try: - genre = self._parent.find_element(*self.GENRE).text + genre = self._parent.find_element(*TrackLocator.GENRE).text except NoSuchElementException: genre = "" return Track( - album=self._parent.find_element(*self.ALBUM).text, - artist=self._parent.find_element(*self.ARTIST).text, + album=self._parent.find_element(*TrackLocator.ALBUM).text, + artist=self._parent.find_element(*TrackLocator.ARTIST).text, genre=genre, url=clean_url, ) def _get_play_button(self): - return self._parent.find_element(*self.PLAY_BUTTON) + return self._parent.find_element(*TrackLocator.PLAY_BUTTON) -class DiscoverTrackList(WebComponent, DiscoverPageLocator, TrackLocator): +class DiscoverTrackList(WebComponent): """Model the track list in Bandcamp's Discover section.""" def __init__(self, parent: WebElement, driver: WebDriver = None) -> None: @@ -54,31 +54,35 @@ def __init__(self, parent: WebElement, driver: WebDriver = None) -> None: def load_more(self) -> None: """Load additional tracks in the Discover section.""" - view_more_button = self._driver.find_element(*self.PAGINATION_BUTTON) + view_more_button = self._driver.find_element( + *DiscoverPageLocator.PAGINATION_BUTTON + ) view_more_button.click() # The button is disabled until all new tracks are loaded. - self._wait.until(EC.element_to_be_clickable(self.PAGINATION_BUTTON)) + self._wait.until(EC.element_to_be_clickable(DiscoverPageLocator.PAGINATION_BUTTON)) self.available_tracks = self._get_available_tracks() def _get_available_tracks(self) -> list: """Find all currently available tracks in the Discover section.""" self._wait.until( - lambda driver: any( - e.is_displayed() and e.text.strip() - for e in driver.find_elements(*self.ITEM) - ), + self._track_text_loaded, message="Timeout waiting for track text to load", ) - all_items = self._driver.find_elements(*self.ITEM) + all_items = self._driver.find_elements(*DiscoverPageLocator.ITEM) all_tracks = [] for item in all_items: - if item.find_element(*self.PLAY_BUTTON): + if item.find_element(*TrackLocator.PLAY_BUTTON): all_tracks.append(item) # Filter tracks that are displayed and have text. return [ - track - for track in all_tracks - if track.is_displayed() and track.text.strip() + track for track in all_tracks if track.is_displayed() and track.text.strip() ] + + def _track_text_loaded(self, driver): + """Check if the track text has loaded.""" + return any( + e.is_displayed() and e.text.strip() + for e in driver.find_elements(*DiscoverPageLocator.ITEM) + ) diff --git a/python-selenium/src/bandcamp/web/page.py b/python-selenium/src/bandcamp/web/page.py index 1488ff5714..2994213d7a 100644 --- a/python-selenium/src/bandcamp/web/page.py +++ b/python-selenium/src/bandcamp/web/page.py @@ -5,16 +5,17 @@ from bandcamp.web.locators import DiscoverPageLocator -class DiscoverPage(WebPage, DiscoverPageLocator): +class DiscoverPage(WebPage): """Model the relevant parts of the Bandcamp Discover page.""" def __init__(self, driver: WebDriver) -> None: super().__init__(driver) self._accept_cookie_consent() self.discover_tracklist = DiscoverTrackList( - self._driver.find_element(*self.DISCOVER_RESULTS), self._driver + self._driver.find_element(*DiscoverPageLocator.DISCOVER_RESULTS), + self._driver, ) def _accept_cookie_consent(self) -> None: """Accept the necessary cookie consent.""" - self._driver.find_element(*self.COOKIE_ACCEPT_NECESSARY).click() + self._driver.find_element(*DiscoverPageLocator.COOKIE_ACCEPT_NECESSARY).click() From b6b18e86d169927012ee11c662208dcdb4e75229 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Sun, 16 Mar 2025 11:00:36 +0100 Subject: [PATCH 08/16] Fix double-exit loop --- python-selenium/src/bandcamp/__main__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/python-selenium/src/bandcamp/__main__.py b/python-selenium/src/bandcamp/__main__.py index ac3589931a..f0cba06399 100644 --- a/python-selenium/src/bandcamp/__main__.py +++ b/python-selenium/src/bandcamp/__main__.py @@ -4,6 +4,3 @@ def main(): """Provide the main entry point for the app.""" interact() - - -main() From 37a10cf52e664b8c3144d500391d90aa3b65ac0e Mon Sep 17 00:00:00 2001 From: martin-martin Date: Sun, 16 Mar 2025 11:22:06 +0100 Subject: [PATCH 09/16] Refix ruff to black --- python-selenium/src/bandcamp/app/tui.py | 20 +++++++++++++++----- python-selenium/src/bandcamp/web/element.py | 12 +++++++++--- python-selenium/src/bandcamp/web/page.py | 4 +++- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index 060e956055..29f66175ed 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -8,7 +8,9 @@ def interact(): """Control the player through user interactions.""" with Player() as player: while True: - print("\nType: play [] | pause | tracks | more | exit") + print( + "\nType: play [] | pause | tracks | more | exit" + ) match input("> ").strip().lower().split(): case ["play"]: play(player) @@ -22,8 +24,12 @@ def interact(): pause(player) case ["tracks"]: display_tracks(player) - case ["more"] if len(player.tracklist.available_tracks) >= MAX_TRACKS: - print("Can't load more tracks. Pick one from the track list.") + case ["more"] if len( + player.tracklist.available_tracks + ) >= MAX_TRACKS: + print( + "Can't load more tracks. Pick one from the track list." + ) case ["more"]: player.tracklist.load_more() display_tracks(player) @@ -56,13 +62,17 @@ def display_tracks(player): header = f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" print(header) print("-" * 100) - for track_number, track in enumerate(player.tracklist.available_tracks, start=1): + for track_number, track in enumerate( + player.tracklist.available_tracks, start=1 + ): if track.text: album, artist, *genre = track.text.split("\n") album = _truncate(album, CW) artist = _truncate(artist, CW) genre = _truncate(genre[0], CW) if genre else "" - print(f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}") + print( + f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}" + ) def _truncate(text, width): diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py index 56362a95bf..c56e4e608e 100644 --- a/python-selenium/src/bandcamp/web/element.py +++ b/python-selenium/src/bandcamp/web/element.py @@ -26,7 +26,9 @@ def is_playing(self) -> bool: def _get_track_info(self) -> Track: """Create a representation of the track's relevant information.""" - full_url = self._parent.find_element(*TrackLocator.URL).get_attribute("href") + full_url = self._parent.find_element(*TrackLocator.URL).get_attribute( + "href" + ) # Cut off the referrer query parameter clean_url = full_url.split("?")[0] if full_url else "" # Some tracks don't have a genre @@ -59,7 +61,9 @@ def load_more(self) -> None: ) view_more_button.click() # The button is disabled until all new tracks are loaded. - self._wait.until(EC.element_to_be_clickable(DiscoverPageLocator.PAGINATION_BUTTON)) + self._wait.until( + EC.element_to_be_clickable(DiscoverPageLocator.PAGINATION_BUTTON) + ) self.available_tracks = self._get_available_tracks() def _get_available_tracks(self) -> list: @@ -77,7 +81,9 @@ def _get_available_tracks(self) -> list: # Filter tracks that are displayed and have text. return [ - track for track in all_tracks if track.is_displayed() and track.text.strip() + track + for track in all_tracks + if track.is_displayed() and track.text.strip() ] def _track_text_loaded(self, driver): diff --git a/python-selenium/src/bandcamp/web/page.py b/python-selenium/src/bandcamp/web/page.py index 2994213d7a..854aed2ecf 100644 --- a/python-selenium/src/bandcamp/web/page.py +++ b/python-selenium/src/bandcamp/web/page.py @@ -18,4 +18,6 @@ def __init__(self, driver: WebDriver) -> None: def _accept_cookie_consent(self) -> None: """Accept the necessary cookie consent.""" - self._driver.find_element(*DiscoverPageLocator.COOKIE_ACCEPT_NECESSARY).click() + self._driver.find_element( + *DiscoverPageLocator.COOKIE_ACCEPT_NECESSARY + ).click() From 446dec2203cf459db47139ff4606410a3beb40d1 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Wed, 2 Apr 2025 13:24:43 +0200 Subject: [PATCH 10/16] Update project code, add training files --- python-selenium/src/bandcamp/app/player.py | 10 +- python-selenium/src/bandcamp/app/tui.py | 27 ++--- python-selenium/src/bandcamp/web/base.py | 2 +- python-selenium/src/bandcamp/web/element.py | 102 +++++++++---------- python-selenium/src/bandcamp/web/locators.py | 7 +- python-selenium/src/bandcamp/web/page.py | 14 ++- python-selenium/training/communication.py | 29 ++++++ python-selenium/training/interaction.py | 23 +++++ python-selenium/training/navigation.py | 23 +++++ python-selenium/training/observation.py | 34 +++++++ 10 files changed, 182 insertions(+), 89 deletions(-) create mode 100644 python-selenium/training/communication.py create mode 100644 python-selenium/training/interaction.py create mode 100644 python-selenium/training/navigation.py create mode 100644 python-selenium/training/observation.py diff --git a/python-selenium/src/bandcamp/app/player.py b/python-selenium/src/bandcamp/app/player.py index 92defb056e..2304f3b8be 100644 --- a/python-selenium/src/bandcamp/app/player.py +++ b/python-selenium/src/bandcamp/app/player.py @@ -1,7 +1,6 @@ from selenium.webdriver import Firefox from selenium.webdriver.firefox.options import Options -from bandcamp.web.element import TrackElement from bandcamp.web.page import DiscoverPage BANDCAMP_DISCOVER_URL = "https://bandcamp.com/discover/" @@ -14,9 +13,7 @@ def __init__(self) -> None: self._driver = self._set_up_driver() self.page = DiscoverPage(self._driver) self.tracklist = self.page.discover_tracklist - self._current_track = TrackElement( - self.tracklist.available_tracks[0], self._driver - ) + self._current_track = self.tracklist.available_tracks[0] def __enter__(self): return self @@ -28,10 +25,7 @@ def __exit__(self, exc_type, exc_value, exc_tb): def play(self, track_number=None): """Play the first track, or one of the available numbered tracks.""" if track_number: - self._current_track = TrackElement( - self.tracklist.available_tracks[track_number - 1], - self._driver, - ) + self._current_track = self.tracklist.available_tracks[track_number - 1] self._current_track.play() def pause(self): diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index 29f66175ed..d9227dd52b 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -8,9 +8,7 @@ def interact(): """Control the player through user interactions.""" with Player() as player: while True: - print( - "\nType: play [] | pause | tracks | more | exit" - ) + print("\nType: play [] | pause | tracks | more | exit") match input("> ").strip().lower().split(): case ["play"]: play(player) @@ -24,12 +22,8 @@ def interact(): pause(player) case ["tracks"]: display_tracks(player) - case ["more"] if len( - player.tracklist.available_tracks - ) >= MAX_TRACKS: - print( - "Can't load more tracks. Pick one from the track list." - ) + case ["more"] if len(player.tracklist.available_tracks) >= MAX_TRACKS: + print("Can't load more tracks. Pick one from the track list.") case ["more"]: player.tracklist.load_more() display_tracks(player) @@ -62,17 +56,14 @@ def display_tracks(player): header = f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" print(header) print("-" * 100) - for track_number, track in enumerate( + for track_number, track_element in enumerate( player.tracklist.available_tracks, start=1 ): - if track.text: - album, artist, *genre = track.text.split("\n") - album = _truncate(album, CW) - artist = _truncate(artist, CW) - genre = _truncate(genre[0], CW) if genre else "" - print( - f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}" - ) + track = track_element._get_track_info() + album = _truncate(track.album, CW) + artist = _truncate(track.artist, CW) + genre = _truncate(track.genre, CW) + print(f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}") def _truncate(text, width): diff --git a/python-selenium/src/bandcamp/web/base.py b/python-selenium/src/bandcamp/web/base.py index b7c2215218..1d04360f81 100644 --- a/python-selenium/src/bandcamp/web/base.py +++ b/python-selenium/src/bandcamp/web/base.py @@ -5,7 +5,7 @@ from selenium.webdriver.support.wait import WebDriverWait MAX_WAIT_SECONDS = 10.0 -DEFAULT_WINDOW_SIZE = (1920, 3000) # Shows 60 tracks +DEFAULT_WINDOW_SIZE = (1920, 3000) @dataclass diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py index c56e4e608e..2646f68979 100644 --- a/python-selenium/src/bandcamp/web/element.py +++ b/python-selenium/src/bandcamp/web/element.py @@ -4,11 +4,52 @@ from selenium.webdriver.support import expected_conditions as EC from bandcamp.web.base import Track, WebComponent -from bandcamp.web.locators import DiscoverPageLocator, TrackLocator +from bandcamp.web.locators import TrackListLocator, TrackLocator + + +class TrackListElement(WebComponent): + """Model the track list on Bandcamp's Discover page.""" + + def __init__(self, parent: WebElement, driver: WebDriver = None) -> None: + super().__init__(parent, driver) + self.available_tracks = self._get_available_tracks() + + def load_more(self) -> None: + """Load additional tracks.""" + view_more_button = self._driver.find_element( + *TrackListLocator.PAGINATION_BUTTON + ) + view_more_button.click() + # The button is disabled until all new tracks are loaded. + self._wait.until(EC.element_to_be_clickable(TrackListLocator.PAGINATION_BUTTON)) + self.available_tracks = self._get_available_tracks() + + def _get_available_tracks(self) -> list: + """Find all currently available tracks.""" + self._wait.until( + self._track_text_loaded, + message="Timeout waiting for track text to load", + ) + + all_tracks = self._driver.find_elements(*TrackListLocator.ITEM) + + # Filter tracks that are displayed and have text. + return [ + TrackElement(track, self._driver) + for track in all_tracks + if track.is_displayed() and track.text.strip() + ] + + def _track_text_loaded(self, driver): + """Check if the track text has loaded.""" + return any( + e.is_displayed() and e.text.strip() + for e in driver.find_elements(*TrackListLocator.ITEM) + ) class TrackElement(WebComponent): - """Model a playable track in Bandcamp's Discover section.""" + """Model a playable track on Bandcamp's Discover page.""" def play(self) -> None: """Play the track.""" @@ -24,11 +65,12 @@ def pause(self) -> None: def is_playing(self) -> bool: return "Pause" in self._get_play_button().get_attribute("aria-label") + def _get_play_button(self): + return self._parent.find_element(*TrackLocator.PLAY_BUTTON) + def _get_track_info(self) -> Track: """Create a representation of the track's relevant information.""" - full_url = self._parent.find_element(*TrackLocator.URL).get_attribute( - "href" - ) + full_url = self._parent.find_element(*TrackLocator.URL).get_attribute("href") # Cut off the referrer query parameter clean_url = full_url.split("?")[0] if full_url else "" # Some tracks don't have a genre @@ -42,53 +84,3 @@ def _get_track_info(self) -> Track: genre=genre, url=clean_url, ) - - def _get_play_button(self): - return self._parent.find_element(*TrackLocator.PLAY_BUTTON) - - -class DiscoverTrackList(WebComponent): - """Model the track list in Bandcamp's Discover section.""" - - def __init__(self, parent: WebElement, driver: WebDriver = None) -> None: - super().__init__(parent, driver) - self.available_tracks = self._get_available_tracks() - - def load_more(self) -> None: - """Load additional tracks in the Discover section.""" - view_more_button = self._driver.find_element( - *DiscoverPageLocator.PAGINATION_BUTTON - ) - view_more_button.click() - # The button is disabled until all new tracks are loaded. - self._wait.until( - EC.element_to_be_clickable(DiscoverPageLocator.PAGINATION_BUTTON) - ) - self.available_tracks = self._get_available_tracks() - - def _get_available_tracks(self) -> list: - """Find all currently available tracks in the Discover section.""" - self._wait.until( - self._track_text_loaded, - message="Timeout waiting for track text to load", - ) - - all_items = self._driver.find_elements(*DiscoverPageLocator.ITEM) - all_tracks = [] - for item in all_items: - if item.find_element(*TrackLocator.PLAY_BUTTON): - all_tracks.append(item) - - # Filter tracks that are displayed and have text. - return [ - track - for track in all_tracks - if track.is_displayed() and track.text.strip() - ] - - def _track_text_loaded(self, driver): - """Check if the track text has loaded.""" - return any( - e.is_displayed() and e.text.strip() - for e in driver.find_elements(*DiscoverPageLocator.ITEM) - ) diff --git a/python-selenium/src/bandcamp/web/locators.py b/python-selenium/src/bandcamp/web/locators.py index f5ef352fac..7626707c3f 100644 --- a/python-selenium/src/bandcamp/web/locators.py +++ b/python-selenium/src/bandcamp/web/locators.py @@ -3,14 +3,17 @@ class DiscoverPageLocator: DISCOVER_RESULTS = (By.CLASS_NAME, "results-grid") - ITEM = (By.CLASS_NAME, "results-grid-item") - PAGINATION_BUTTON = (By.ID, "view-more") COOKIE_ACCEPT_NECESSARY = ( By.CSS_SELECTOR, "#cookie-control-dialog button.g-button.outline", ) +class TrackListLocator: + ITEM = (By.CLASS_NAME, "results-grid-item") + PAGINATION_BUTTON = (By.ID, "view-more") + + class TrackLocator: PLAY_BUTTON = (By.CSS_SELECTOR, "button.play-pause-button") URL = (By.CSS_SELECTOR, "div.meta p a") diff --git a/python-selenium/src/bandcamp/web/page.py b/python-selenium/src/bandcamp/web/page.py index 854aed2ecf..714b0288ec 100644 --- a/python-selenium/src/bandcamp/web/page.py +++ b/python-selenium/src/bandcamp/web/page.py @@ -1,7 +1,8 @@ +from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.remote.webdriver import WebDriver from bandcamp.web.base import WebPage -from bandcamp.web.element import DiscoverTrackList +from bandcamp.web.element import TrackListElement from bandcamp.web.locators import DiscoverPageLocator @@ -11,13 +12,16 @@ class DiscoverPage(WebPage): def __init__(self, driver: WebDriver) -> None: super().__init__(driver) self._accept_cookie_consent() - self.discover_tracklist = DiscoverTrackList( + self.discover_tracklist = TrackListElement( self._driver.find_element(*DiscoverPageLocator.DISCOVER_RESULTS), self._driver, ) def _accept_cookie_consent(self) -> None: """Accept the necessary cookie consent.""" - self._driver.find_element( - *DiscoverPageLocator.COOKIE_ACCEPT_NECESSARY - ).click() + try: + self._driver.find_element( + *DiscoverPageLocator.COOKIE_ACCEPT_NECESSARY + ).click() + except NoSuchElementException: + pass diff --git a/python-selenium/training/communication.py b/python-selenium/training/communication.py new file mode 100644 index 0000000000..54c8d94ed6 --- /dev/null +++ b/python-selenium/training/communication.py @@ -0,0 +1,29 @@ +import time + +from selenium import webdriver +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By + +driver = webdriver.Firefox() # Run in normal mode +driver.get("https://bandcamp.com/discover/") + +# Accept cookies, if required +try: + cookie_accept_button = driver.find_element( + By.CSS_SELECTOR, + "#cookie-control-dialog button.g-button.outline", + ) + cookie_accept_button.click() +except NoSuchElementException: + pass + +time.sleep(0.5) + +search = driver.find_element(By.CLASS_NAME, "site-search-form") +search_field = search.find_element(By.TAG_NAME, "input") +search_field.send_keys("selenium") +search_field.submit() + +time.sleep(5) + +driver.quit() diff --git a/python-selenium/training/interaction.py b/python-selenium/training/interaction.py new file mode 100644 index 0000000000..5026cbaa67 --- /dev/null +++ b/python-selenium/training/interaction.py @@ -0,0 +1,23 @@ +import time + +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.firefox.options import Options + +options = Options() +options.add_argument("--headless") +driver = webdriver.Firefox(options=options) +driver.get("https://bandcamp.com/discover/") + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) + +pagination_button = driver.find_element(By.ID, "view-more") +pagination_button.click() + +time.sleep(0.5) + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) + +driver.quit() diff --git a/python-selenium/training/navigation.py b/python-selenium/training/navigation.py new file mode 100644 index 0000000000..f278588bf8 --- /dev/null +++ b/python-selenium/training/navigation.py @@ -0,0 +1,23 @@ +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.firefox.options import Options + +options = Options() +options.add_argument("--headless") +driver = webdriver.Firefox(options=options) + +driver.get("https://bandcamp.com/discover/") +print(driver.title) + +pagination_button = driver.find_element(By.ID, "view-more") +print(pagination_button.accessible_name) + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) +print(tracks[0].text) + +track_1 = tracks[0] +album = track_1.find_element(By.CSS_SELECTOR, "div.meta a strong") +print(album.text) + +driver.quit() diff --git a/python-selenium/training/observation.py b/python-selenium/training/observation.py new file mode 100644 index 0000000000..7f15b4b9af --- /dev/null +++ b/python-selenium/training/observation.py @@ -0,0 +1,34 @@ +from selenium import webdriver +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By +from selenium.webdriver.firefox.options import Options +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +options = Options() +options.add_argument("--headless") +driver = webdriver.Firefox(options=options) +driver.get("https://bandcamp.com/discover/") + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) + +try: + cookie_accept_button = driver.find_element( + By.CSS_SELECTOR, + "#cookie-control-dialog button.g-button.outline", + ) + cookie_accept_button.click() +except NoSuchElementException: + pass + +pagination_button = driver.find_element(By.ID, "view-more") +pagination_button.click() + +wait = WebDriverWait(driver, 10) +wait.until(EC.element_to_be_clickable((By.ID, "view-more"))) + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) + +driver.quit() From 67f6d93fd5f00023720f1961c57d1a4fd681a057 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Wed, 2 Apr 2025 13:56:44 +0200 Subject: [PATCH 11/16] Format black --- python-selenium/src/bandcamp/app/player.py | 4 +++- python-selenium/src/bandcamp/app/tui.py | 12 +++++++++--- python-selenium/src/bandcamp/web/element.py | 8 ++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/python-selenium/src/bandcamp/app/player.py b/python-selenium/src/bandcamp/app/player.py index 2304f3b8be..aa09b0b6ac 100644 --- a/python-selenium/src/bandcamp/app/player.py +++ b/python-selenium/src/bandcamp/app/player.py @@ -25,7 +25,9 @@ def __exit__(self, exc_type, exc_value, exc_tb): def play(self, track_number=None): """Play the first track, or one of the available numbered tracks.""" if track_number: - self._current_track = self.tracklist.available_tracks[track_number - 1] + self._current_track = self.tracklist.available_tracks[ + track_number - 1 + ] self._current_track.play() def pause(self): diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index d9227dd52b..7cb548ac8b 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -8,7 +8,9 @@ def interact(): """Control the player through user interactions.""" with Player() as player: while True: - print("\nType: play [] | pause | tracks | more | exit") + print( + "\nType: play [] | pause | tracks | more | exit" + ) match input("> ").strip().lower().split(): case ["play"]: play(player) @@ -22,8 +24,12 @@ def interact(): pause(player) case ["tracks"]: display_tracks(player) - case ["more"] if len(player.tracklist.available_tracks) >= MAX_TRACKS: - print("Can't load more tracks. Pick one from the track list.") + case ["more"] if len( + player.tracklist.available_tracks + ) >= MAX_TRACKS: + print( + "Can't load more tracks. Pick one from the track list." + ) case ["more"]: player.tracklist.load_more() display_tracks(player) diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/element.py index 2646f68979..a1a43275da 100644 --- a/python-selenium/src/bandcamp/web/element.py +++ b/python-selenium/src/bandcamp/web/element.py @@ -21,7 +21,9 @@ def load_more(self) -> None: ) view_more_button.click() # The button is disabled until all new tracks are loaded. - self._wait.until(EC.element_to_be_clickable(TrackListLocator.PAGINATION_BUTTON)) + self._wait.until( + EC.element_to_be_clickable(TrackListLocator.PAGINATION_BUTTON) + ) self.available_tracks = self._get_available_tracks() def _get_available_tracks(self) -> list: @@ -70,7 +72,9 @@ def _get_play_button(self): def _get_track_info(self) -> Track: """Create a representation of the track's relevant information.""" - full_url = self._parent.find_element(*TrackLocator.URL).get_attribute("href") + full_url = self._parent.find_element(*TrackLocator.URL).get_attribute( + "href" + ) # Cut off the referrer query parameter clean_url = full_url.split("?")[0] if full_url else "" # Some tracks don't have a genre From 79b9b8ace5d31dbecba2d3594127b07429cf29a2 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Fri, 4 Apr 2025 16:07:54 +0200 Subject: [PATCH 12/16] Rename modules, improve CLI output, rename entry point Co-authored-by: Bartosz --- python-selenium/pyproject.toml | 2 +- python-selenium/src/bandcamp/app/player.py | 2 +- python-selenium/src/bandcamp/app/tui.py | 2 +- python-selenium/src/bandcamp/web/base.py | 4 ++++ .../bandcamp/web/{element.py => elements.py} | 0 .../src/bandcamp/web/{page.py => pages.py} | 2 +- python-selenium/training/communication.py | 2 ++ python-selenium/training/interaction.py | 2 ++ python-selenium/training/navigation.py | 1 + python-selenium/training/observation.py | 2 ++ python-selenium/uv.lock | 18 +++++++++--------- 11 files changed, 24 insertions(+), 13 deletions(-) rename python-selenium/src/bandcamp/web/{element.py => elements.py} (100%) rename python-selenium/src/bandcamp/web/{page.py => pages.py} (94%) diff --git a/python-selenium/pyproject.toml b/python-selenium/pyproject.toml index b675214a12..12ebd32c3a 100644 --- a/python-selenium/pyproject.toml +++ b/python-selenium/pyproject.toml @@ -15,4 +15,4 @@ dependencies = [ "selenium", ] [project.scripts] -bandcamp-player = "bandcamp.__main__:main" +discover = "bandcamp.__main__:main" diff --git a/python-selenium/src/bandcamp/app/player.py b/python-selenium/src/bandcamp/app/player.py index aa09b0b6ac..f5cebe6ea2 100644 --- a/python-selenium/src/bandcamp/app/player.py +++ b/python-selenium/src/bandcamp/app/player.py @@ -1,7 +1,7 @@ from selenium.webdriver import Firefox from selenium.webdriver.firefox.options import Options -from bandcamp.web.page import DiscoverPage +from bandcamp.web.pages import DiscoverPage BANDCAMP_DISCOVER_URL = "https://bandcamp.com/discover/" diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index 7cb548ac8b..e688200693 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -61,7 +61,7 @@ def display_tracks(player): """Display information about the currently playable tracks.""" header = f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" print(header) - print("-" * 100) + print("-" * 80) for track_number, track_element in enumerate( player.tracklist.available_tracks, start=1 ): diff --git a/python-selenium/src/bandcamp/web/base.py b/python-selenium/src/bandcamp/web/base.py index 1d04360f81..c533848815 100644 --- a/python-selenium/src/bandcamp/web/base.py +++ b/python-selenium/src/bandcamp/web/base.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from pprint import pformat from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement @@ -15,6 +16,9 @@ class Track: genre: str url: str + def __str__(self): + return pformat(self) + class WebPage: def __init__(self, driver: WebDriver) -> None: diff --git a/python-selenium/src/bandcamp/web/element.py b/python-selenium/src/bandcamp/web/elements.py similarity index 100% rename from python-selenium/src/bandcamp/web/element.py rename to python-selenium/src/bandcamp/web/elements.py diff --git a/python-selenium/src/bandcamp/web/page.py b/python-selenium/src/bandcamp/web/pages.py similarity index 94% rename from python-selenium/src/bandcamp/web/page.py rename to python-selenium/src/bandcamp/web/pages.py index 714b0288ec..f3a258dac2 100644 --- a/python-selenium/src/bandcamp/web/page.py +++ b/python-selenium/src/bandcamp/web/pages.py @@ -2,7 +2,7 @@ from selenium.webdriver.remote.webdriver import WebDriver from bandcamp.web.base import WebPage -from bandcamp.web.element import TrackListElement +from bandcamp.web.elements import TrackListElement from bandcamp.web.locators import DiscoverPageLocator diff --git a/python-selenium/training/communication.py b/python-selenium/training/communication.py index 54c8d94ed6..96f77bc0e6 100644 --- a/python-selenium/training/communication.py +++ b/python-selenium/training/communication.py @@ -5,6 +5,8 @@ from selenium.webdriver.common.by import By driver = webdriver.Firefox() # Run in normal mode +driver.implicitly_wait(5) + driver.get("https://bandcamp.com/discover/") # Accept cookies, if required diff --git a/python-selenium/training/interaction.py b/python-selenium/training/interaction.py index 5026cbaa67..50724f5fa8 100644 --- a/python-selenium/training/interaction.py +++ b/python-selenium/training/interaction.py @@ -7,6 +7,8 @@ options = Options() options.add_argument("--headless") driver = webdriver.Firefox(options=options) +driver.implicitly_wait(5) + driver.get("https://bandcamp.com/discover/") tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") diff --git a/python-selenium/training/navigation.py b/python-selenium/training/navigation.py index f278588bf8..b02e3c3382 100644 --- a/python-selenium/training/navigation.py +++ b/python-selenium/training/navigation.py @@ -5,6 +5,7 @@ options = Options() options.add_argument("--headless") driver = webdriver.Firefox(options=options) +driver.implicitly_wait(5) driver.get("https://bandcamp.com/discover/") print(driver.title) diff --git a/python-selenium/training/observation.py b/python-selenium/training/observation.py index 7f15b4b9af..74eeeffed2 100644 --- a/python-selenium/training/observation.py +++ b/python-selenium/training/observation.py @@ -8,6 +8,8 @@ options = Options() options.add_argument("--headless") driver = webdriver.Firefox(options=options) +driver.implicitly_wait(5) + driver.get("https://bandcamp.com/discover/") tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") diff --git a/python-selenium/uv.lock b/python-selenium/uv.lock index f3e7cae0d9..3557ceda08 100644 --- a/python-selenium/uv.lock +++ b/python-selenium/uv.lock @@ -4,11 +4,11 @@ requires-python = ">=3.10" [[package]] name = "attrs" -version = "25.2.0" +version = "25.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/82/3c4e1d44f3cbaa2a578127d641fe385ba3bff6c38b789447ae11a21fa413/attrs-25.2.0.tar.gz", hash = "sha256:18a06db706db43ac232cce80443fcd9f2500702059ecf53489e3c5a3f417acaf", size = 812038 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/33/7a7388b9ef94aab40539939d94461ec682afbd895458945ed25be07f03f6/attrs-25.2.0-py3-none-any.whl", hash = "sha256:611344ff0a5fed735d86d7784610c84f8126b95e549bcad9ff61b4242f2d386b", size = 64016 }, + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, ] [[package]] @@ -109,7 +109,7 @@ wheels = [ [[package]] name = "selenium" -version = "4.29.0" +version = "4.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "urllib3", extra = ["socks"] }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/09de87ef66a10a7d40417d4e93449eb892154d2dc6385187aa9298a2c09d/selenium-4.29.0.tar.gz", hash = "sha256:3a62f7ec33e669364a6c0562a701deb69745b569c50d55f1a912bf8eb33358ba", size = 985717 } +sdist = { url = "https://files.pythonhosted.org/packages/70/f8/12e5c86f5d4b26758151a2145cb0909d2b811a3ac846b645dd7c63023543/selenium-4.30.0.tar.gz", hash = "sha256:16ab890fc7cb21a01e1b1e9a0fbaa9445fe30837eabc66e90b3bacf12138126a", size = 859424 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/a6/fc66ea71ec0769f72abdf15cb9ec9269517abe68a160839383ddff7478f1/selenium-4.29.0-py3-none-any.whl", hash = "sha256:ce5d26f1ddc1111641113653af33694c13947dd36c2df09cdd33f554351d372e", size = 9536642 }, + { url = "https://files.pythonhosted.org/packages/79/cb/6e9c6f9072eb09d0f0cdfc52e33ad6583a6bd5232a322fccdd378104c6e0/selenium-4.30.0-py3-none-any.whl", hash = "sha256:90bcd3be86a1762100a093b33e5e4530b328226da94208caadb15ce13243dffd", size = 9353816 }, ] [[package]] @@ -177,11 +177,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.13.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 }, ] [[package]] From ef6919a9dab5fe7b01c24dd5fac6650384f3d83f Mon Sep 17 00:00:00 2001 From: martin-martin Date: Fri, 4 Apr 2025 16:09:30 +0200 Subject: [PATCH 13/16] blacken --- python-selenium/src/bandcamp/app/tui.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py index e688200693..c12e166ced 100644 --- a/python-selenium/src/bandcamp/app/tui.py +++ b/python-selenium/src/bandcamp/app/tui.py @@ -24,9 +24,9 @@ def interact(): pause(player) case ["tracks"]: display_tracks(player) - case ["more"] if len( - player.tracklist.available_tracks - ) >= MAX_TRACKS: + case ["more"] if ( + len(player.tracklist.available_tracks) >= MAX_TRACKS + ): print( "Can't load more tracks. Pick one from the track list." ) From bf5326a2b8e26b3efc7f199a0addea9633f1a527 Mon Sep 17 00:00:00 2001 From: martin-martin Date: Fri, 4 Apr 2025 16:25:26 +0200 Subject: [PATCH 14/16] Add implicit wait --- python-selenium/src/bandcamp/web/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python-selenium/src/bandcamp/web/base.py b/python-selenium/src/bandcamp/web/base.py index c533848815..4212844f13 100644 --- a/python-selenium/src/bandcamp/web/base.py +++ b/python-selenium/src/bandcamp/web/base.py @@ -24,6 +24,7 @@ class WebPage: def __init__(self, driver: WebDriver) -> None: self._driver = driver self._driver.set_window_size(*DEFAULT_WINDOW_SIZE) + self._driver.implicitly_wait(5) self._wait = WebDriverWait(driver, MAX_WAIT_SECONDS) From 7f22ab09e38ea1518f3f57ab1ce3ce393cc3d63f Mon Sep 17 00:00:00 2001 From: martin-martin Date: Tue, 8 Apr 2025 12:09:25 +0200 Subject: [PATCH 15/16] Add package info --- python-selenium/README.md | 8 ++++---- python-selenium/pyproject.toml | 12 +++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/python-selenium/README.md b/python-selenium/README.md index 11989404c4..b321a501b4 100644 --- a/python-selenium/README.md +++ b/python-selenium/README.md @@ -36,18 +36,18 @@ Type one of the available commands to interact with Bandcamp's Discover section ## Troubleshooting -If the music player seems to hang when you run the script, confirm whether you've correctly set up your webdriver based on the following points. +If the music player seems to hang when you run the script, confirm whether you've correctly set up your WebDriver based on the following points. ### Version Compatibility -Confirm that your browser and corresponding webdriver are in sync. If you followed the previous suggestion, then you should be using Firefox and geckodriver. If that doesn't work for any reason, you may need to switch browser _and_ webdriver. +Confirm that your browser and corresponding WebDriver are in sync. If you followed the previous suggestion, then you should be using Firefox and geckodriver. If that doesn't work for any reason, you may need to switch browser _and_ WebDriver. For example, if you're using Chrome, then you need to install ChromeDriver and it must match your Chrome version. Otherwise, you may run into errors like `SessionNotCreatedException`. For more details, refer to the official [ChromeDriver documentation](https://sites.google.com/chromium.org/driver/) or [geckodriver releases](https://github.com/mozilla/geckodriver/releases). ### Driver Installation and Path Issues -Once you've confirmed that your browser and driver match, make sure that the webdriver executable is properly installed: +Once you've confirmed that your browser and driver match, make sure that the WebDriver executable is properly installed: - **Path Configuration:** The driver must be in your system's PATH, or you need to specify its full path in your code. - **Permissions:** Ensure the driver file has the necessary execution permissions. @@ -61,4 +61,4 @@ Bartosz Zaczyński - Email: bartosz@realpython.com ## License -Distributed under the MIT license. See `LICENSE` for more information. +Distributed under the MIT license. diff --git a/python-selenium/pyproject.toml b/python-selenium/pyproject.toml index 12ebd32c3a..940a8631a8 100644 --- a/python-selenium/pyproject.toml +++ b/python-selenium/pyproject.toml @@ -3,10 +3,15 @@ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "bandcamp_player" -version = "0.1.0" +name = "pycamp" +version = "0.1.1" requires-python = ">=3.10" -description = "A web player for Bandcamp using Selenium" +description = "A CLI music player for Bandcamp's Discover page using Python and Selenium" +license = "MIT" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent" +] authors = [ { name = "Martin Breuss", email = "martin@realpython.com" }, { name = "Bartosz Zaczyński", email = "bartosz@realpython.com" }, @@ -16,3 +21,4 @@ dependencies = [ ] [project.scripts] discover = "bandcamp.__main__:main" +pycamp = "bandcamp.__main__:main" From 87d63aa1763218182a6c6a264eec61c36708f69f Mon Sep 17 00:00:00 2001 From: brendaweles <160772586+brendaweles@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:50:37 -0600 Subject: [PATCH 16/16] Language edit --- python-selenium/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python-selenium/README.md b/python-selenium/README.md index b321a501b4..f9b1d5c658 100644 --- a/python-selenium/README.md +++ b/python-selenium/README.md @@ -18,7 +18,7 @@ You'll need a [Firefox Selenium driver](https://selenium-python.readthedocs.io/i ## Run the Bandcamp Discover Player -To run the music player, install the package, then use the entry point command from your command-line: +To run the music player, install the package, then use the entry-point command from your command line: ```sh (venv) $ python -m pip install . @@ -32,7 +32,7 @@ Type: play [] | pause | tracks | more | exit > ``` -Type one of the available commands to interact with Bandcamp's Discover section through your headless browser. Listen to songs with `play`, pause the current song with `pause` and restart it with `play`. List available tracks with `tracks`, and load more songs using `more`. You can exit the music player by typing `exit`. +Type one of the available commands to interact with Bandcamp's Discover section through your headless browser. Listen to songs with `play`, pause the current song with `pause`, and restart it with `play`. List available tracks with `tracks`, and load more songs using `more`. You can exit the music player by typing `exit`. ## Troubleshooting @@ -40,7 +40,7 @@ If the music player seems to hang when you run the script, confirm whether you'v ### Version Compatibility -Confirm that your browser and corresponding WebDriver are in sync. If you followed the previous suggestion, then you should be using Firefox and geckodriver. If that doesn't work for any reason, you may need to switch browser _and_ WebDriver. +Confirm that your browser and corresponding WebDriver are in sync. If you followed the previous suggestion, then you should be using Firefox and geckodriver. If that doesn't work for some reason, then you may need to switch your browser _and_ WebDriver. For example, if you're using Chrome, then you need to install ChromeDriver and it must match your Chrome version. Otherwise, you may run into errors like `SessionNotCreatedException`. For more details, refer to the official [ChromeDriver documentation](https://sites.google.com/chromium.org/driver/) or [geckodriver releases](https://github.com/mozilla/geckodriver/releases). @@ -52,7 +52,7 @@ Once you've confirmed that your browser and driver match, make sure that the Web - **Path Configuration:** The driver must be in your system's PATH, or you need to specify its full path in your code. - **Permissions:** Ensure the driver file has the necessary execution permissions. -If you're still running into issues executing the script, then consult the [Selenium Documentation](https://www.selenium.dev/documentation/) for additional troubleshooting tips or leave a comment on the tutorial. +If you're still running into issues executing the script, then consult the [Selenium Documentation](https://www.selenium.dev/documentation/) for additional troubleshooting tips or leave a comment in the tutorial. ## About the Authors