Skip to content

Commit a0fd072

Browse files
committed
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 :)
1 parent 79a06fc commit a0fd072

File tree

10 files changed

+388
-104
lines changed

10 files changed

+388
-104
lines changed

python-selenium/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ dependencies = [
1616
]
1717
[project.scripts]
1818
bandcamp-player = "bandcamp.__main__:main"
19+
20+
[tool.setuptools.packages.find]
21+
where = ["src"]

python-selenium/requirements.txt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
attrs==24.2.0
2-
certifi==2024.8.30
1+
attrs==25.2.0
2+
certifi==2025.1.31
33
h11==0.14.0
44
idna==3.10
55
outcome==1.3.0.post0
6-
PySocks==1.7.1
7-
selenium==4.25.0
6+
pysocks==1.7.1
7+
selenium==4.29.0
88
sniffio==1.3.1
99
sortedcontainers==2.4.0
10-
trio==0.27.0
11-
trio-websocket==0.11.1
12-
typing_extensions==4.12.2
13-
urllib3==2.2.3
10+
trio==0.29.0
11+
trio-websocket==0.12.2
12+
typing-extensions==4.12.2
13+
urllib3==2.3.0
1414
websocket-client==1.8.0
1515
wsproto==1.2.0
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
from bandcamp.app.tui import interact
1+
from bandcamp.app.tui import TUI
22

33

44
def main():
5-
"""Provide the main entry point for the app."""
6-
interact()
5+
"""Provides the main entry point for the app."""
6+
tui = TUI()
7+
tui.interact()
8+
9+
10+
if __name__ == "__main__":
11+
main()

python-selenium/src/bandcamp/app/player.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,46 @@
22
from selenium.webdriver.firefox.options import Options
33

44
from bandcamp.web.element import TrackElement
5-
from bandcamp.web.page import HomePage
5+
from bandcamp.web.page import DiscoverPage
66

7-
BANDCAMP_FRONTPAGE_URL = "https://bandcamp.com/"
7+
BANDCAMP_DISCOVER = "https://bandcamp.com/discover/"
88

99

1010
class Player:
11-
"""Play tracks from Bandcamp's Discover section."""
11+
"""Plays tracks from Bandcamp's Discover page."""
1212

1313
def __init__(self) -> None:
1414
self._driver = self._set_up_driver()
15-
self.home = HomePage(self._driver)
16-
self.discover = self.home.discover_tracklist
15+
self.page = DiscoverPage(self._driver)
16+
self.tracklist = self.page.discover_tracklist
1717
self._current_track = TrackElement(
18-
self.home.discover_tracklist.available_tracks[0], self._driver
18+
self.tracklist.available_tracks[0], self._driver
1919
)
2020

2121
def __enter__(self):
2222
return self
2323

2424
def __exit__(self, exc_type, exc_value, exc_tb):
25-
"""Close the headless browser."""
26-
self._driver.close()
25+
"""Closes the headless browser."""
26+
self._driver.quit()
2727

2828
def play(self, track_number=None):
29-
"""Play the first track, or one of the available numbered tracks."""
29+
"""Plays the first track, or one of the available numbered tracks."""
3030
if track_number:
3131
self._current_track = TrackElement(
32-
self.home.discover_tracklist.available_tracks[
33-
track_number - 1
34-
],
32+
self.tracklist.available_tracks[track_number - 1],
3533
self._driver,
3634
)
3735
self._current_track.play()
3836

37+
def pause(self):
38+
"""Pauses the current track."""
39+
self._current_track.pause()
40+
3941
def _set_up_driver(self):
40-
"""Create a headless browser pointing to Bandcamp."""
42+
"""Creates a headless browser pointing to Bandcamp."""
4143
options = Options()
4244
options.add_argument("--headless")
4345
browser = Firefox(options=options)
44-
browser.get(BANDCAMP_FRONTPAGE_URL)
46+
browser.get(BANDCAMP_DISCOVER)
4547
return browser
Lines changed: 62 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,76 @@
1-
"""Provide a text-based user interface for a Bandcamp music player."""
2-
31
from bandcamp.app.player import Player
42

5-
COLUMN_WIDTH = CW = 30
63

4+
class TUI:
5+
"""Provides a text-based user interface for a Bandcamp music player."""
6+
7+
COLUMN_WIDTH = CW = 30
8+
MAX_TRACKS = 120 # Twice the number of tracks that fit in the viewport.
9+
10+
def interact(self):
11+
"""Controls the player through user interactions."""
12+
with Player() as player:
13+
while True:
14+
print(
15+
"\nType: [play <track number>], [pause], [tracks], [more], [exit]"
16+
)
17+
command = input("> ").strip().lower()
718

