2424
2525import flickrapi
2626from django .conf import settings
27+ from flickrapi .exceptions import FlickrError
2728from tqdm import tqdm
2829
2930from images .models import Collection , Image , Source
3031from images .utils import R2Uploader
3132
32- POLITE_WAIT_SECS = 1.0
33+ DEFAULT_POLITE_WAIT_SECS = 1.0
34+ BACKOFF_MULTIPLIER = 2
35+ MAX_BACKOFF_SECS = 120
36+ MAX_RETRIES = 5
3337
3438# Flickr license IDs that represent public domain / no known restrictions
3539# See https://www.flickr.com/services/api/flickr.photos.licenses.getInfo.html
4044}
4145
4246
47+ def prompt_wait_secs ():
48+ """Ask the user to configure the polite wait time between API requests."""
49+ choice = input (
50+ f"\n Seconds to wait between API requests [{ DEFAULT_POLITE_WAIT_SECS } ]: "
51+ ).strip ()
52+ if not choice :
53+ return DEFAULT_POLITE_WAIT_SECS
54+ try :
55+ value = float (choice )
56+ if value < 0 :
57+ print ("Wait time cannot be negative, using default." )
58+ return DEFAULT_POLITE_WAIT_SECS
59+ return value
60+ except ValueError :
61+ print ("Invalid number, using default." )
62+ return DEFAULT_POLITE_WAIT_SECS
63+
64+
65+ def flickr_call_with_backoff (func , * args , ** kwargs ):
66+ """Call a Flickr API method with exponential backoff on 429 errors."""
67+ backoff = MAX_BACKOFF_SECS / (BACKOFF_MULTIPLIER ** (MAX_RETRIES - 1 ))
68+ for attempt in range (1 , MAX_RETRIES + 1 ):
69+ try :
70+ return func (* args , ** kwargs )
71+ except FlickrError as e :
72+ if "429" in str (e ) and attempt < MAX_RETRIES :
73+ wait = min (backoff , MAX_BACKOFF_SECS )
74+ tqdm .write (f" ⏳ Rate limited (429), backing off { wait :.0f} s..." )
75+ sleep (wait )
76+ backoff *= BACKOFF_MULTIPLIER
77+ else :
78+ raise
79+
80+
4381def extract_album_id (album_input ):
4482 """Extract a Flickr album (photoset) ID from a URL or raw ID string."""
4583 match = re .search (r"albums/(\d+)" , album_input )
@@ -243,7 +281,7 @@ def get_or_create_collection(source, album_info):
243281
244282def get_original_url (flickr_json , photo_id ):
245283 """Get the URL of the largest available size for a photo."""
246- sizes = flickr_json .photos .getSizes ( photo_id = photo_id )
284+ sizes = flickr_call_with_backoff ( flickr_json .photos .getSizes , photo_id = photo_id )
247285 size_list = sizes ["sizes" ]["size" ]
248286
249287 # Prefer Original, then Large, then whatever is biggest
@@ -257,7 +295,7 @@ def get_original_url(flickr_json, photo_id):
257295 return size_list [- 1 ]["source" ] if size_list else None
258296
259297
260- def fetch_album_photos (flickr , album_id , total ):
298+ def fetch_album_photos (flickr , album_id , total , wait_secs ):
261299 """Paginate through all photos in an album, yielding each photo dict.
262300
263301 Handles pagination via flickr.photosets.getPhotos with parsed-json format,
@@ -267,7 +305,8 @@ def fetch_album_photos(flickr, album_id, total):
267305 pages = (total + per_page - 1 ) // per_page
268306
269307 for page in range (1 , pages + 1 ):
270- resp = flickr .photosets .getPhotos (
308+ resp = flickr_call_with_backoff (
309+ flickr .photosets .getPhotos ,
271310 photoset_id = album_id ,
272311 extras = "description,license,owner_name,date_taken,url_o" ,
273312 per_page = per_page ,
@@ -276,10 +315,10 @@ def fetch_album_photos(flickr, album_id, total):
276315 for photo in resp ["photoset" ]["photo" ]:
277316 yield photo
278317 if page < pages :
279- sleep (POLITE_WAIT_SECS )
318+ sleep (wait_secs )
280319
281320
282- def process_album (flickr , album_id , collection , owner , total , options ):
321+ def process_album (flickr , album_id , collection , owner , total , options , wait_secs ):
283322 """Enumerate photos in an album and import them.
284323
285324 Mode 1 ("album"): parse metadata from descriptions, import everything.
@@ -292,7 +331,7 @@ def process_album(flickr, album_id, collection, owner, total, options):
292331 skipped = 0
293332 errors = 0
294333
295- photos = fetch_album_photos (flickr , album_id , total )
334+ photos = fetch_album_photos (flickr , album_id , total , wait_secs )
296335
297336 for photo in tqdm (photos , total = total , desc = "Processing photos" ):
298337 if max_images and imported >= max_images :
@@ -340,7 +379,7 @@ def process_album(flickr, album_id, collection, owner, total, options):
340379 continue
341380
342381 # Get the highest-resolution image URL
343- sleep (POLITE_WAIT_SECS )
382+ sleep (wait_secs )
344383 try :
345384 image_url = get_original_url (flickr , photo_id )
346385 except Exception as e :
@@ -380,7 +419,7 @@ def process_album(flickr, album_id, collection, owner, total, options):
380419 tqdm .write (f" ✗ DB error for { photo_id } : { e } " )
381420 errors += 1
382421
383- sleep (POLITE_WAIT_SECS )
422+ sleep (wait_secs )
384423
385424 return imported , skipped , errors
386425
@@ -414,9 +453,13 @@ def handle(options):
414453 print (f"Album ID: { album_id } " )
415454
416455 flickr = get_flickr_client ()
456+ wait_secs = prompt_wait_secs ()
457+ print (f"Wait between requests: { wait_secs } s" )
417458
418459 # Fetch album metadata
419- album_info_resp = flickr .photosets .getInfo (photoset_id = album_id )
460+ album_info_resp = flickr_call_with_backoff (
461+ flickr .photosets .getInfo , photoset_id = album_id
462+ )
420463 photoset = album_info_resp ["photoset" ]
421464 album_info = {
422465 "id" : album_id ,
@@ -445,6 +488,7 @@ def handle(options):
445488 album_info ["owner" ],
446489 int (album_info ["count" ]),
447490 options ,
491+ wait_secs ,
448492 )
449493
450494 # Summary
0 commit comments