Skip to content

Commit bc40ff7

Browse files
authored
Merge pull request #166 from marceljungle/copilot/fix-165
Add show-missing command to compare local collections with site collages
2 parents 061d5d2 + 3379fde commit bc40ff7

6 files changed

Lines changed: 257 additions & 4 deletions

File tree

red_plex/infrastructure/cli/commands/collages.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from red_plex.infrastructure.plex.plex_manager import PlexManager
1111
from red_plex.infrastructure.rest.gazelle.gazelle_api import GazelleAPI
1212
from red_plex.infrastructure.service.collection_processor import CollectionProcessingService
13+
from red_plex.use_case.show_missing.show_missing_use_case import ShowMissingUseCase
1314

1415

1516
@click.group('collages')
@@ -171,3 +172,67 @@ def convert_collages(ctx, collage_ids, site, fetch_mode):
171172
)
172173

173174
click.echo("Processing finished.")
175+
176+
177+
@collages.command('show-missing')
178+
@click.pass_context
179+
@click.argument('collage_id')
180+
def show_missing(ctx, collage_id):
181+
"""
182+
Show missing torrent groups from the local collection compared to the site collage.
183+
184+
COLLAGE_ID is the external ID of the collage to check.
185+
"""
186+
local_database = ctx.obj.get('db')
187+
if not local_database:
188+
click.echo("Error: Database not initialized.", err=True)
189+
return
190+
191+
# Get the local collection to determine which site to use
192+
local_collection = local_database.get_collage_collection_by_external_id(collage_id)
193+
if not local_collection:
194+
click.echo(
195+
f"Error: No local collection found for collage ID {collage_id}. "
196+
f"You may need to convert it first using "
197+
f"'red-plex collages convert {collage_id} --site <site>'", err=True)
198+
return
199+
200+
site = local_collection.site
201+
202+
# Initialize Gazelle API
203+
try:
204+
gazelle_api = GazelleAPI(site)
205+
except Exception as e: # pylint: disable=W0718
206+
logger.error("Failed to initialize Gazelle API: %s", e, exc_info=True)
207+
click.echo(f"Error: Failed to initialize API for {site.upper()} - {e}", err=True)
208+
return
209+
210+
# Use the show missing use case
211+
use_case = ShowMissingUseCase(local_database, gazelle_api)
212+
result = use_case.execute(collage_id)
213+
214+
if not result.success:
215+
click.echo(f"Error: {result.error_message}", err=True)
216+
return
217+
218+
click.echo(f"Checking collage '{result.collage_name}' "
219+
f"(ID: {collage_id}) on {result.site.upper()}...")
220+
221+
if not result.has_missing_groups:
222+
click.echo("✓ No missing groups found! "
223+
"Your local collection is up to date.")
224+
return
225+
226+
click.echo(f"\nFound {len(result.missing_groups)} missing group(s) "
227+
"in your local collection:")
228+
click.echo("=" * 80)
229+
230+
# Display missing groups
231+
for i, missing_group in enumerate(result.missing_groups, 1):
232+
artists_str = ", ".join(missing_group.artist_names)
233+
click.echo(f"{i:3d}. {artists_str} - {missing_group.album_name}")
234+
click.echo(f" Link: {missing_group.torrent_url}")
235+
if i < len(result.missing_groups): # Don't add extra line after last item
236+
click.echo()
237+
238+
click.echo("=" * 80)

red_plex/infrastructure/db/collection.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,35 @@ def get_collage_collection(self, rating_key: str) -> Optional[Collection]:
143143
site=site
144144
)
145145

