Skip to content

Commit d7874a1

Browse files
Merge pull request #86 from allenhutchison/refactor/remove-is-subscribed-field
refactor: remove legacy Podcast.is_subscribed field
2 parents aa19d62 + 305aa3c commit d7874a1

File tree

11 files changed

+190
-69
lines changed

11 files changed

+190
-69
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""remove_is_subscribed_field
2+
3+
Revision ID: f6a7b8c9d0e1
4+
Revises: e5f6a7b8c9d0
5+
Create Date: 2026-01-16 10:00:00.000000
6+
7+
Remove the legacy is_subscribed field from podcasts table.
8+
Subscription status is now determined by UserSubscription entries.
9+
"""
10+
from typing import Sequence, Union
11+
12+
from alembic import op
13+
import sqlalchemy as sa
14+
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = 'f6a7b8c9d0e1'
18+
down_revision: Union[str, None] = 'e5f6a7b8c9d0'
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
op.drop_column('podcasts', 'is_subscribed')
25+
26+
27+
def downgrade() -> None:
28+
op.add_column(
29+
'podcasts',
30+
sa.Column('is_subscribed', sa.Boolean(), server_default=sa.text('TRUE'), nullable=False)
31+
)

src/cli/podcast_commands.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -221,12 +221,13 @@ def list_podcasts(args, config: Config):
221221
"""
222222
Prints a table of podcasts to stdout.
223223
224-
Displays podcasts from the repository as rows containing ID, title (truncated to 40 characters), episode count, and subscription status. Honors the following fields on `args`: `all` (when true, include unsubscribed podcasts) and `limit` (maximum number of podcasts to list).
224+
Displays podcasts from the repository as rows containing ID, title (truncated to 40 characters),
225+
episode count, and subscriber count. When `args.all` is false, only shows podcasts with subscribers.
225226
226227
Parameters:
227228
args: argparse.Namespace with at least:
228-
- all (bool): If true, include unsubscribed podcasts; otherwise only subscribed podcasts.
229-
- limit (int | None): Maximum number of podcasts to retrieve; when None, the repository default is used.
229+
- all (bool): If true, include all podcasts; otherwise only podcasts with subscribers.
230+
- limit (int | None): Maximum number of podcasts to retrieve; when None, no limit is applied.
230231
"""
231232
repository = create_repository(
232233
database_url=config.DATABASE_URL,
@@ -236,27 +237,31 @@ def list_podcasts(args, config: Config):
236237
)
237238

238239
try:
239-
podcasts = repository.list_podcasts(
240-
subscribed_only=not args.all,
241-
limit=args.limit,
242-
)
240+
if args.all:
241+
podcasts = repository.list_podcasts(limit=args.limit)
242+
else:
243+
podcasts = repository.list_podcasts_with_subscribers(limit=args.limit)
243244

244245
if not podcasts:
245246
print("No podcasts found")
246247
return
247248

248-
print(f"\n{'ID':<36} {'Title':<40} {'Episodes':<10} {'Status'}")
249-
print("-" * 100)
249+
# Get subscriber counts for all podcasts in one query
250+
podcast_ids = [p.id for p in podcasts]
251+
subscriber_counts = repository.get_podcast_subscriber_counts(podcast_ids)
252+
253+
print(f"\n{'ID':<36} {'Title':<40} {'Episodes':<10} {'Subscribers'}")
254+
print("-" * 105)
250255

251256
for podcast in podcasts:
252257
# Get episode count
253258
episodes = repository.list_episodes(podcast_id=podcast.id)
254-
status = "Subscribed" if podcast.is_subscribed else "Unsubscribed"
259+
sub_count = subscriber_counts.get(podcast.id, 0)
255260
print(
256261
f"{podcast.id:<36} "
257262
f"{podcast.title[:40]:<40} "
258263
f"{len(episodes):<10} "
259-
f"{status}"
264+
f"{sub_count}"
260265
)
261266

262267
finally:
@@ -500,7 +505,7 @@ def create_parser() -> argparse.ArgumentParser:
500505
list_parser.add_argument(
501506
"--all",
502507
action="store_true",
503-
help="Include unsubscribed podcasts",
508+
help="Include all podcasts (default: only podcasts with subscribers)",
504509
)
505510
list_parser.add_argument(
506511
"--limit",

src/db/models.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@ class Podcast(Base):
5959
image_url: Mapped[str | None] = mapped_column(String(2048))
6060
image_local_path: Mapped[str | None] = mapped_column(String(1024))
6161

62-
# Subscription management
63-
is_subscribed: Mapped[bool] = mapped_column(Boolean, default=True)
62+
# Feed management
6463
last_checked: Mapped[datetime | None] = mapped_column(DateTime)
6564
last_new_episode: Mapped[datetime | None] = mapped_column(DateTime)
6665
check_frequency_hours: Mapped[int] = mapped_column(Integer, default=24)

src/db/repository.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def create_podcast(self, feed_url: str, title: str, **kwargs) -> Podcast:
6262
Parameters:
6363
feed_url (str): RSS or Atom feed URL of the podcast.
6464
title (str): Display title for the podcast subscription.
65-
**kwargs: Additional Podcast attributes to set (e.g., description, is_subscribed, image_url).
65+
**kwargs: Additional Podcast attributes to set (e.g., description, author, image_url).
6666
6767
Returns:
6868
Podcast: The persisted Podcast instance with updated identifiers and timestamps.
@@ -95,25 +95,22 @@ def get_podcast_by_feed_url(self, feed_url: str) -> Podcast | None:
9595
@abstractmethod
9696
def list_podcasts(
9797
self,
98-
subscribed_only: bool = True,
9998
limit: int | None = None,
10099
sort_by: str = "recency",
101100
sort_order: str = "desc"
102101
) -> list[Podcast]:
103102
"""
104-
Return podcasts optionally filtered to subscribed ones and limited in count.
103+
Return all podcasts with configurable sorting.
105104
106-
Queries podcasts with configurable sorting. If `subscribed_only` is True, only podcasts
107-
with an active subscription are returned. `limit` caps the number of results when provided.
105+
Use list_podcasts_with_subscribers() to get only podcasts with active subscribers.
108106
109107
Parameters:
110-
subscribed_only (bool): If True, include only subscribed podcasts. Default is True.
111108
limit (Optional[int]): Maximum number of podcasts to return; if None, no limit is applied.
112109
sort_by (str): Field to sort by ("recency", "subscribers", "alphabetical"). Default is "recency".
113110
sort_order (str): Sort direction ("asc" or "desc"). Default is "desc".
114111
115112
Returns:
116-
List[Podcast]: Podcasts matching the filters, sorted according to parameters.
113+
List[Podcast]: Podcasts sorted according to parameters.
117114
"""
118115
pass
119116

@@ -124,7 +121,7 @@ def update_podcast(self, podcast_id: str, **kwargs) -> Podcast | None:
124121
125122
Parameters:
126123
podcast_id (str): The podcast's primary key.
127-
**kwargs: Podcast fields to update (for example `title`, `feed_url`, `is_subscribed`).
124+
**kwargs: Podcast fields to update (for example `title`, `feed_url`, `image_url`).
128125
129126
Returns:
130127
Optional[Podcast]: The updated Podcast instance if found and updated, `None` if no podcast with `podcast_id` exists.
@@ -147,6 +144,23 @@ def delete_podcast(self, podcast_id: str, delete_files: bool = False) -> bool:
147144
"""
148145
pass
149146

