parth-dl 1.0.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.
- parth_dl/__init__.py +53 -0
- parth_dl/cli.py +169 -0
- parth_dl/core.py +274 -0
- parth_dl/extractors.py +526 -0
- parth_dl/utils.py +285 -0
- parth_dl-1.0.0.dist-info/METADATA +211 -0
- parth_dl-1.0.0.dist-info/RECORD +11 -0
- parth_dl-1.0.0.dist-info/WHEEL +5 -0
- parth_dl-1.0.0.dist-info/entry_points.txt +2 -0
- parth_dl-1.0.0.dist-info/licenses/LICENSE +31 -0
- parth_dl-1.0.0.dist-info/top_level.txt +1 -0
parth_dl/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
parth-dl: Instagram Media Downloader
|
|
3
|
+
Download Instagram reels, posts, and profile pictures (public accounts only)
|
|
4
|
+
|
|
5
|
+
Author: Parth
|
|
6
|
+
License: MIT
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
__author__ = "Parth"
|
|
11
|
+
__description__ = "Instagram media downloader for public content"
|
|
12
|
+
|
|
13
|
+
from .core import InstagramDownloader
|
|
14
|
+
from .utils import DownloadError, RateLimitError, NetworkError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'InstagramDownloader',
|
|
18
|
+
'DownloadError',
|
|
19
|
+
'RateLimitError',
|
|
20
|
+
'NetworkError',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def download(url, output_path=None, quality='best', verbose=False):
|
|
25
|
+
"""
|
|
26
|
+
Quick download function
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
url: Instagram URL (reel, post, or profile)
|
|
30
|
+
output_path: Output file/directory path
|
|
31
|
+
quality: 'best' or 'worst'
|
|
32
|
+
verbose: Enable verbose logging
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Path to downloaded file(s)
|
|
36
|
+
"""
|
|
37
|
+
downloader = InstagramDownloader(verbose=verbose)
|
|
38
|
+
return downloader.download(url, output_path, quality)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_info(url, verbose=False):
|
|
42
|
+
"""
|
|
43
|
+
Get media information without downloading
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
url: Instagram URL
|
|
47
|
+
verbose: Enable verbose logging
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Dictionary with media information
|
|
51
|
+
"""
|
|
52
|
+
downloader = InstagramDownloader(verbose=verbose)
|
|
53
|
+
return downloader.get_info(url)
|
parth_dl/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for parth-dl
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import argparse
|
|
7
|
+
from . import __version__, __description__
|
|
8
|
+
from .core import InstagramDownloader
|
|
9
|
+
from .utils import DownloadError, RateLimitError, NetworkError, ValidationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def print_banner():
|
|
13
|
+
"""Print application banner"""
|
|
14
|
+
banner = f"""
|
|
15
|
+
╔═══════════════════════════════════════════════════════════════╗
|
|
16
|
+
║ parth-dl v{__version__} ║
|
|
17
|
+
║ Instagram Media Downloader ║
|
|
18
|
+
║ (Public Content Only) ║
|
|
19
|
+
╚═══════════════════════════════════════════════════════════════╝
|
|
20
|
+
"""
|
|
21
|
+
print(banner)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_parser():
|
|
25
|
+
"""Create command-line argument parser"""
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog='parth-dl',
|
|
28
|
+
description=__description__,
|
|
29
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
30
|
+
epilog="""
|
|
31
|
+
Examples:
|
|
32
|
+
Download a reel:
|
|
33
|
+
parth-dl https://www.instagram.com/reel/ABC123/
|
|
34
|
+
|
|
35
|
+
Download a post:
|
|
36
|
+
parth-dl https://www.instagram.com/p/ABC123/
|
|
37
|
+
|
|
38
|
+
Download profile picture:
|
|
39
|
+
parth-dl https://www.instagram.com/username/
|
|
40
|
+
|
|
41
|
+
Download with custom output:
|
|
42
|
+
parth-dl https://www.instagram.com/reel/ABC123/ -o my_video.mp4
|
|
43
|
+
|
|
44
|
+
List available formats:
|
|
45
|
+
parth-dl https://www.instagram.com/reel/ABC123/ --list-formats
|
|
46
|
+
|
|
47
|
+
Enable verbose logging:
|
|
48
|
+
parth-dl https://www.instagram.com/reel/ABC123/ -v
|
|
49
|
+
|
|
50
|
+
Supported Content:
|
|
51
|
+
✓ Reels (with audio)
|
|
52
|
+
✓ Video posts (with audio)
|
|
53
|
+
✓ Image posts (single & carousel)
|
|
54
|
+
✓ Profile pictures
|
|
55
|
+
✗ Stories (requires authentication)
|
|
56
|
+
✗ Highlights (requires authentication)
|
|
57
|
+
✗ Private accounts (not supported)
|
|
58
|
+
|
|
59
|
+
Note: This tool only works with PUBLIC Instagram content.
|
|
60
|
+
"""
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Positional arguments
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
'url',
|
|
66
|
+
help='Instagram URL (post, reel, or profile)'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Optional arguments
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
'-o', '--output',
|
|
72
|
+
metavar='PATH',
|
|
73
|
+
help='Output file or directory path'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
'-q', '--quality',
|
|
78
|
+
choices=['best', 'worst'],
|
|
79
|
+
default='best',
|
|
80
|
+
help='Video quality preference (default: best)'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
'-v', '--verbose',
|
|
85
|
+
action='store_true',
|
|
86
|
+
help='Enable verbose/debug output'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
'--list-formats',
|
|
91
|
+
action='store_true',
|
|
92
|
+
help='List all available formats without downloading'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
'--no-rate-limit',
|
|
97
|
+
action='store_true',
|
|
98
|
+
help='Disable rate limiting (not recommended)'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
'--version',
|
|
103
|
+
action='version',
|
|
104
|
+
version=f'%(prog)s {__version__}'
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
"""Main CLI entry point"""
|
|
112
|
+
parser = create_parser()
|
|
113
|
+
args = parser.parse_args()
|
|
114
|
+
|
|
115
|
+
# Print banner for non-quiet operations
|
|
116
|
+
if not args.list_formats:
|
|
117
|
+
print_banner()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
# Create downloader instance
|
|
121
|
+
downloader = InstagramDownloader(
|
|
122
|
+
verbose=args.verbose,
|
|
123
|
+
rate_limit=not args.no_rate_limit
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Execute command
|
|
127
|
+
if args.list_formats:
|
|
128
|
+
downloader.list_formats(args.url)
|
|
129
|
+
else:
|
|
130
|
+
downloader.download(
|
|
131
|
+
url=args.url,
|
|
132
|
+
output_path=args.output,
|
|
133
|
+
quality=args.quality
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
print("\n\n[parth-dl] ⚠ Download cancelled by user", file=sys.stderr)
|
|
140
|
+
return 130
|
|
141
|
+
|
|
142
|
+
except ValidationError as e:
|
|
143
|
+
print(f"\n[parth-dl] ✗ Invalid input: {e}", file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
|
|
146
|
+
except RateLimitError as e:
|
|
147
|
+
print(f"\n[parth-dl] ✗ Rate limit error: {e}", file=sys.stderr)
|
|
148
|
+
print("Tip: Wait a few minutes before trying again.", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
except NetworkError as e:
|
|
152
|
+
print(f"\n[parth-dl] ✗ Network error: {e}", file=sys.stderr)
|
|
153
|
+
print("Tip: Check your internet connection and try again.", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
except DownloadError as e:
|
|
157
|
+
print(f"\n[parth-dl] ✗ Download failed: {e}", file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
print(f"\n[parth-dl] ✗ Unexpected error: {e}", file=sys.stderr)
|
|
162
|
+
if args.verbose:
|
|
163
|
+
import traceback
|
|
164
|
+
traceback.print_exc()
|
|
165
|
+
return 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == '__main__':
|
|
169
|
+
sys.exit(main())
|
parth_dl/core.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core downloader class - orchestrates extraction and downloading
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from .extractors import MediaExtractor, ProfilePictureExtractor
|
|
9
|
+
from .utils import (
|
|
10
|
+
validate_url, sanitize_filename, is_profile_url, is_media_url,
|
|
11
|
+
RateLimiter, ProgressBar, DownloadError, format_size, retry_on_failure
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InstagramDownloader:
|
|
16
|
+
"""
|
|
17
|
+
Main Instagram downloader class
|
|
18
|
+
Supports: reels, posts (single/carousel images/videos), profile pictures
|
|
19
|
+
Public accounts only - no authentication required
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
BASE_HEADERS = {
|
|
23
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
24
|
+
'Accept': '*/*',
|
|
25
|
+
'Referer': 'https://www.instagram.com/',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def __init__(self, verbose=False, rate_limit=True):
|
|
29
|
+
"""
|
|
30
|
+
Initialize downloader
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
verbose: Enable verbose logging
|
|
34
|
+
rate_limit: Enable rate limiting (recommended)
|
|
35
|
+
"""
|
|
36
|
+
self.verbose = verbose
|
|
37
|
+
self.rate_limiter = RateLimiter(max_requests=30, time_window=60) if rate_limit else None
|
|
38
|
+
|
|
39
|
+
# Initialize extractors
|
|
40
|
+
self.media_extractor = MediaExtractor(verbose=verbose)
|
|
41
|
+
self.profile_extractor = ProfilePictureExtractor(verbose=verbose)
|
|
42
|
+
|
|
43
|
+
def log(self, message):
|
|
44
|
+
"""Print verbose log messages"""
|
|
45
|
+
if self.verbose:
|
|
46
|
+
print(f"[parth-dl] {message}")
|
|
47
|
+
|
|
48
|
+
def get_info(self, url):
|
|
49
|
+
"""
|
|
50
|
+
Get media information without downloading
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
url: Instagram URL (post, reel, or profile)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Dictionary with media information
|
|
57
|
+
"""
|
|
58
|
+
# Validate URL
|
|
59
|
+
validate_url(url)
|
|
60
|
+
|
|
61
|
+
# Rate limiting
|
|
62
|
+
if self.rate_limiter:
|
|
63
|
+
self.rate_limiter.wait_if_needed()
|
|
64
|
+
|
|
65
|
+
# Determine content type and extract
|
|
66
|
+
if is_profile_url(url):
|
|
67
|
+
self.log("Detected profile URL")
|
|
68
|
+
return self.profile_extractor.extract(url)
|
|
69
|
+
elif is_media_url(url):
|
|
70
|
+
self.log("Detected media URL")
|
|
71
|
+
return self.media_extractor.extract(url)
|
|
72
|
+
else:
|
|
73
|
+
raise DownloadError("Unsupported URL format. Use post/reel/profile URL.")
|
|
74
|
+
|
|
75
|
+
@retry_on_failure(max_retries=3)
|
|
76
|
+
def _download_file(self, url, output_path, show_progress=True):
|
|
77
|
+
"""
|
|
78
|
+
Download file with progress bar
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
url: Direct download URL
|
|
82
|
+
output_path: Output file path
|
|
83
|
+
show_progress: Show download progress
|
|
84
|
+
"""
|
|
85
|
+
req = urllib.request.Request(url, headers=self.BASE_HEADERS)
|
|
86
|
+
|
|
87
|
+
with urllib.request.urlopen(req, timeout=60) as response:
|
|
88
|
+
total_size = int(response.headers.get('Content-Length', 0))
|
|
89
|
+
|
|
90
|
+
# Create progress bar
|
|
91
|
+
progress = None
|
|
92
|
+
if show_progress and total_size > 0:
|
|
93
|
+
progress = ProgressBar(total_size)
|
|
94
|
+
|
|
95
|
+
# Download
|
|
96
|
+
with open(output_path, 'wb') as f:
|
|
97
|
+
while True:
|
|
98
|
+
chunk = response.read(8192)
|
|
99
|
+
if not chunk:
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
f.write(chunk)
|
|
103
|
+
if progress:
|
|
104
|
+
progress.update(len(chunk))
|
|
105
|
+
|
|
106
|
+
if progress:
|
|
107
|
+
progress.finish()
|
|
108
|
+
|
|
109
|
+
self.log(f"Downloaded: {output_path} ({format_size(os.path.getsize(output_path))})")
|
|
110
|
+
|
|
111
|
+
def _select_best_format(self, formats, quality='best'):
|
|
112
|
+
"""
|
|
113
|
+
Select best format based on quality preference
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
formats: List of format dictionaries
|
|
117
|
+
quality: 'best' or 'worst'
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Selected format dictionary
|
|
121
|
+
"""
|
|
122
|
+
if not formats:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
# Filter to only formats with audio (for videos)
|
|
126
|
+
video_formats = [f for f in formats if f.get('has_audio')]
|
|
127
|
+
if video_formats:
|
|
128
|
+
formats = video_formats
|
|
129
|
+
|
|
130
|
+
if quality == 'best':
|
|
131
|
+
# Best: highest resolution
|
|
132
|
+
return max(formats, key=lambda f: (
|
|
133
|
+
f.get('height', 0) * f.get('width', 0),
|
|
134
|
+
f.get('height', 0)
|
|
135
|
+
))
|
|
136
|
+
else:
|
|
137
|
+
# Worst: lowest resolution
|
|
138
|
+
return min(formats, key=lambda f: (
|
|
139
|
+
f.get('height', 999999),
|
|
140
|
+
f.get('width', 999999)
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
def download(self, url, output_path=None, quality='best'):
|
|
144
|
+
"""
|
|
145
|
+
Download media from Instagram URL
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
url: Instagram URL (post, reel, or profile)
|
|
149
|
+
output_path: Output file/directory path (auto-generated if None)
|
|
150
|
+
quality: 'best' or 'worst'
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Path(s) to downloaded file(s)
|
|
154
|
+
"""
|
|
155
|
+
# Get media info
|
|
156
|
+
info = self.get_info(url)
|
|
157
|
+
|
|
158
|
+
if not info:
|
|
159
|
+
raise DownloadError("Failed to extract media information")
|
|
160
|
+
|
|
161
|
+
# Prepare output directory
|
|
162
|
+
if output_path and os.path.isdir(output_path):
|
|
163
|
+
output_dir = output_path
|
|
164
|
+
output_path = None
|
|
165
|
+
else:
|
|
166
|
+
output_dir = os.getcwd()
|
|
167
|
+
|
|
168
|
+
# Handle different media types
|
|
169
|
+
media_type = info.get('type')
|
|
170
|
+
downloaded_files = []
|
|
171
|
+
|
|
172
|
+
print(f"\n{'='*70}")
|
|
173
|
+
print(f"Title: {info.get('title', 'Untitled')}")
|
|
174
|
+
print(f"Uploader: @{info.get('uploader', 'unknown')}")
|
|
175
|
+
print(f"Type: {media_type}")
|
|
176
|
+
print(f"{'='*70}\n")
|
|
177
|
+
|
|
178
|
+
# Download videos
|
|
179
|
+
if info.get('formats'):
|
|
180
|
+
selected_format = self._select_best_format(info['formats'], quality)
|
|
181
|
+
|
|
182
|
+
if not selected_format:
|
|
183
|
+
raise DownloadError("No suitable video format found")
|
|
184
|
+
|
|
185
|
+
# Generate filename
|
|
186
|
+
if not output_path:
|
|
187
|
+
safe_title = sanitize_filename(info.get('title', 'video'))
|
|
188
|
+
media_id = info.get('id', 'unknown')
|
|
189
|
+
output_path = os.path.join(output_dir, f"{safe_title}_{media_id}.mp4")
|
|
190
|
+
|
|
191
|
+
print(f"Resolution: {selected_format.get('width')}x{selected_format.get('height')}")
|
|
192
|
+
print(f"Audio: {'✓ YES' if selected_format.get('has_audio') else '✗ NO'}")
|
|
193
|
+
print(f"Output: {output_path}\n")
|
|
194
|
+
|
|
195
|
+
self._download_file(selected_format['url'], output_path)
|
|
196
|
+
downloaded_files.append(output_path)
|
|
197
|
+
|
|
198
|
+
# Download images
|
|
199
|
+
elif info.get('images'):
|
|
200
|
+
images = info['images']
|
|
201
|
+
|
|
202
|
+
if len(images) == 1:
|
|
203
|
+
# Single image
|
|
204
|
+
if not output_path:
|
|
205
|
+
safe_title = sanitize_filename(info.get('title', 'image'))
|
|
206
|
+
media_id = info.get('id', 'unknown')
|
|
207
|
+
output_path = os.path.join(output_dir, f"{safe_title}_{media_id}.jpg")
|
|
208
|
+
|
|
209
|
+
print(f"Output: {output_path}\n")
|
|
210
|
+
self._download_file(images[0]['url'], output_path)
|
|
211
|
+
downloaded_files.append(output_path)
|
|
212
|
+
|
|
213
|
+
else:
|
|
214
|
+
# Multiple images (carousel)
|
|
215
|
+
print(f"Downloading {len(images)} images from carousel...\n")
|
|
216
|
+
|
|
217
|
+
safe_title = sanitize_filename(info.get('title', 'carousel'))
|
|
218
|
+
media_id = info.get('id', 'unknown')
|
|
219
|
+
|
|
220
|
+
for idx, image in enumerate(images, 1):
|
|
221
|
+
filename = f"{safe_title}_{media_id}_{idx:02d}.jpg"
|
|
222
|
+
file_path = os.path.join(output_dir, filename)
|
|
223
|
+
|
|
224
|
+
print(f"[{idx}/{len(images)}] {filename}")
|
|
225
|
+
self._download_file(image['url'], file_path, show_progress=False)
|
|
226
|
+
downloaded_files.append(file_path)
|
|
227
|
+
print()
|
|
228
|
+
|
|
229
|
+
else:
|
|
230
|
+
raise DownloadError("No downloadable content found")
|
|
231
|
+
|
|
232
|
+
# Success message
|
|
233
|
+
print(f"{'='*70}")
|
|
234
|
+
print(f"✓ Download complete!")
|
|
235
|
+
print(f"Files saved: {len(downloaded_files)}")
|
|
236
|
+
for file in downloaded_files:
|
|
237
|
+
print(f" - {file}")
|
|
238
|
+
print(f"{'='*70}\n")
|
|
239
|
+
|
|
240
|
+
return downloaded_files[0] if len(downloaded_files) == 1 else downloaded_files
|
|
241
|
+
|
|
242
|
+
def list_formats(self, url):
|
|
243
|
+
"""
|
|
244
|
+
List all available formats for a URL
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
url: Instagram URL
|
|
248
|
+
"""
|
|
249
|
+
info = self.get_info(url)
|
|
250
|
+
|
|
251
|
+
print(f"\nMedia: {info.get('title', 'Untitled')}")
|
|
252
|
+
print(f"Uploader: @{info.get('uploader', 'unknown')}")
|
|
253
|
+
print(f"Type: {info.get('type', 'unknown')}")
|
|
254
|
+
print(f"{'='*70}\n")
|
|
255
|
+
|
|
256
|
+
# Video formats
|
|
257
|
+
if info.get('formats'):
|
|
258
|
+
print("Video Formats:")
|
|
259
|
+
for fmt in info['formats']:
|
|
260
|
+
audio = "🔊 WITH AUDIO" if fmt.get('has_audio') else "🔇 NO AUDIO"
|
|
261
|
+
print(f" {fmt.get('format_id')}: {fmt.get('width')}x{fmt.get('height')} [{audio}]")
|
|
262
|
+
print()
|
|
263
|
+
|
|
264
|
+
# Image formats
|
|
265
|
+
if info.get('images'):
|
|
266
|
+
print(f"Images: {len(info['images'])} image(s)")
|
|
267
|
+
for idx, img in enumerate(info['images'], 1):
|
|
268
|
+
print(f" [{idx}] {img.get('width')}x{img.get('height')}")
|
|
269
|
+
print()
|
|
270
|
+
|
|
271
|
+
# Thumbnail
|
|
272
|
+
if info.get('thumbnail'):
|
|
273
|
+
print(f"Thumbnail: {info['thumbnail'][:60]}...")
|
|
274
|
+
print()
|