146+
def get_collage_collection_by_external_id(self, external_id: str) -> Optional[Collection]:
147+
"""
148+
Retrieve a single collage-based collection (and associated group_ids) by external_id.
149+
Returns a Collection or None if not found.
150+
"""
151+
cur = self.conn.cursor()
152+
# Get collage collection fields
153+
cur.execute(
154+
"""
155+
SELECT rating_key, name, site, external_id
156+
FROM collage_collections
157+
WHERE external_id = ?
158+
""",
159+
(external_id,)
160+
)
161+
row = cur.fetchone()
162+
if not row:
163+
return None
164+
rating_key_val, name, site, external_id_val = row
165+
# Get associated group_ids
166+
group_ids = self._get_torrent_group_ids_for(rating_key_val)
167+
return Collection(
168+
id=rating_key_val,
169+
external_id=external_id_val,
170+
name=name,
171+
torrent_groups=[TorrentGroup(id=gid) for gid in group_ids],
172+
site=site
173+
)
174+
146175
def get_all_collage_collections(self) -> List[Collection]:
147176
"""
148177
Retrieve all collage-based collections from the DB,

red_plex/infrastructure/db/local_database.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ def get_collage_collection(self, rating_key: str) -> Optional[Collection]:
7878
"""Retrieve a single collage-based collection by rating_key."""
7979
return self._collection_manager.get_collage_collection(rating_key)
8080

81+
def get_collage_collection_by_external_id(self, external_id: str) -> Optional[Collection]:
82+
"""Retrieve a single collage-based collection by external_id."""
83+
return self._collection_manager.get_collage_collection_by_external_id(external_id)
84+
8185
def get_all_collage_collections(self) -> List[Collection]:
8286
"""Retrieve all collage-based collections from the DB."""
8387
return self._collection_manager.get_all_collage_collections()

red_plex/infrastructure/rest/gazelle/gazelle_api.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ def __init__(self, site: str):
2929
site_config = config_data.site_configurations.get(site.upper())
3030

3131
api_key = site_config.api_key
32-
base_url = site_config.base_url
32+
self.base_url = site_config.base_url
3333
rate_limit_config = site_config.rate_limit
3434
rate_limit = Rate(
3535
rate_limit_config.calls, Duration.SECOND * rate_limit_config.seconds)
3636

37-
self.base_url = base_url.rstrip('/') + '/ajax.php?action='
37+
self.base_url_with_action = self.base_url.rstrip('/') + '/ajax.php?action='
3838
self.headers = {'Authorization': api_key}
3939

4040
# Initialize the rate limiter: default to 10 calls per 10 seconds if not specified
@@ -68,7 +68,7 @@ def get_call(self, action: str, params: Dict[str, str]) -> Dict[str, Any]:
6868
Rate limit is handled in a loop, while network/HTTP errors trigger a retry.
6969
"""
7070
formatted_params = '&' + '&'.join(f'{k}={v}' for k, v in params.items()) if params else ''
71-
formatted_url = f'{self.base_url}{action}{formatted_params}'
71+
formatted_url = f'{self.base_url_with_action}{action}{formatted_params}'
7272
logger.debug('Calling GET API: %s', formatted_url)
7373

