From d3d141dc88ab258ccfdb8400ec56f6892bf4eb4e Mon Sep 17 00:00:00 2001 From: Matthew Hickson Date: Sun, 24 Nov 2024 18:14:18 -0500 Subject: [PATCH] added docstrings to project --- tuipod/models/episode.py | 20 ++++++++++++++++++++ tuipod/models/player.py | 6 ++++++ tuipod/models/podcast.py | 18 ++++++++++++++++++ tuipod/models/search.py | 10 ++++++++++ tuipod/models/subscription_list.py | 8 ++++++++ tuipod/ui/about_info.py | 10 ++++++++-- tuipod/ui/episode_info.py | 8 ++++++++ tuipod/ui/episode_list.py | 5 +++++ tuipod/ui/error_info.py | 8 ++++++++ tuipod/ui/podcast_app.py | 28 ++++++++++++++++++++++++++++ tuipod/ui/podcast_list.py | 5 +++++ tuipod/ui/podcast_player.py | 9 +++++++++ tuipod/ui/search_input.py | 4 ++++ 13 files changed, 137 insertions(+), 2 deletions(-) diff --git a/tuipod/models/episode.py b/tuipod/models/episode.py index 2afafe9..38e9817 100644 --- a/tuipod/models/episode.py +++ b/tuipod/models/episode.py @@ -7,8 +7,16 @@ #import playsound3 class Episode: + """ + A minimal representation of a podcast episode. + + It also handles playback of the episode (at the moment). + + FIX: extract play functionality from model. + """ def __init__(self, title: str, url: str, description: str, pubdate: str, duration: int) -> None: + """initialize episode with title, url, description, pubdate and duration""" self.id = uuid.uuid4().hex self.title = title self.url = url @@ -22,15 +30,22 @@ def __init__(self, title: str, url: str, description: str, pubdate: str, duratio self.is_playing = False def __lt__(self, other): + """'less than' support to allow episode sorting""" if self.pubdate != other.pubdate: return self.pubdate < other.pubdate else: return self.title < other.title def is_playing(self) -> bool: + """track whether episode is playing or not""" return self.is_playing() def play_episode(self): + """ + play the episode audio directly from its internet source + + TODO: extract this, and add ability to track playback (and ideally switch from internet play to cached play from disk, once file is downloaded) + """ if not self.device is None: self.device.start(self.stream) else: @@ -42,5 +57,10 @@ def play_episode(self): self.is_playing = True def stop_episode(self): + """ + stop episode from playing + + NOTE: more like pausing, since we don't close/destroy the associated playback device. + """ self.device.stop() self.is_playing = False diff --git a/tuipod/models/player.py b/tuipod/models/player.py index 03afacc..a3c67e9 100644 --- a/tuipod/models/player.py +++ b/tuipod/models/player.py @@ -1,7 +1,13 @@ from tuipod.models.episode import Episode class Player: + """ + A minimal, abstract player stub for the podcast player. + + TODO: flesh this out... + """ def __init__(self, episode: Episode, position_seconds: int) -> None: + """initialize a podcast player with the episode to play, and the current position/offset within the episode in seconds""" self.episode = episode self.position_seconds = position_seconds diff --git a/tuipod/models/podcast.py b/tuipod/models/podcast.py index c56b044..ee0a154 100644 --- a/tuipod/models/podcast.py +++ b/tuipod/models/podcast.py @@ -6,8 +6,16 @@ from tuipod.models.episode import Episode class Podcast: + """ + A minimal representation of a podcast. + + It also handles retrieval of episodes from the podcast feed (at the moment). + + FIX: extract episode retrieval into a more appropriate spot. + """ def __init__(self, title: str, url: str, description: str) -> None: + """initialize a podcast with title, url, and description""" self.id = uuid.uuid4().hex self.title = title self.url = url @@ -16,18 +24,28 @@ def __init__(self, title: str, url: str, description: str) -> None: self.subscribed = False def __lt__(self, other): + """'less than' support to allow podcast sorting""" return self.title < other.title def add_episode(self, episode: Episode) -> None: + """add an episode to the podcast""" self.episodes.append(episode) def remove_episode(self, url: str) -> None: + """remove an episode from the podcast based on the episode URL""" for e in self.episodes: if e.url == url: self.episodes.remove(e) break def get_episode_list(self) -> []: + """ + Get an episode list from the podcast feed. + + Works around missing data in a tested, but haphazard, manner. + + TODO: put this someplace more appropriate. + """ with urllib.request.urlopen(self.url) as response: result = response.read() diff --git a/tuipod/models/search.py b/tuipod/models/search.py index 2b4f5fe..165cf5c 100644 --- a/tuipod/models/search.py +++ b/tuipod/models/search.py @@ -5,16 +5,25 @@ from tuipod.models.podcast import Podcast class Search: + """ + A barebones search hooked up to the iTunes podcast search service. + + TODO: abstract and add additional search providers (e.g. gpodder.net, Spotify, YouTube, etc.) + """ + ENDPOINT = "https://itunes.apple.com/search" def __init__(self, search_text: str) -> None: + """initialize search with text to find""" self.search_text = search_text self.cached_results = [] def get_cached_search_results(self) -> []: + """pull search results from cache""" return self.cached_results def get_search_results(self) -> []: + """reach out to provider (iTunes) and retrieve search results""" results = [] data = {"media": "podcast", "entity": "podcast", "term": self.search_text} @@ -41,6 +50,7 @@ def get_search_results(self) -> []: return results async def search(self, search_text: str) -> []: + """based on the search_text supplied, either get fresh results, or pull from cache (if same search is conducted)""" if self.search_text == search_text: return self.get_cached_search_results() else: diff --git a/tuipod/models/subscription_list.py b/tuipod/models/subscription_list.py index 39df5ff..a5b3566 100644 --- a/tuipod/models/subscription_list.py +++ b/tuipod/models/subscription_list.py @@ -5,17 +5,23 @@ from tuipod.models.podcast import Podcast class SubscriptionList: + """ + A simple subscription list saved/loaded from a fixed OPML file (subscriptions.opml). + """ SUBSCRIPTION_FILE = "subscriptions.opml" def __init__(self) -> None: + """initialize the subscription list""" self.podcasts = [] def add_podcast(self, p: Podcast) -> None: + """add a podcast subscription""" p.subscribed = True self.podcasts.append(p) def remove_podcast(self, url: str) -> None: + """remove a subscribed podcast by URL""" for p in self.podcasts: if p.url == url: p.subscribed = False @@ -23,6 +29,7 @@ def remove_podcast(self, url: str) -> None: break def retrieve(self) -> []: + """load subscriptions from disk (if the subscription file exists)""" self.podcasts = [] if exists(self.SUBSCRIPTION_FILE): @@ -41,6 +48,7 @@ def retrieve(self) -> []: self.add_podcast(p) def persist(self) -> None: + """save the current subscription list to disk""" with open(self.SUBSCRIPTION_FILE, "wt", encoding="utf-8") as subscription_file: lines = [] lines.append('\n') diff --git a/tuipod/ui/about_info.py b/tuipod/ui/about_info.py index 95a7c60..9755e3b 100644 --- a/tuipod/ui/about_info.py +++ b/tuipod/ui/about_info.py @@ -5,6 +5,10 @@ class AboutInfoScreen(ModalScreen): + """ + An about/help modal screen for tuipod. + """ + BINDINGS = [ ("escape", "close_modal", "Close modal") ] @@ -77,13 +81,13 @@ class AboutInfoScreen(ModalScreen): NOTE: Some keystrokes depend on application state (e.g. not actively searching, episode playing, etc.) """ -# TODO: - `S` - subscribe to the current podcast - def __init__(self) -> None: + """initialize the about/help screen""" super().__init__() self.detail = self.ABOUT_INFO def compose(self) -> ComposeResult: + """build the about/help screen""" yield Container( Static("About", id="modalTitle"), Container( @@ -98,8 +102,10 @@ def compose(self) -> ComposeResult: ) def on_button_pressed(self, event: Button.Pressed) -> None: + """handle button presses (currently just the close button)""" if event.button.id == "closeInfoButton": self.app.pop_screen() def action_close_modal(self) -> None: + """close the modal""" self.app.pop_screen() diff --git a/tuipod/ui/episode_info.py b/tuipod/ui/episode_info.py index fd6b6c2..c0c83d2 100644 --- a/tuipod/ui/episode_info.py +++ b/tuipod/ui/episode_info.py @@ -5,6 +5,10 @@ class EpisodeInfoScreen(ModalScreen): + """ + An episode information modal screen for tuipod. + """ + BINDINGS = [ ("escape", "close_modal", "Close modal") ] @@ -61,12 +65,14 @@ class EpisodeInfoScreen(ModalScreen): """ def __init__(self, title: str, url: str, detail: str) -> None: + """initialize the episode information screen""" super().__init__() self.title = title self.url = url self.detail = detail def compose(self) -> ComposeResult: + """build the episode information screen""" yield Container( Static("Episode Information", id="modalTitle"), Container( @@ -83,8 +89,10 @@ def compose(self) -> ComposeResult: ) def on_button_pressed(self, event: Button.Pressed) -> None: + """handle button presses (currently just the close button)""" if event.button.id == "closeInfoButton": self.app.pop_screen() def action_close_modal(self) -> None: + """close the modal""" self.app.pop_screen() diff --git a/tuipod/ui/episode_list.py b/tuipod/ui/episode_list.py index 9e760a2..5a8cc1e 100644 --- a/tuipod/ui/episode_list.py +++ b/tuipod/ui/episode_list.py @@ -3,6 +3,9 @@ from textual.widgets import DataTable class EpisodeList(Widget): + """ + A simple episode list widget using a datatable for columnar display. + """ DEFAULT_CSS = """ EpisodeList { @@ -11,9 +14,11 @@ class EpisodeList(Widget): """ def compose(self) -> ComposeResult: + """build the widget""" yield DataTable(id="EpisodeList", cursor_type="row", zebra_stripes=True) def on_mount(self): + """set up the columns on mount""" table: DataTable = self.query_one("#EpisodeList") table.add_column("Episode Title") table.add_column("Duration") diff --git a/tuipod/ui/error_info.py b/tuipod/ui/error_info.py index 2992df3..8226ac9 100644 --- a/tuipod/ui/error_info.py +++ b/tuipod/ui/error_info.py @@ -5,6 +5,10 @@ class ErrorInfoScreen(ModalScreen): + """ + A generic error display modal screen for tuipod. + """ + BINDINGS = [ ("escape", "close_modal", "Close modal") ] @@ -51,10 +55,12 @@ class ErrorInfoScreen(ModalScreen): """ def __init__(self, detail: str) -> None: + """initialize the error screen""" super().__init__() self.detail = detail def compose(self) -> ComposeResult: + """build the error screen""" yield Container( Static("Error Information", id="modalTitle"), Container( @@ -69,8 +75,10 @@ def compose(self) -> ComposeResult: ) def on_button_pressed(self, event: Button.Pressed) -> None: + """handle button presses (currently just the close button)""" if event.button.id == "closeInfoButton": self.app.pop_screen() def action_close_modal(self) -> None: + """close the modal""" self.app.pop_screen() diff --git a/tuipod/ui/podcast_app.py b/tuipod/ui/podcast_app.py index cab065b..e8f6ecc 100644 --- a/tuipod/ui/podcast_app.py +++ b/tuipod/ui/podcast_app.py @@ -27,6 +27,10 @@ APPLICATION_VERSION = "2024-11-24.6147baed9d02462989c1d8cc65b87af5-beta" class PodcastApp(App): + """ + A podcast player application (tuipod) utilizing textual. + """ + BINDINGS = [ Binding("f1", "display_about", "Display about information", priority=True), Binding("space", "toggle_play", "Play/Pause"), @@ -39,6 +43,11 @@ class PodcastApp(App): SUB_TITLE = "version {0}".format(APPLICATION_VERSION) def __init__(self): + """ + initialize the application + + NOTE: sets a User-Agent override for urllib.openurl(), and multimedia key support. + """ super().__init__() self.searcher = Search("") self.subscriptions = SubscriptionList() @@ -57,6 +66,7 @@ def __init__(self): self.keylistener.start() def compose(self) -> ComposeResult: + """build the app""" yield Header(icon="#", show_clock=True, time_format="%I:%M %p") yield SearchInput() @@ -65,11 +75,13 @@ def compose(self) -> ComposeResult: yield PodcastPlayer() async def on_mount(self) -> None: + """set up application, including listing current subscriptions""" self.subscriptions.retrieve() if len(self.subscriptions.podcasts) > 0: await self._refresh_podcast_list("") async def _refresh_podcast_list(self, search_term: str) -> None: + """refresh the podcast list, subscriptions first then search results""" podcast_list = self.query_one(PodcastList) episode_list = self.query_one(EpisodeList) @@ -111,12 +123,14 @@ async def _refresh_podcast_list(self, search_term: str) -> None: @on(Input.Submitted) async def action_submit(self, event: Input.Submitted) -> None: + """perform the search for the criteria entered in the search widget""" search_input: Input = event.input search_term = search_input.value self.notify("searching for: {0}".format(search_term), timeout=3) await self._refresh_podcast_list(search_term) def _set_player_button_status(self, mode: str): + """set the visual status of the play button in the podcast player widget""" player: PodcastPlayer = self.query_one(PodcastPlayer) play_button: Button = player.query_one("#playButton") @@ -134,6 +148,7 @@ def _set_player_button_status(self, mode: str): play_button.styles.color = "white" def _action_podcast_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """keep track of the highlighted podcast row for easy reference""" k = event.row_key if not k is None: v = k.value @@ -146,6 +161,7 @@ def _action_podcast_row_highlighted(self, event: DataTable.RowHighlighted) -> No break def _action_episode_row_highlighted(self, event: DataTable.RowSelected) -> None: + """keep track of the highlighted episode row for easy reference""" k = event.row_key if not k is None: v = k.value @@ -158,6 +174,7 @@ def _action_episode_row_highlighted(self, event: DataTable.RowSelected) -> None: break def _action_podcast_row_selected(self, event: DataTable.RowSelected) -> None: + """select the currently highlighted podcast, and retrieve its episode list""" self.notify("getting episodes for: {0}".format(self.current_podcast.title), timeout=3) episode_list = self.query_one(EpisodeList) @@ -179,6 +196,7 @@ def _action_podcast_row_selected(self, event: DataTable.RowSelected) -> None: table.loading = False def _action_episode_row_selected(self, event: DataTable.RowSelected) -> None: + """select the currently highlighted episode, and begin playing the episode""" try: playing_episode = self.current_episode @@ -197,6 +215,7 @@ def _action_episode_row_selected(self, event: DataTable.RowSelected) -> None: @on(DataTable.RowHighlighted) def action_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """general row highlighting handler""" if event.data_table.id == "PodcastList": self._action_podcast_row_highlighted(event) elif event.data_table.id == "EpisodeList": @@ -204,15 +223,18 @@ def action_row_highlighted(self, event: DataTable.RowHighlighted) -> None: @on(DataTable.RowSelected) def action_row_selected(self, event: DataTable.RowSelected) -> None: + """general row selection handler""" if event.data_table.id == "PodcastList": self._action_podcast_row_selected(event) elif event.data_table.id == "EpisodeList": self._action_episode_row_selected(event) def action_toggle_dark(self) -> None: + """toggle between dark and light themes""" self.dark = not self.dark def action_toggle_play(self) -> None: + """toggle between playing or pausing an active episode""" if not self.current_episode is None: if self.current_episode.is_playing: self.current_episode.stop_episode() @@ -224,16 +246,20 @@ def action_toggle_play(self) -> None: self.notify("playing: {0}".format(self.current_episode.title), timeout=3) def action_display_about(self) -> None: + """display the about/help screen""" self.app.push_screen(AboutInfoScreen()) def action_display_info(self) -> None: + """display the episode information screen for the active podcast""" if not self.current_episode is None: self.app.push_screen(EpisodeInfoScreen(self.current_episode.title, self.current_episode.url, self.current_episode.description)) def action_quit_application(self) -> None: + """quit the application""" self.exit() async def action_subscribe_to_podcast(self) -> None: + """record the subscription of the active podcast""" if not self.current_podcast is None: self.subscriptions.add_podcast(self.current_podcast) self.subscriptions.persist() @@ -241,6 +267,7 @@ async def action_subscribe_to_podcast(self) -> None: self.notify("subscribed to: {0}".format(self.current_podcast.title), timeout=3) async def action_unsubscribe_from_podcast(self) -> None: + """remove (and thus unsubscribe) from the active podcast""" if not self.current_podcast is None: title = self.current_podcast.title self.subscriptions.remove_podcast(self.current_podcast.url) @@ -249,5 +276,6 @@ async def action_unsubscribe_from_podcast(self) -> None: self.notify("unsubscribed from: {0}".format(title), timeout=3) def on_listen_keys(self, key) -> None: + """listen for the media play/pause keypress to complete""" if key == keyboard.Key.media_play_pause: self.action_toggle_play() \ No newline at end of file diff --git a/tuipod/ui/podcast_list.py b/tuipod/ui/podcast_list.py index ff9117a..1e69bae 100644 --- a/tuipod/ui/podcast_list.py +++ b/tuipod/ui/podcast_list.py @@ -3,6 +3,9 @@ from textual.widgets import DataTable class PodcastList(Widget): + """ + A simple podcast list widget using a datatable for columnar display. + """ DEFAULT_CSS = """ PodcastList { @@ -11,9 +14,11 @@ class PodcastList(Widget): """ def compose(self) -> ComposeResult: + """build the widget""" yield DataTable(id="PodcastList", cursor_type="row", zebra_stripes=True) def on_mount(self): + """set up the columns on mount""" table: DataTable = self.query_one("#PodcastList") table.add_column("Status") table.add_column("Podcast Title") diff --git a/tuipod/ui/podcast_player.py b/tuipod/ui/podcast_player.py index 196c002..982f046 100644 --- a/tuipod/ui/podcast_player.py +++ b/tuipod/ui/podcast_player.py @@ -3,6 +3,9 @@ from textual.widgets import Button, Static class PodcastPlayer(Widget): + """ + A super simple player widget for podcast episode playback interaction. + """ DEFAULT_CSS = """ PodcastPlayer { @@ -31,6 +34,12 @@ class PodcastPlayer(Widget): """ def compose(self) -> ComposeResult: + """ + build the widget + + TODO: so very much... + """ + #yield Button(id="backButton", label="back") yield Button(id="playButton", label="play") #yield Button(id="forwardButton", label="forward") diff --git a/tuipod/ui/search_input.py b/tuipod/ui/search_input.py index bd70f5a..8015418 100644 --- a/tuipod/ui/search_input.py +++ b/tuipod/ui/search_input.py @@ -3,6 +3,9 @@ from textual.widgets import Input, Label class SearchInput(Widget): + """ + A simple, but effective search widget for discovering podcasts by search text." + """ DEFAULT_CSS = """ SearchInput { @@ -26,5 +29,6 @@ class SearchInput(Widget): """ def compose(self) -> ComposeResult: + """build the widget""" yield Label("Search", id="searchLabel") yield Input(id="searchInput")