8-
def interact():
9-
"""Control the player through user interactions."""
10-
with Player() as player:
11-
while True:
12-
print("\nType: play [<track_number>] | tracks | more | exit")
13-
match input("> ").strip().lower().split():
14-
case ["play"]:
15-
play(player)
16-
case ["play", track]:
19+
if command.startswith("play"):
1720
try:
18-
track_number = int(track)
19-
play(player, track_number)
21+
track_number = int(command.split()[1])
22+
self.play(player, track_number)
23+
except IndexError: # Play first track.
24+
self.play(player)
2025
except ValueError:
2126
print("Please provide a valid track number.")
22-
case ["tracks"]:
23-
display_tracks(player)
24-
case ["more"]:
25-
player.discover.load_more()
26-
display_tracks(player)
27-
case ["exit"]:
27+
elif command == "pause":
28+
self.pause(player)
29+
elif command == "tracks":
30+
self.tracks(player)
31+
elif command == "more":
32+
# A higher number of tracks can't be clicked without scrolling.
33+
if len(player.tracklist.available_tracks) >= self.MAX_TRACKS:
34+
print("Can't load more tracks. Pick one from the track list.")
35+
else:
36+
player.tracklist.load_more()
37+
self.tracks(player)
38+
elif command == "exit":
2839
print("Exiting the player...")
2940
break
30-
case _:
41+
else:
3142
print("Unknown command. Try again.")
3243

44+
def play(self, player, track_number=None):
45+
"""Plays a track and shows info about the track."""
46+
player.play(track_number)
47+
print(player._current_track._get_track_info())
3348

34-
def play(player, track_number=None):
35-
"""Play a track and show info about the track."""
36-
player.play(track_number)
37-
print(player._current_track._get_track_info())
38-
39-
40-
def display_tracks(player):
41-
"""Display information about the currently playable tracks."""
42-
header = (
43-
f"{'#':<5} {'Album':<{CW}} " f"{'Artist':<{CW}} " f"{'Genre':<{CW}}"
44-
)
45-
print(header)
46-
print("-" * 100)
47-
for track_number, track in enumerate(
48-
player.discover.available_tracks, start=1
49-
):
50-
album, artist, *genre = track.text.split("\n")
51-
album = _truncate(album, CW)
52-
artist = _truncate(artist, CW)
53-
genre = _truncate(genre[0], CW) if genre else ""
54-
print(
55-
f"{track_number:<5} {album:<{CW}} " f"{artist:<{CW}} {genre:<{CW}}"
56-
)
49+
def pause(self, player):
50+
"""Pauses the current track."""
51+
player.pause()
5752

53+
def tracks(self, player):
54+
"""Displays information about the currently playable tracks."""
55+
header = (
56+
f"{'#':<5} {'Album':<{self.CW}} {'Artist':<{self.CW}} {'Genre':<{self.CW}}"
57+
)
58+
print(header)
59+
print("-" * 100)
60+
for track_number, track in enumerate(
61+
player.tracklist.available_tracks, start=1
62+
):
63+
if track.text:
64+
album, artist, *genre = track.text.split("\n")
65+
album = self._truncate(album, self.CW)
66+
artist = self._truncate(artist, self.CW)
67+
genre = self._truncate(genre[0], self.CW) if genre else ""
68+
print(
69+
f"{track_number:<5} {album:<{self.CW}} "
70+
f"{artist:<{self.CW}} {genre:<{self.CW}}"
71+
)
5872

59-
def _truncate(text, width):
60-
"""Truncate track information."""
61-
return text[: width - 3] + "..." if len(text) > width else text
73+
@staticmethod
74+
def _truncate(text, width):
75+
"""Truncates track information."""
76+
return text[: width - 3] + "..." if len(text) > width else text

python-selenium/src/bandcamp/web/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from selenium.webdriver.support.wait import WebDriverWait
66

77
MAX_WAIT_SECONDS = 10.0
8+
DEFAULT_WINDOW_SIZE = (1920, 3000) # Shows 60 tracks
89

910

1011
@dataclass
@@ -18,6 +19,7 @@ class Track:
1819
class WebPage:
1920
def __init__(self, driver: WebDriver) -> None:
2021
self._driver = driver
22+
self._driver.set_window_size(*DEFAULT_WINDOW_SIZE)
2123
self._wait = WebDriverWait(driver, MAX_WAIT_SECONDS)
2224

2325

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,82 @@
1+
from selenium.common.exceptions import NoSuchElementException
12
from selenium.webdriver.remote.webdriver import WebDriver
23
from selenium.webdriver.remote.webelement import WebElement
4+
from selenium.webdriver.support import expected_conditions as EC
35

46
from bandcamp.web.base import Track, WebComponent
5-
from bandcamp.web.locators import HomePageLocator, TrackLocator
7+
from bandcamp.web.locators import DiscoverPageLocatorMixin, TrackLocatorMixin
68

79

8-
class TrackElement(WebComponent, TrackLocator):
9-
"""Model a playable track in Bandcamp's Discover section."""
10+
class TrackElement(WebComponent, TrackLocatorMixin):
11+
"""Models a playable track in Bandcamp's Discover section."""
1012

1113
def play(self) -> None:
12-
"""Play the track."""
13-
if not self.is_playing():
14+
"""Plays the track."""
15+
if not self.is_playing:
1416
self._get_play_button().click()
15-
self._wait.until(lambda _: self.is_playing())
1617

