wpn 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wpn/__init__.py ADDED
@@ -0,0 +1,370 @@
1
+ """
2
+ WPN (What's Playing Now) Web Scraper
3
+
4
+ This module provides functionality to scrape song information from the Muzak WPN website.
5
+ It retrieves current and historical song data for various music channels/stations.
6
+
7
+ The WPN class provides methods to:
8
+ - Get a directory of available channels
9
+ - Retrieve the current song playing on a specific channel
10
+ - Get a list of previous songs played on a channel
11
+ - Get a complete list of current and previous songs for a channel
12
+ - Fetch and process all song data for all available channels
13
+
14
+ Usage:
15
+ from wpn import WPN
16
+
17
+ # Create an instance
18
+ wpn = WPN()
19
+
20
+ # Get all available channels
21
+ channels = wpn.channel_list
22
+
23
+ # Get current song on a specific channel
24
+ song, artist = wpn.get_current_song("Channel Name")
25
+
26
+ # Get all songs (current and previous) for a channel
27
+ songs = wpn.get_all_songs("Channel Name")
28
+
29
+ # Get previous songs using negative indices
30
+ recent_song = songs[-1] # Most recently played song (before current)
31
+ two_songs_ago = songs[-2] # Second most recently played song
32
+
33
+ # Get all song data for all channels
34
+ all_data = wpn.get_all_song_data()
35
+ """
36
+
37
+ from importlib.metadata import version
38
+
39
+ __version__ = version("wpn") # Uses installed package metadata
40
+
41
+ import json
42
+ import os
43
+ import re
44
+ from typing import Union
45
+
46
+ import click
47
+ import grequests
48
+ import requests
49
+ from bs4 import BeautifulSoup
50
+ from thefuzz import process
51
+
52
+ BASEADDR = "http://muzakwpn.muzak.com/"
53
+
54
+
55
+ class WPN:
56
+ def __init__(self):
57
+ self.directory = self._get_directory()
58
+ self.song_data = {k: {"url": v} for k, v in self.directory.items()}
59
+ self.channel_list = list(self.directory.keys())
60
+ self.urls = list(self.directory.values())
61
+
62
+ def _get_soup(self, html: str) -> BeautifulSoup:
63
+ """Given html, return a BeautifulSoup object.
64
+
65
+ Args:
66
+ html (str): HTML document
67
+
68
+ Returns:
69
+ BeautifulSoup: Parsable object of the html document.
70
+ """
71
+ return BeautifulSoup(html, "html.parser")
72
+
73
+ def _get_directory(self, sort: bool = True) -> dict[str, dict[str, str]]:
74
+ """Create an up-to-date directory of channels and urls from the WPN website.
75
+
76
+ Args:
77
+ sort (bool, optional): Whether to sort the directory. Defaults to True.
78
+
79
+ Returns:
80
+ dict[str,str]: The directory of channel keys matched with the channel url.
81
+ """
82
+ html = requests.get(BASEADDR).text
83
+ soup = self._get_soup(html)
84
+ crumblinks = soup.find_all(class_="crumblink")
85
+ wpnaddr = re.compile(r"wpn/...\.html")
86
+ self.directory = {
87
+ crumblink.text: os.path.join(
88
+ BASEADDR, wpnaddr.findall(crumblink["onclick"])[0]
89
+ )
90
+ for crumblink in crumblinks[1:]
91
+ }
92
+ if sort:
93
+ self.directory = dict(
94
+ sorted(self.directory.items(), key=lambda item: item[0])
95
+ )
96
+ return self.directory
97
+
98
+ def _split_song(self, song: str) -> tuple[str, str]:
99
+ """Given an artist and song as a string, convert it into a tuple.
100
+
101
+ Args:
102
+ song (str): A string containing song and artist information.
103
+
104
+ Returns:
105
+ tuple[str, str]: A tuple containing (song, artist).
106
+ """
107
+ # Check if song is a Tag object from BeautifulSoup
108
+ if hasattr(song, "text"):
109
+ text = song.text.strip()
110
+ else:
111
+ text = str(song).strip()
112
+
113
+ # Split by ", by " to separate song and artist
114
+ parts = text.split(", by ", 1)
115
+ if len(parts) > 1:
116
+ return (parts[0].strip(), parts[1].strip())
117
+ else:
118
+ return (parts[0].strip(), "Unknown Artist")
119
+
120
+ def _get_song_list_from_html(self, html: str) -> tuple[str, list[tuple[str, str]]]:
121
+ """Given the html for a channel, generate a song list.
122
+
123
+ Args:
124
+ html (str): HTML document for a channel URL
125
+
126
+ Returns:
127
+ tuple[str, list[tuple[str, str]]]: A tuple containing the channel name and
128
+ a list of tuples containing (song, artist) info.
129
+ Index 0 is current song, negative indices refer
130
+ to previous songs chronologically (-1 is most recent).
131
+ """
132
+ soup = self._get_soup(html)
133
+ all_channel_data = soup.find(id="titles")
134
+ # get the channel name
135
+ channel_name = all_channel_data.find("p").find("b").text.replace("Now on ", "")
136
+ # get first song (current song)
137
+ current_song = self._split_song(list(all_channel_data.children)[0].contents[-1])
138
+ # list for previous songs that will be reversed
139
+ previous_songs = []
140
+
141
+ # try to add additional songs
142
+ try:
143
+ # add second song to previous_songs
144
+ previous_songs.append(self._split_song(list(all_channel_data.children)[2]))
145
+ # add songs 3-10 to previous_songs
146
+ previous_songs.extend(
147
+ self._split_song(song)
148
+ for song in list(all_channel_data.children)[3]
149
+ if len(song.text) > 1
150
+ )
151
+ # reverse previous_songs so that most recent is last (for negative indexing)
152
+ previous_songs.reverse()
153
+ # combine current song with reversed previous songs
154
+ song_list = [current_song] + previous_songs
155
+ return channel_name, song_list
156
+ except IndexError:
157
+ return channel_name, [current_song]
158
+
159
+ def _get_all_channels(self, urls: list[str]) -> list[requests.models.Response]:
160
+ """Given a list of URLs, perform simultaneous GET requests on those and return
161
+ the data as a list.
162
+
163
+ Args:
164
+ urls (list[str]): A list of URLs.
165
+
166
+ Returns:
167
+ list[requests.models.Response]: A list of requests Response objects from the URLs.
168
+ """
169
+ reqs = (grequests.get(url) for url in urls)
170
+ web_data = grequests.map(reqs)
171
+ return web_data
172
+
173
+ def get_channel_name(self, channel_input: Union[int, str]) -> str:
174
+ """Filter input to match a valid channel name. Or, accept an integer to get a
175
+ channel by index.
176
+
177
+ Args:
178
+ channel_input (Union[int, str]): User input for the channel.
179
+
180
+ Returns:
181
+ str: A valid channel name from the WPN website.
182
+ """
183
+ if channel_input in self.channel_list:
184
+ return channel_input
185
+ elif isinstance(channel_input, int):
186
+ return self.channel_list[channel_input]
187
+ else:
188
+ return process.extractOne(channel_input, self.channel_list)[0]
189
+
190
+ def get_all_song_data(self) -> dict:
191
+ """Generate the song data for all songs currently playing.
192
+
193
+ Returns:
194
+ dict: A dictionary of all the WPN data.
195
+ """
196
+ # get the webdata
197
+ web_data = self._get_all_channels(self.urls)
198
+ # cycle through the responses, extract the channel name and song list,
199
+ # and add to the song_data
200
+ for channel in web_data:
201
+ channel_name, song_list = self._get_song_list_from_html(channel.text)
202
+ channel_name = self.get_channel_name(channel_name)
203
+ self.song_data[channel_name].update({"song_list": song_list})
204
+ return self.song_data
205
+
206
+ def get_current_song(self, channel_input: Union[str, int]) -> tuple[str, str]:
207
+ """Given a channel name or index, get the current song and artist.
208
+
209
+ Args:
210
+ channel_input (Union[str, int]): The name of the channel, or index number.
211
+
212
+ Returns:
213
+ tuple[str, str]: The current song as a tuple (song, artist).
214
+ """
215
+ channel_name = self.get_channel_name(channel_input)
216
+ song_data = self.get_all_song_data()
217
+ return song_data[channel_name]["song_list"][0]
218
+
219
+ def get_previous_songs(
220
+ self, channel_input: Union[str, int]
221
+ ) -> list[tuple[str, str]]:
222
+ """Given a channel name or index, get the previous songs and artists.
223
+
224
+ Args:
225
+ channel_input (Union[str, int]): The name of the channel, or index number.
226
+
227
+ Returns:
228
+ list[tuple[str, str]]: A list of previous songs as tuples (song, artist).
229
+ Ordered from oldest to most recent.
230
+ """
231
+ channel_name = self.get_channel_name(channel_input)
232
+ song_data = self.get_all_song_data()
233
+ # Return all songs except the current one (index 0), in the order they appear
234
+ return song_data[channel_name]["song_list"][1:]
235
+
236
+ def get_all_songs(self, channel_input: Union[str, int]) -> list[tuple[str, str]]:
237
+ """Given a channel name or index, get current and previous songs and artists.
238
+
239
+ Args:
240
+ channel_input (Union[str, int]): The name of the channel, or index number.
241
+
242
+ Returns:
243
+ list[tuple[str, str]]: A list of current and previous songs
244
+ as tuples (song, artist).
245
+ """
246
+ channel_name = self.get_channel_name(channel_input)
247
+ song_data = self.get_all_song_data()
248
+ return song_data[channel_name]["song_list"]
249
+
250
+
251
+ @click.group()
252
+ def cli():
253
+ """Command-line interface for the WPN (What's Playing Now) web scraper."""
254
+ pass
255
+
256
+
257
+ @cli.command(
258
+ "all-data", help="Get all song data for all channels and save to a JSON file."
259
+ )
260
+ @click.option(
261
+ "--output",
262
+ "-o",
263
+ type=str,
264
+ default="output/output.json",
265
+ help="Path to save the JSON output file",
266
+ )
267
+ def all_data(output):
268
+ """Get all song data for all channels and save to a JSON file."""
269
+ try:
270
+ wpn = WPN()
271
+ data = wpn.get_all_song_data()
272
+
273
+ # Ensure output directory exists
274
+ os.makedirs(os.path.dirname(output), exist_ok=True)
275
+
276
+ with open(output, "w", encoding="UTF-8") as f:
277
+ json.dump(data, f, indent=2)
278
+
279
+ click.echo(f"Data saved to {output} with {len(data)} channels")
280
+ except Exception as e:
281
+ click.echo(f"Error: {str(e)}", err=True)
282
+
283
+
284
+ @cli.command("list", help="List all available music channels.")
285
+ def list_channels():
286
+ """List all available music channels."""
287
+ wpn = WPN()
288
+ channels = wpn.channel_list
289
+ click.echo(f"Available channels ({len(channels)}):")
290
+ for i, channel in enumerate(channels):
291
+ click.echo(f"{i}: {channel}")
292
+
293
+
294
+ @cli.command("songs", help="Get all songs (current and previous) for a channel.")
295
+ @click.argument("channel", type=str)
296
+ def all_songs(channel):
297
+ """Get all songs (current and previous) for a channel.
298
+
299
+ CHANNEL can be the exact channel name, a partial name that will be matched,
300
+ or an index number from the list command.
301
+ """
302
+ wpn = WPN()
303
+ try:
304
+ # Handle integer input
305
+ if channel.isdigit():
306
+ channel = int(channel)
307
+
308
+ channel_name = wpn.get_channel_name(channel)
309
+ songs = wpn.get_all_songs(channel)
310
+ click.echo(f"All songs on {channel_name}:")
311
+ click.echo(f"Currently playing: {songs[0][0]} by {songs[0][1]}")
312
+ if len(songs) > 1:
313
+ click.echo("Previously played (most recent first):")
314
+ # Show previous songs in reverse order (most recent first)
315
+ for i, (song, artist) in enumerate(reversed(songs[1:]), 1):
316
+ click.echo(f"{i}. {song} by {artist}")
317
+ except Exception as e:
318
+ click.echo(f"Error: {str(e)}", err=True)
319
+
320
+
321
+ @cli.command("current", help="Get the current song playing on a specific channel.")
322
+ @click.argument("channel", type=str)
323
+ def current_song(channel):
324
+ """Get the current song playing on a specific channel.
325
+
326
+ CHANNEL can be the exact channel name, a partial name that will be matched,
327
+ or an index number from the list command.
328
+ """
329
+ wpn = WPN()
330
+ try:
331
+ # Handle integer input
332
+ if channel.isdigit():
333
+ channel = int(channel)
334
+
335
+ channel_name = wpn.get_channel_name(channel)
336
+ song, artist = wpn.get_current_song(channel)
337
+ click.echo(f"Channel: {channel_name}")
338
+ click.echo(f"Now playing: {song} by {artist}")
339
+ except Exception as e:
340
+ click.echo(f"Error: {str(e)}", err=True)
341
+
342
+
343
+ @cli.command("previous", help="Get a list of previous songs played on a channel.")
344
+ @click.argument("channel", type=str)
345
+ def previous_songs(channel):
346
+ """Get a list of previous songs played on a channel.
347
+
348
+ CHANNEL can be the exact channel name, a partial name that will be matched,
349
+ or an index number from the list command.
350
+ """
351
+ wpn = WPN()
352
+ try:
353
+ # Handle integer input
354
+ if channel.isdigit():
355
+ channel = int(channel)
356
+
357
+ channel_name = wpn.get_channel_name(channel)
358
+ songs = wpn.get_previous_songs(channel)
359
+ click.echo(f"Previous songs on {channel_name} (most recent first):")
360
+
361
+ # Reverse the order to show most recent first
362
+ songs_to_display = list(reversed(songs))
363
+ for i, (song, artist) in enumerate(songs_to_display, 1):
364
+ click.echo(f"{i}. {song} by {artist}")
365
+ except Exception as e:
366
+ click.echo(f"Error: {str(e)}", err=True)
367
+
368
+
369
+ if __name__ == "__main__":
370
+ cli()
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: wpn
3
+ Version: 0.1.0
4
+ Summary: A Python library for scraping and retrieving song information from the Muzak WPN (What's Playing Now) website. This tool allows you to get current and historical song data for various music channels/stations.
5
+ Author-email: Lance Reinsmith <info@k2rad.com>
6
+ License: MIT
7
+ Keywords: music,song,scraping,wpn,muzak
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Topic :: Software Development :: Build Tools
14
+ Requires-Python: >=3.12
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: beautifulsoup4
17
+ Requires-Dist: click
18
+ Requires-Dist: grequests
19
+ Requires-Dist: python-Levenshtein
20
+ Requires-Dist: requests
21
+ Requires-Dist: thefuzz
22
+
23
+ # WPN - What's Playing Now
24
+
25
+ A Python library for scraping and retrieving song information from the Muzak WPN (What's Playing Now) website. This tool allows you to get current and historical song data for various music channels/stations.
26
+
27
+ ## Features
28
+
29
+ - Get a directory of available music channels
30
+ - Retrieve the current song playing on a specific channel
31
+ - Get a list of previous songs played on a channel
32
+ - Get a complete list of current and previous songs for a channel
33
+ - Fetch and process all song data for all available channels
34
+ - Export song data to JSON format
35
+ - Command-line interface for all functionality
36
+
37
+ ## Installation
38
+
39
+ ### Requirements
40
+
41
+ - Python 3.12 or higher
42
+ - Dependencies are specified in `pyproject.toml`
43
+
44
+ ### Installation steps
45
+
46
+ 1. Clone this repository:
47
+ ```
48
+ git clone https://github.com/lancereinsmith/wpn.git
49
+ cd wpn
50
+ ```
51
+
52
+ 2. Install using `uv` (recommended):
53
+ ```
54
+ pip install uv
55
+ uv venv
56
+ uv pip install -e .
57
+ ```
58
+
59
+ 3. Alternatively, you can use a standard venv:
60
+ ```
61
+ python -m venv .venv
62
+ source .venv/bin/activate # On Windows, use `.venv\Scripts\activate`
63
+ pip install -e .
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ ### Python API Usage
69
+
70
+ ```python
71
+ from wpn import WPN
72
+
73
+ # Create an instance
74
+ wpn = WPN()
75
+
76
+ # Get all available channels
77
+ channels = wpn.channel_list
78
+ print(f"Available channels: {len(channels)}")
79
+
80
+ # Get current song on a specific channel (by name or index)
81
+ song, artist = wpn.get_current_song("Channel Name") # Using channel name
82
+ # Or
83
+ song, artist = wpn.get_current_song(5) # Using channel index
84
+ print(f"Now playing: {song} by {artist}")
85
+
86
+ # Get all songs (current and previous) for a channel
87
+ songs = wpn.get_all_songs("Channel Name") # Using channel name
88
+ # Or
89
+ songs = wpn.get_all_songs(5) # Using channel index
90
+
91
+ # Current song is at index 0
92
+ current_song, current_artist = songs[0]
93
+ print(f"Now playing: {current_song} by {current_artist}")
94
+
95
+ # Previous songs can be accessed using negative indices
96
+ # Most recent previous song
97
+ if len(songs) > 1:
98
+ prev_song, prev_artist = songs[-1]
99
+ print(f"Previously played: {prev_song} by {prev_artist}")
100
+
101
+ # Second most recent previous song
102
+ if len(songs) > 2:
103
+ older_song, older_artist = songs[-2]
104
+ print(f"Before that: {older_song} by {older_artist}")
105
+
106
+ # Or iterate through all songs
107
+ for i, (song, artist) in enumerate(songs):
108
+ if i == 0:
109
+ print(f"Current: {song} by {artist}")
110
+ else:
111
+ print(f"Previous ({len(songs)-i}): {song} by {artist}")
112
+
113
+ # Get previous songs only (by name or index)
114
+ previous_songs = wpn.get_previous_songs("Channel Name") # Using channel name
115
+ # Or
116
+ previous_songs = wpn.get_previous_songs(5) # Using channel index
117
+
118
+ # Get all song data for all channels
119
+ all_data = wpn.get_all_song_data()
120
+ ```
121
+
122
+ ### Command-Line Interface
123
+
124
+ WPN provides a comprehensive command-line interface for accessing all functionality.
125
+
126
+ After installing the package, the `wpn` command will be available:
127
+
128
+ ```
129
+ wpn --help
130
+ ```
131
+
132
+ #### List all available channels
133
+
134
+ ```
135
+ wpn list
136
+ ```
137
+
138
+ #### Get the current song playing on a channel
139
+
140
+ ```
141
+ wpn current "Channel Name"
142
+ # Or using channel index
143
+ wpn current 5
144
+ ```
145
+
146
+ #### Get previous songs played on a channel
147
+
148
+ ```
149
+ wpn previous "Channel Name"
150
+ # Or using channel index
151
+ wpn previous 5
152
+ ```
153
+
154
+ This will display a list of previously played songs from most recent to least recent.
155
+
156
+ #### Get all songs (current and previous) for a channel
157
+
158
+ ```
159
+ wpn songs "Channel Name"
160
+ # Or using channel index
161
+ wpn songs 5
162
+ ```
163
+
164
+ #### Get all song data for all channels
165
+
166
+ ```
167
+ wpn all-data
168
+ # Or specify custom output path
169
+ wpn all-data --output data.json
170
+ ```
171
+
172
+ ## API Reference
173
+
174
+ ### WPN Class
175
+
176
+ #### `__init__()`
177
+ Initialize the WPN scraper with an up-to-date directory of channels.
178
+
179
+ #### `get_directory(sort=True)`
180
+ Create an up-to-date directory of channels and URLs from the WPN website.
181
+
182
+ #### `get_channel_name(channel_input)`
183
+ Filter input to match a valid channel name or accept an integer to get a channel by index.
184
+
185
+ #### `get_current_song(channel_input)`
186
+ Get the current song playing on a specified channel, returned as a tuple of (song, artist).
187
+ The `channel_input` can be either a string (channel name) or an integer (channel index).
188
+
189
+ #### `get_previous_songs(channel_input)`
190
+ Get a list of previous songs played on a channel as a list of (song, artist) tuples, ordered from oldest to most recent.
191
+ The `channel_input` can be either a string (channel name) or an integer (channel index).
192
+
193
+ #### `get_all_songs(channel_input)`
194
+ Get a list of current and previous songs for a channel as a list of (song, artist) tuples. The list is structured so that:
195
+ - Index 0 contains the current song
196
+ - Negative indices can be used to access previous songs chronologically
197
+ - songs[-1] is the most recently played previous song
198
+ - songs[-2] is the second most recently played previous song, and so on
199
+
200
+ The `channel_input` can be either a string (channel name) or an integer (channel index).
201
+
202
+ #### `get_all_song_data()`
203
+ Generate the song data for all songs currently playing on all channels.
204
+
205
+ ## Project Structure
206
+
207
+ ```wpn/
208
+ ├── src/
209
+ │ └── wpn/
210
+ │ ├── __init__.py
211
+ │ └── wpn.py
212
+ ├── tests/
213
+ │ └── test_wpn.py
214
+ ├── pyproject.toml
215
+ └── README.md
216
+ ```
217
+
218
+ ## Testing
219
+
220
+ Run tests using pytest:
221
+
222
+ ```
223
+ python -m pytest
224
+ ```
225
+
226
+ ## License
227
+
228
+ MIT License
229
+
230
+ ## Contributing
231
+
232
+ Contributions are welcome! Please feel free to submit a Pull Request.
233
+
@@ -0,0 +1,6 @@
1
+ wpn/__init__.py,sha256=rlnVZ8ZmInu-WUfRJG-3Jir4blCyfmMvLJGIYPrdTpo,13102
2
+ wpn-0.1.0.dist-info/METADATA,sha256=IOcwa2yYhLwK5jsl8fAjdjNQddtRfMCGuB0cPFP-YYE,6280
3
+ wpn-0.1.0.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
4
+ wpn-0.1.0.dist-info/entry_points.txt,sha256=9oE4n_2iK_9UwuuQfXdb5dDkxixtcBx9GY-HvMPFW9s,32
5
+ wpn-0.1.0.dist-info/top_level.txt,sha256=6bAm5Qnt2Gf5N_KBlOIPxo9fVlLRQKHKEAx2QX0CHYQ,4
6
+ wpn-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (79.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wpn = wpn:cli
@@ -0,0 +1 @@
1
+ wpn