7474
self._wait_for_rate_limit()
@@ -89,7 +89,7 @@ def post_call(self, action: str,
8989
Makes a rate-limited POST API call to the Gazelle-based service with retries.
9090
Rate limit is handled in a loop, while network/HTTP errors trigger a retry.
9191
"""
92-
url = f'{self.base_url}{action}'
92+
url = f'{self.base_url_with_action}{action}'
9393
logger.debug('Calling POST API: %s', url)
9494

9595
self._wait_for_rate_limit()
@@ -378,6 +378,10 @@ def add_to_collage(self, collage_id: str,
378378
logger.error('Error adding groups %s to collage %s: %s', group_ids_str, collage_id, e)
379379
return None
380380

381+
def get_torrent_group_url(self, group_id: str) -> str:
382+
""" Constructs the URL for a torrent group based on its ID."""
383+
return f"{self.base_url}/torrents.php?id={group_id}"
384+
381385
@staticmethod
382386
def _normalize_string(text: str) -> str:
383387
"""
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Response model for show missing use case."""
2+
3+
from dataclasses import dataclass
4+
from typing import List, Optional
5+
6+
7+
@dataclass
8+
class MissingGroupInfo:
9+
"""Information about a missing torrent group."""
10+
group_id: int
11+
artist_names: List[str]
12+
album_name: str
13+
torrent_url: str
14+
15+
16+
@dataclass
17+
class ShowMissingResponse:
18+
"""Response from the show missing use case."""
19+
success: bool
20+
collage_name: str
21+
site: str
22+
missing_groups: List[MissingGroupInfo]
23+
error_message: Optional[str] = None
24+
25+
@property
26+
def has_missing_groups(self) -> bool:
27+
"""Check if there are any missing groups."""
28+
return len(self.missing_groups) > 0
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Use case for showing missing torrent groups in local collections."""
2+
3+
from red_plex.infrastructure.db.local_database import LocalDatabase
4+
from red_plex.infrastructure.logger.logger import logger
5+
from red_plex.infrastructure.rest.gazelle.gazelle_api import GazelleAPI
6+
from red_plex.use_case.show_missing.show_missing_response import (
7+
ShowMissingResponse, MissingGroupInfo
8+
)
9+
10+
11+
# pylint: disable=R0903
12+
class ShowMissingUseCase:
13+
"""Use case for finding missing torrent groups in local collections."""
14+
15+
def __init__(self, db: LocalDatabase, gazelle_api: GazelleAPI):
16+
"""Initialize the use case with required dependencies."""
17+
self.db = db
18+
self.gazelle_api = gazelle_api
19+
20+
# pylint: disable=R0914
21+
def execute(self, collage_id: str) -> ShowMissingResponse:
22+
"""
23+
Execute the show missing use case.
24+
25+
Args:
26+
collage_id: The external ID of the collage to check
27+
28+
Returns:
29+
ShowMissingResponse with the results
30+
"""
31+
# Get the local collection by external_id
32+
local_collection = self.db.get_collage_collection_by_external_id(collage_id)
33+
if not local_collection:
34+
return ShowMissingResponse(
35+
success=False,
36+
collage_name="",
37+
site="",
38+
missing_groups=[],
39+
error_message=(
40+
f"No local collection found for collage ID {collage_id}. "
41+
f"You may need to convert it first using "
42+
f"'red-plex collages convert {collage_id} --site <site>'"
43+
)
44+
)
45+
46+
site = local_collection.site
47+
48+
# Get the current collage from the site
49+
try:
50+
site_collection = self.gazelle_api.get_collage(collage_id)
51+
except Exception as e: # pylint: disable=W0718
52+
logger.error("Failed to fetch collage from site: %s", e, exc_info=True)
53+
return ShowMissingResponse(
54+
success=False,
55+
collage_name=local_collection.name,
56+
site=site,
57+
missing_groups=[],
58+
error_message=f"Failed to fetch collage {collage_id} from {site.upper()} - {e}"
59+
)
60+
61+
if not site_collection:
62+
return ShowMissingResponse(
63+
success=False,
64+
collage_name=local_collection.name,
65+
site=site,
66+
missing_groups=[],
67+
error_message=f"Collage {collage_id} not found on {site.upper()}"
68+
)
69+
70+
# Compare group IDs
71+
local_group_ids = {int(tg.id) for tg in local_collection.torrent_groups}
72+
site_group_ids = {int(tg.id) for tg in site_collection.torrent_groups}
73+
missing_group_ids = site_group_ids - local_group_ids
74+
75+
# If no missing groups, return success with empty list
76+
if not missing_group_ids:
77+
return ShowMissingResponse(
78+
success=True,
79+
collage_name=local_collection.name,
80+
site=site,
81+
missing_groups=[]
82+
)
83+
84+
# Fetch details for each missing group
85+
missing_groups = []
86+
for group_id in sorted(missing_group_ids):
87+
try:
88+
torrent_group = self.gazelle_api.get_torrent_group(str(group_id))
89+
if torrent_group:
90+
artists = torrent_group.artists if torrent_group.artists else ["Unknown Artist"]
91+
album_name = torrent_group.album_name or "Unknown Album"
92+
torrent_url = self.gazelle_api.get_torrent_group_url(str(group_id))
93+
94+
missing_groups.append(MissingGroupInfo(
95+
group_id=group_id,
96+
artist_names=artists,
97+
album_name=album_name,
98+
torrent_url=torrent_url
99+
))
100+
else:
101+
# Group details not available, but still add basic info
102+
missing_groups.append(MissingGroupInfo(
103+
group_id=group_id,
104+
artist_names=["Unknown Artist"],
105+
album_name="Details not available",
106+
torrent_url=self.gazelle_api.get_torrent_group_url(str(group_id))
107+
))
108+
except Exception as e: # pylint: disable=W0718
109+
logger.warning("Failed to fetch details for group %s: %s", group_id, e)
110+
# Add basic info even if details fetch failed
111+
missing_groups.append(MissingGroupInfo(
112+
group_id=group_id,
113+
artist_names=["Unknown Artist"],
114+
album_name="Error fetching details",
115+
torrent_url=self.gazelle_api.get_torrent_group_url(str(group_id))
116+
))
117+
118+
return ShowMissingResponse(
119+
success=True,
120+
collage_name=local_collection.name,
121+
site=site,
122+
missing_groups=missing_groups
123+
)

0 commit comments

Comments
 (0)