18+
def pause(self) -> None:
19+
"""Pauses the track."""
20+
if self.is_playing:
21+
self._get_play_button().click()
22+
23+
@property
1724
def is_playing(self) -> bool:
18-
return "playing" in self._get_play_button().get_attribute("class")
25+
return "Pause" in self._get_play_button().get_attribute("aria-label")
1926

2027
def _get_track_info(self) -> Track:
21-
"""Create a representation of the track's relevant information."""
22-
full_url = self._parent.find_element(*self.ALBUM).get_attribute("href")
28+
"""Creates a representation of the track's relevant information."""
29+
full_url = self._parent.find_element(*self.URL).get_attribute("href")
2330
# Cut off the referrer query parameter
2431
clean_url = full_url.split("?")[0] if full_url else ""
32+
# Some tracks don't have a genre
33+
try:
34+
genre = self._parent.find_element(*self.GENRE).text
35+
except NoSuchElementException:
36+
genre = ""
2537
return Track(
2638
album=self._parent.find_element(*self.ALBUM).text,
2739
artist=self._parent.find_element(*self.ARTIST).text,
28-
genre=self._parent.find_element(*self.GENRE).text,
40+
genre=genre,
2941
url=clean_url,
3042
)
3143

3244
def _get_play_button(self):
3345
return self._parent.find_element(*self.PLAY_BUTTON)
3446

3547

36-
class DiscoverTrackList(WebComponent, HomePageLocator):
37-
"""Model the track list in Bandcamp's Discover section."""
48+
class DiscoverTrackList(WebComponent, DiscoverPageLocatorMixin, TrackLocatorMixin):
49+
"""Models the track list in Bandcamp's Discover section."""
3850

3951
def __init__(self, parent: WebElement, driver: WebDriver = None) -> None:
4052
super().__init__(parent, driver)
4153
self.available_tracks = self._get_available_tracks()
4254

4355
def load_more(self) -> None:
44-
"""Load additional tracks in the Discover section."""
45-
self._get_next_page_button().click()
56+
"""Loads additional tracks in the Discover section."""
57+
view_more_button = self._driver.find_element(*self.PAGINATION_BUTTON)
58+
view_more_button.click()
59+
# The button is disabled until all new tracks are loaded.
60+
self._wait.until(EC.element_to_be_clickable(self.PAGINATION_BUTTON))
4661
self.available_tracks = self._get_available_tracks()
4762

4863
def _get_available_tracks(self) -> list:
49-
"""Find all currently available tracks in the Discover section."""
50-
all_tracks = self._driver.find_elements(*self.TRACK)
51-
return [track for track in all_tracks if track.is_displayed()]
64+
"""Finds all currently available tracks in the Discover section."""
65+
self._wait.until(
66+
lambda driver: any(
67+
e.is_displayed() and e.text.strip()
68+
for e in driver.find_elements(*self.ITEM)
69+
),
70+
message="Timeout waiting for track text to load",
71+
)
72+
73+
all_items = self._driver.find_elements(*self.ITEM)
74+
all_tracks = []
75+
for item in all_items:
76+
if item.find_element(*self.PLAY_BUTTON):
77+
all_tracks.append(item)
5278

53-
def _get_next_page_button(self):
54-
"""Locate and return the 'Next' button that loads more results."""
55-
return self._driver.find_elements(*self.PAGINATION_BUTTON)[-1]
79+
# Filter tracks that are displayed and have text.
80+
return [
81+
track for track in all_tracks if track.is_displayed() and track.text.strip()
82+
]
Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
from selenium.webdriver.common.by import By
22

33

4-
class HomePageLocator:
5-
DISCOVER_RESULTS = (By.CLASS_NAME, "discover-results")
6-
TRACK = (By.CLASS_NAME, "discover-item")
7-
PAGINATION_BUTTON = (By.CLASS_NAME, "item-page")
4+
class DiscoverPageLocatorMixin:
5+
DISCOVER_RESULTS = (By.CLASS_NAME, "results-grid")
6+
ITEM = (By.CLASS_NAME, "results-grid-item")
7+
PAGINATION_BUTTON = (By.ID, "view-more")
8+
COOKIE_ACCEPT_NECESSARY = (
9+
By.CSS_SELECTOR,
10+
"#cookie-control-dialog button.g-button.outline",
11+
)
812

913

10-
class TrackLocator:
11-
PLAY_BUTTON = (By.CSS_SELECTOR, "a")
12-
ALBUM = (By.CLASS_NAME, "item-title")
13-
GENRE = (By.CLASS_NAME, "item-genre")
14-
ARTIST = (By.CLASS_NAME, "item-artist")
14+
class TrackLocatorMixin:
15+
PLAY_BUTTON = (By.CSS_SELECTOR, "button.play-pause-button")
16+
URL = (By.CSS_SELECTOR, "div.meta p a")
17+
ALBUM = (By.CSS_SELECTOR, "div.meta p a strong")
18+
GENRE = (By.CSS_SELECTOR, "div.meta p.genre")
19+
ARTIST = (By.CSS_SELECTOR, "div.meta p a span")

0 commit comments

Comments
 (0)