147+
@abstractmethod
148+
def list_podcasts_with_subscribers(
149+
self, limit: int | None = None
150+
) -> list[Podcast]:
151+
"""List podcasts that have at least one user subscribed.
152+
153+
This is used by the pipeline to determine which podcasts need to be synced.
154+
Only podcasts with active user subscriptions are returned.
155+
156+
Args:
157+
limit: Maximum number of podcasts to return.
158+
159+
Returns:
160+
List[Podcast]: Podcasts with at least one subscriber.
161+
"""
162+
pass
163+
150164
# --- Episode Operations ---
151165

152166
@abstractmethod
@@ -1450,16 +1464,16 @@ def get_podcast_by_feed_url(self, feed_url: str) -> Podcast | None:
14501464

14511465
def list_podcasts(
14521466
self,
1453-
subscribed_only: bool = True,
14541467
limit: int | None = None,
14551468
sort_by: str = "recency",
14561469
sort_order: str = "desc"
14571470
) -> list[Podcast]:
14581471
"""
1459-
List podcasts, optionally restricting results to subscribed podcasts.
1472+
List all podcasts with configurable sorting.
1473+
1474+
Use list_podcasts_with_subscribers() to get only podcasts with active subscribers.
14601475
14611476
Parameters:
1462-
subscribed_only (bool): If True, include only podcasts with `is_subscribed` set to True.
14631477
limit (Optional[int]): Maximum number of podcasts to return; if None, no limit is applied.
14641478
sort_by (str): Field to sort by ("recency", "subscribers", "alphabetical")
14651479
sort_order (str): Sort direction ("asc" or "desc")
@@ -1470,8 +1484,6 @@ def list_podcasts(
14701484
with self._get_session() as session:
14711485
# Build base query
14721486
stmt = select(Podcast)
1473-
if subscribed_only:
1474-
stmt = stmt.where(Podcast.is_subscribed.is_(True))
14751487

14761488
# Determine sort column
14771489
if sort_by == "recency":
@@ -1561,6 +1573,32 @@ def delete_podcast(self, podcast_id: str, delete_files: bool = False) -> bool:
15611573
logger.info(f"Deleted podcast: {podcast.title} ({podcast_id})")
15621574
return True
15631575

1576+
def list_podcasts_with_subscribers(
1577+
self, limit: int | None = None
1578+
) -> list[Podcast]:
1579+
"""List podcasts that have at least one user subscribed.
1580+
1581+
This is used by the pipeline to determine which podcasts need to be synced.
1582+
Only podcasts with active user subscriptions are returned.
1583+
1584+
Args:
1585+
limit: Maximum number of podcasts to return.
1586+
1587+
Returns:
1588+
List[Podcast]: Podcasts with at least one subscriber.
1589+
"""
1590+
with self._get_session() as session:
1591+
# Get distinct podcast IDs that have at least one subscription
1592+
subquery = (
1593+
select(UserSubscription.podcast_id)
1594+
.distinct()
1595+
.subquery()
1596+
)
1597+
stmt = select(Podcast).where(Podcast.id.in_(select(subquery)))
1598+
if limit:
1599+
stmt = stmt.limit(limit)
1600+
return list(session.scalars(stmt).all())
1601+
15641602
# --- Episode Operations ---
15651603

15661604
def create_episode(
@@ -2664,7 +2702,7 @@ def get_overall_stats(self) -> dict[str, Any]:
26642702
@returns:
26652703
stats (Dict[str, Any]): Mapping of statistic names to integer counts:
26662704
- total_podcasts: Total number of podcasts in the repository.
2667-
- subscribed_podcasts: Number of podcasts marked as subscribed.
2705+
- subscribed_podcasts: Number of podcasts with at least one user subscriber.
26682706
- total_episodes: Total number of episodes in the repository.
26692707
- pending_download: Episodes with download_status == "pending".
26702708
- downloading: Episodes with download_status == "downloading".
@@ -2683,8 +2721,9 @@ def get_overall_stats(self) -> dict[str, Any]:
26832721
with self._get_session() as session:
26842722
# Podcast counts (efficient SQL aggregations)
26852723
total_podcasts = session.scalar(select(func.count(Podcast.id))) or 0
2724+
# Count podcasts with at least one subscriber
26862725
subscribed_podcasts = session.scalar(
2687-
select(func.count(Podcast.id)).where(Podcast.is_subscribed.is_(True))
2726+
select(func.count(func.distinct(UserSubscription.podcast_id)))
26882727
) or 0
26892728

26902729
# Episode counts by status (single query with conditional aggregation)

src/podcast/feed_sync.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,27 @@ def sync_podcast(self, podcast_id: str) -> dict[str, Any]:
107107

108108
return result
109109

110-
def sync_all_podcasts(
111-
self,
112-
subscribed_only: bool = True,
113-
) -> dict[str, Any]:
110+
def sync_all_podcasts(self) -> dict[str, Any]:
114111
"""
115112
Synchronize all podcasts from the repository.
116113
117-
Parameters:
118-
subscribed_only (bool): If True, limit synchronization to podcasts marked as subscribed.
114+
Returns:
115+
overall_result (dict): Aggregated sync results with keys:
116+
- synced (int): Number of podcasts successfully synced.
117+
- failed (int): Number of podcasts that failed to sync.
118+
- new_episodes (int): Total number of new episodes added across all podcasts.
119+
- results (list): Per-podcast result dictionaries returned by `sync_podcast`.
120+
"""
121+
podcasts = self.repository.list_podcasts()
122+
123+
return self._sync_podcasts(podcasts)
124+
125+
def sync_podcasts_with_subscribers(self) -> dict[str, Any]:
126+
"""
127+
Synchronize podcasts that have at least one user subscribed.
128+
129+
This is the primary method used by the pipeline to sync feeds.
130+
Only podcasts with active user subscriptions are synced.
119131
120132
Returns:
121133
overall_result (dict): Aggregated sync results with keys:
@@ -124,8 +136,20 @@ def sync_all_podcasts(
124136
- new_episodes (int): Total number of new episodes added across all podcasts.
125137
- results (list): Per-podcast result dictionaries returned by `sync_podcast`.
126138
"""
127-
podcasts = self.repository.list_podcasts(subscribed_only=subscribed_only)
139+
podcasts = self.repository.list_podcasts_with_subscribers()
140+
141+
return self._sync_podcasts(podcasts)
142+
143+
def _sync_podcasts(self, podcasts: list) -> dict[str, Any]:
144+
"""
145+
Internal method to sync a list of podcasts.
146+
147+
Parameters:
148+
podcasts: List of Podcast objects to sync.
128149
150+
Returns:
151+
overall_result (dict): Aggregated sync results.
152+
"""
129153
overall_result = {
130154
"synced": 0,
131155
"failed": 0,

src/workflow/workers/sync.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,16 @@ def feed_sync_service(self) -> FeedSyncService:
5151
return self._feed_sync_service
5252

5353
def get_pending_count(self) -> int:
54-
"""Get the count of subscribed podcasts to sync.
54+
"""Get the count of podcasts with subscribers to sync.
5555
5656
Returns:
57-
Number of subscribed podcasts.
57+
Number of podcasts with at least one subscriber.
5858
"""
59-
podcasts = self.repository.list_podcasts(subscribed_only=True)
59+
podcasts = self.repository.list_podcasts_with_subscribers()
6060
return len(podcasts)
6161

6262
def process_batch(self, limit: int = 0) -> WorkerResult:
63-
"""Sync all subscribed podcast feeds.
63+
"""Sync podcast feeds for podcasts with subscribers.
6464
6565
Args:
6666
limit: Ignored for sync worker (always syncs all feeds).
@@ -71,9 +71,7 @@ def process_batch(self, limit: int = 0) -> WorkerResult:
7171
result = WorkerResult()
7272

7373
try:
74-
sync_result = self.feed_sync_service.sync_all_podcasts(
75-
subscribed_only=True
76-
)
74+
sync_result = self.feed_sync_service.sync_podcasts_with_subscribers()
7775

7876
result.processed = sync_result.get("synced", 0)
7977
result.failed = sync_result.get("failed", 0)

tests/test_cli_commands.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ def mock_config(self):
394394
def test_list_podcasts_empty(self, mock_create_repo, mock_config, capsys):
395395
"""Test listing when no podcasts exist."""
396396
mock_repo = Mock()
397-
mock_repo.list_podcasts.return_value = []
397+
mock_repo.list_podcasts_with_subscribers.return_value = []
398398
mock_create_repo.return_value = mock_repo
399399

400400
args = Mock()
@@ -412,11 +412,11 @@ def test_list_podcasts_with_data(self, mock_create_repo, mock_config, capsys):
412412
mock_podcast = Mock()
413413
mock_podcast.id = "pod-123"
414414
mock_podcast.title = "Test Podcast"
415-
mock_podcast.is_subscribed = True
416415

417416
mock_repo = Mock()
418-
mock_repo.list_podcasts.return_value = [mock_podcast]
417+
mock_repo.list_podcasts_with_subscribers.return_value = [mock_podcast]
419418
mock_repo.list_episodes.return_value = [Mock(), Mock()] # 2 episodes
419+
mock_repo.get_podcast_subscriber_counts.return_value = {"pod-123": 3}
420420
mock_create_repo.return_value = mock_repo
421421

422422
args = Mock()
@@ -427,7 +427,7 @@ def test_list_podcasts_with_data(self, mock_create_repo, mock_config, capsys):
427427

428428
captured = capsys.readouterr()
429429
assert "Test Podcast" in captured.out
430-
assert "Subscribed" in captured.out
430+
assert "Subscribers" in captured.out # Column header
431431

432432

433433
class TestShowStatus:

0 commit comments

Comments
 (0)