ytm-cli 0.5.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.
ytm_cli/auth.py ADDED
@@ -0,0 +1,697 @@
1
+ """Authentication management for YTM CLI"""
2
+
3
+ import configparser
4
+ import glob
5
+ import json
6
+ import os
7
+ import webbrowser
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ from rich import print
11
+ from ytmusicapi import YTMusic
12
+ from ytmusicapi.setup import setup_oauth
13
+
14
+ #
15
+ try:
16
+ import pyperclip
17
+
18
+ CLIPBOARD_AVAILABLE = True
19
+ except ImportError:
20
+ CLIPBOARD_AVAILABLE = False
21
+
22
+
23
+ class AuthManager:
24
+ """Manages authentication for YouTube Music API"""
25
+
26
+ def __init__(self, config_path: str = "config.ini"):
27
+ self.config_path = config_path
28
+ self.config = configparser.ConfigParser()
29
+ self.config.read(config_path)
30
+
31
+ self.oauth_file = "oauth.json"
32
+ self.browser_file = "browser.json"
33
+
34
+ def is_auth_enabled(self) -> bool:
35
+ """Check if authentication is enabled"""
36
+ return self.config.getboolean("auth", "enabled", fallback=False)
37
+
38
+ def get_auth_method(self) -> str:
39
+ """Get the configured authentication method"""
40
+ return self.config.get("auth", "method", fallback="none")
41
+
42
+ def scan_for_credential_files(self) -> List[Tuple[str, Dict[str, str]]]:
43
+ """Scan for Google Cloud credential files"""
44
+ credential_files = []
45
+
46
+ # Define search patterns
47
+ patterns = [
48
+ "client_secret*.json",
49
+ "auth/client_secret*.json",
50
+ "credentials/client_secret*.json",
51
+ "*client_secret*.json",
52
+ ]
53
+
54
+ for pattern in patterns:
55
+ files = glob.glob(pattern)
56
+ for file_path in files:
57
+ try:
58
+ creds = self.parse_credential_file(file_path)
59
+ if creds:
60
+ credential_files.append((file_path, creds))
61
+ except Exception as e:
62
+ print(f"[yellow]Warning: Could not parse {file_path}: {e}[/yellow]")
63
+ continue
64
+
65
+ # Remove duplicates (same file found by different patterns)
66
+ seen_files = set()
67
+ unique_files = []
68
+ for file_path, creds in credential_files:
69
+ abs_path = os.path.abspath(file_path)
70
+ if abs_path not in seen_files:
71
+ seen_files.add(abs_path)
72
+ unique_files.append((file_path, creds))
73
+
74
+ return unique_files
75
+
76
+ def parse_credential_file(self, file_path: str) -> Optional[Dict[str, str]]:
77
+ """Parse Google Cloud credential JSON file"""
78
+ try:
79
+ with open(file_path) as f:
80
+ data = json.load(f)
81
+
82
+ # Handle different credential file formats
83
+ if "installed" in data:
84
+ # Desktop application credentials
85
+ client_info = data["installed"]
86
+ elif "web" in data:
87
+ # Web application credentials
88
+ client_info = data["web"]
89
+ else:
90
+ # Direct format (client_id and client_secret at root level)
91
+ client_info = data
92
+
93
+ # Extract required fields
94
+ client_id = client_info.get("client_id")
95
+ client_secret = client_info.get("client_secret")
96
+
97
+ if client_id and client_secret:
98
+ # Try to get project_id from different locations
99
+ project_id = data.get("project_id") or client_info.get("project_id") or "Unknown"
100
+ return {
101
+ "client_id": client_id,
102
+ "client_secret": client_secret,
103
+ "project_id": project_id,
104
+ }
105
+
106
+ return None
107
+
108
+ except (json.JSONDecodeError, FileNotFoundError, KeyError):
109
+ return None
110
+
111
+ def select_credential_file(
112
+ self, credential_files: List[Tuple[str, Dict[str, str]]]
113
+ ) -> Optional[Dict[str, str]]:
114
+ """Interactive selection of credential file"""
115
+ if not credential_files:
116
+ return None
117
+
118
+ if len(credential_files) == 1:
119
+ file_path, creds = credential_files[0]
120
+ print(f"[green]Found credential file: {file_path}[/green]")
121
+ response = input("Use this file? (Y/n): ").strip().lower()
122
+ if response != "n":
123
+ return creds
124
+ return None
125
+
126
+ # Multiple files found - let user choose
127
+ print(f"\n[cyan]Found {len(credential_files)} credential files:[/cyan]")
128
+ for i, (file_path, creds) in enumerate(credential_files, 1):
129
+ project_id = creds.get("project_id", "Unknown")
130
+ print(f"[{i}] {file_path} (Project: {project_id})")
131
+
132
+ while True:
133
+ try:
134
+ choice = input("\nSelect file number (or 'n' to skip): ").strip()
135
+ if choice.lower() == "n":
136
+ return None
137
+
138
+ index = int(choice) - 1
139
+ if 0 <= index < len(credential_files):
140
+ return credential_files[index][1]
141
+ else:
142
+ print("[red]Invalid selection. Please try again.[/red]")
143
+ except ValueError:
144
+ print("[red]Please enter a number or 'n'.[/red]")
145
+
146
+ def setup_oauth_auth(
147
+ self, client_id: str, client_secret: str, open_browser: bool = True
148
+ ) -> bool:
149
+ """Setup OAuth authentication"""
150
+ try:
151
+ print("[yellow]Setting up OAuth authentication...[/yellow]")
152
+
153
+ # Use ytmusicapi's setup_oauth function
154
+ setup_oauth(
155
+ filepath=self.oauth_file,
156
+ client_id=client_id,
157
+ client_secret=client_secret,
158
+ open_browser=open_browser,
159
+ )
160
+
161
+ # Update config
162
+ self._update_auth_config("oauth")
163
+ print("[green]OAuth authentication setup complete![/green]")
164
+ return True
165
+
166
+ except Exception as e:
167
+ error_str = str(e).lower()
168
+
169
+ # Check for specific OAuth verification error
170
+ if (
171
+ ("verification" in error_str and "process" in error_str)
172
+ or ("access_denied" in error_str)
173
+ or ("not completed" in error_str and "verification" in error_str)
174
+ ):
175
+ print(f"[red]OAuth verification error: {e}[/red]")
176
+ print("\n[yellow]🚨 This is a Google verification issue![/yellow]")
177
+ print("Your OAuth app needs to add test users or be verified.")
178
+ print("Run: [cyan]python -m ytm_cli auth troubleshoot[/cyan] for solutions")
179
+ print("Quick fix: [cyan]python -m ytm_cli auth setup-browser[/cyan]")
180
+ return False
181
+ else:
182
+ print(f"[red]OAuth setup failed: {e}[/red]")
183
+ return False
184
+
185
+ def setup_browser_auth(self, headers_raw: str) -> bool:
186
+ """Setup browser authentication from raw headers"""
187
+ try:
188
+ print("[yellow]Setting up browser authentication...[/yellow]")
189
+
190
+ # Use ytmusicapi's setup function to create proper format
191
+ from ytmusicapi.setup import setup
192
+
193
+ auth_string = setup(filepath=None, headers_raw=headers_raw)
194
+
195
+ # Save the auth string to browser.json
196
+ with open(self.browser_file, "w") as f:
197
+ f.write(auth_string)
198
+
199
+ # Update config
200
+ self._update_auth_config("browser")
201
+ print("[green]Browser authentication setup complete![/green]")
202
+ return True
203
+
204
+ except Exception as e:
205
+ print(f"[red]Browser setup failed: {e}[/red]")
206
+ return False
207
+
208
+ def setup_browser_auth_interactive(self, open_browser: bool = True) -> bool:
209
+ """Browser authentication setup with specific header extraction"""
210
+ print("\n[cyan]Browser Authentication Setup[/cyan]")
211
+
212
+ if open_browser:
213
+ print("[yellow]Opening YouTube Music in your browser...[/yellow]")
214
+ try:
215
+ webbrowser.open("https://music.youtube.com")
216
+ print("[green]Browser opened![/green]")
217
+ except Exception as e:
218
+ print(f"[red]Could not open browser: {e}[/red]")
219
+ print("Please manually open: https://music.youtube.com")
220
+
221
+ print("\n[yellow]📋 Step-by-Step Instructions:[/yellow]")
222
+ print("1. Make sure you're logged into YouTube Music")
223
+ print("2. Open Developer Tools (F12)")
224
+ print("3. Go to Network tab and clear it")
225
+ print("4. Click on any song or playlist to trigger a request")
226
+ print("5. In Network tab, filter by 'browse'")
227
+ print("6. Find a POST request to '/youtubei/v1/browse'")
228
+ print("7. Right-click the request → Copy → Copy as cURL")
229
+
230
+ print(
231
+ "\n[red]⚠️ IMPORTANT: cURL commands are ~5000 characters and will break terminal input![/red]"
232
+ )
233
+ print("[yellow]Instead, extract ONLY these specific headers:[/yellow]")
234
+ print()
235
+ print("From your cURL command, find and copy ONLY these lines:")
236
+ print("[cyan] -H 'cookie: ...'[/cyan]")
237
+ print("[cyan] -H 'user-agent: ...'[/cyan]")
238
+ print("[cyan] -H 'x-goog-visitor-id: ...'[/cyan]")
239
+ print("[cyan] -H 'x-youtube-client-name: ...'[/cyan]")
240
+ print("[cyan] -H 'x-youtube-client-version: ...'[/cyan]")
241
+
242
+ print("\n[green]✅ Simple clipboard method:[/green]")
243
+ print("1. Copy your cURL command to clipboard (Cmd+C)")
244
+ print("2. Press Enter - we'll read it directly from clipboard")
245
+ print("3. No pasting needed - keeps your terminal clean!")
246
+
247
+ choice = (
248
+ input("\nPress Enter when cURL is copied to clipboard (or 'q' to quit): ")
249
+ .strip()
250
+ .lower()
251
+ )
252
+ if choice == "q":
253
+ return False
254
+
255
+ return self._setup_browser_from_clipboard()
256
+
257
+ def _setup_browser_from_clipboard(self) -> bool:
258
+ """Setup browser auth from clipboard - simple and clean"""
259
+ if not CLIPBOARD_AVAILABLE:
260
+ print("[red]Clipboard not available. Install pyperclip: pip install pyperclip[/red]")
261
+ print("Falling back to file method...")
262
+ return self._setup_browser_from_file_with_guidance()
263
+
264
+ try:
265
+ print("[yellow]Reading cURL command from clipboard...[/yellow]")
266
+ clipboard_content = pyperclip.paste().strip()
267
+
268
+ if not clipboard_content:
269
+ print("[red]Clipboard is empty[/red]")
270
+ return False
271
+
272
+ # Basic validation - should contain curl and music.youtube.com
273
+ if not clipboard_content.startswith("curl"):
274
+ print("[red]Clipboard doesn't contain a cURL command[/red]")
275
+ print("Make sure you copied the full cURL command starting with 'curl'")
276
+ return False
277
+
278
+ if "music.youtube.com" not in clipboard_content:
279
+ print("[red]This doesn't look like a YouTube Music cURL command[/red]")
280
+ print("Make sure you copied the cURL from YouTube Music's Network tab")
281
+ return False
282
+
283
+ # Show length without showing content
284
+ length = len(clipboard_content)
285
+ print(f"[green]✓ Found cURL command ({length} characters)[/green]")
286
+
287
+ if length > 3000:
288
+ print("[green]✓ Large command detected - perfect for clipboard method![/green]")
289
+
290
+ print("[yellow]Processing headers silently...[/yellow]")
291
+ success = self.setup_browser_auth(clipboard_content)
292
+
293
+ if success:
294
+ print("[green]✓ Browser authentication setup complete![/green]")
295
+ print("[dim]Clipboard content was processed securely without display[/dim]")
296
+
297
+ return success
298
+
299
+ except Exception as e:
300
+ print(f"[red]Error reading clipboard: {e}[/red]")
301
+ print("Falling back to file method...")
302
+ return self._setup_browser_from_file_with_guidance()
303
+
304
+ def _setup_browser_with_header_guidance(self) -> bool:
305
+ """Guide user to extract specific headers from cURL command"""
306
+ print("\n[cyan]🎯 Header Extraction Method[/cyan]")
307
+ print("This method guides you to extract only the essential headers.")
308
+
309
+ print("\n[yellow]📋 Step-by-step extraction:[/yellow]")
310
+ print("1. Copy your cURL command to a text editor")
311
+ print("2. Look for these specific header lines:")
312
+ print(" [cyan]-H 'cookie: VISITOR_INFO1_LIVE=...; HSID=...; SSID=...'[/cyan]")
313
+ print(" [cyan]-H 'user-agent: Mozilla/5.0...'[/cyan]")
314
+ print(" [cyan]-H 'x-goog-visitor-id: Cgt...'[/cyan]")
315
+ print(" [cyan]-H 'x-youtube-client-name: 67'[/cyan]")
316
+ print(" [cyan]-H 'x-youtube-client-version: 1.20241...'[/cyan]")
317
+
318
+ print("\n[green]✅ Enter headers one by one (press Enter twice when done):[/green]")
319
+
320
+ headers_lines = []
321
+ empty_count = 0
322
+
323
+ while True:
324
+ try:
325
+ line = input("Header: ").strip()
326
+
327
+ if not line:
328
+ empty_count += 1
329
+ if empty_count >= 2:
330
+ break
331
+ continue
332
+ else:
333
+ empty_count = 0
334
+
335
+ # Validate header format
336
+ if line.startswith("-H '") and line.endswith("'"):
337
+ headers_lines.append(line)
338
+ print(f"[green]✓ Added header ({len(headers_lines)} total)[/green]")
339
+ elif ":" in line:
340
+ # Raw header format, convert to cURL format
341
+ headers_lines.append(f"-H '{line}'")
342
+ print(f"[green]✓ Added header ({len(headers_lines)} total)[/green]")
343
+ else:
344
+ print(
345
+ "[yellow]⚠️ Expected format: -H 'header-name: value' or 'header-name: value'[/yellow]"
346
+ )
347
+
348
+ except (EOFError, KeyboardInterrupt):
349
+ break
350
+
351
+ if not headers_lines:
352
+ print("[red]No valid headers provided[/red]")
353
+ return False
354
+
355
+ # Create a minimal cURL command with just the headers
356
+ curl_command = "curl 'https://music.youtube.com/youtubei/v1/browse' \\\n"
357
+ curl_command += " \\\n".join(headers_lines)
358
+
359
+ print(f"\n[green]Processing {len(headers_lines)} headers...[/green]")
360
+ return self.setup_browser_auth(curl_command)
361
+
362
+ def _setup_browser_from_file_with_guidance(self) -> bool:
363
+ """Setup browser auth from file with enhanced guidance"""
364
+ file_path = "curl_command.txt"
365
+
366
+ print("\n[yellow]📁 File Method for Large cURL Commands[/yellow]")
367
+ print("This method handles large (~5000 character) cURL commands safely.")
368
+
369
+ print("\n[cyan]Instructions:[/cyan]")
370
+ print("1. Copy your FULL cURL command")
371
+ print(f"2. Save it to: [cyan]{file_path}[/cyan]")
372
+ print("3. Come back here and press Enter")
373
+
374
+ print("\n[yellow]💡 Pro tip:[/yellow] You can use any text editor:")
375
+ print(f"• nano {file_path}")
376
+ print(f"• vim {file_path}")
377
+ print(f"• code {file_path}")
378
+ print("• Or copy-paste in your favorite editor")
379
+
380
+ input(f"\nPress Enter when you've saved the cURL command to {file_path}...")
381
+
382
+ try:
383
+ if not os.path.exists(file_path):
384
+ print(f"[red]File {file_path} not found[/red]")
385
+ print(f"Make sure you saved the cURL command to: [cyan]{file_path}[/cyan]")
386
+ return False
387
+
388
+ with open(file_path) as f:
389
+ curl_content = f.read().strip()
390
+
391
+ if not curl_content:
392
+ print(f"[red]File {file_path} is empty[/red]")
393
+ return False
394
+
395
+ if len(curl_content) > 1000:
396
+ print(f"[green]✓ Large command detected ({len(curl_content)} characters)[/green]")
397
+ else:
398
+ print(
399
+ f"[yellow]⚠️ Small command ({len(curl_content)} characters) - verify it's complete[/yellow]"
400
+ )
401
+
402
+ print("[yellow]Processing cURL command...[/yellow]")
403
+ success = self.setup_browser_auth(curl_content)
404
+
405
+ if success:
406
+ # Ask if user wants to delete the file for security
407
+ response = (
408
+ input(f"\n[yellow]Delete {file_path} for security? (Y/n): [/yellow]")
409
+ .strip()
410
+ .lower()
411
+ )
412
+ if response != "n":
413
+ try:
414
+ os.remove(file_path)
415
+ print(f"[green]✓ Deleted {file_path}[/green]")
416
+ except Exception as e:
417
+ print(f"[yellow]Could not delete {file_path}: {e}[/yellow]")
418
+
419
+ return success
420
+
421
+ except Exception as e:
422
+ print(f"[red]Error reading file: {e}[/red]")
423
+ return False
424
+
425
+ def _setup_browser_from_file(self) -> bool:
426
+ """Setup browser auth from file"""
427
+ file_path = "headers.txt"
428
+ print("\n[yellow]File Method Selected[/yellow]")
429
+ print(f"1. Save your cURL command or headers to: [cyan]{file_path}[/cyan]")
430
+ print("2. Press Enter when ready")
431
+
432
+ input("Press Enter when you've saved the file...")
433
+
434
+ try:
435
+ if not os.path.exists(file_path):
436
+ print(f"[red]File {file_path} not found[/red]")
437
+ return False
438
+
439
+ with open(file_path) as f:
440
+ headers_raw = f.read().strip()
441
+
442
+ if not headers_raw:
443
+ print(f"[red]File {file_path} is empty[/red]")
444
+ return False
445
+
446
+ print(f"[green]Read {len(headers_raw.split())} words from file[/green]")
447
+
448
+ # Ask if user wants to delete the file
449
+ response = input(f"Delete {file_path} after setup? (Y/n): ").strip().lower()
450
+ success = self.setup_browser_auth(headers_raw)
451
+
452
+ if success and response != "n":
453
+ try:
454
+ os.remove(file_path)
455
+ print(f"[yellow]Deleted {file_path}[/yellow]")
456
+ except Exception as e:
457
+ print(f"[yellow]Could not delete {file_path}: {e}[/yellow]")
458
+
459
+ return success
460
+
461
+ except Exception as e:
462
+ print(f"[red]Error reading file: {e}[/red]")
463
+ return False
464
+
465
+ def _setup_browser_interactive(self) -> bool:
466
+ """Interactive browser auth setup with smart paste handling"""
467
+ print("\n[yellow]Interactive Method Selected[/yellow]")
468
+ print("Smart paste detection enabled!")
469
+ print("\n[cyan]Instructions:[/cyan]")
470
+ print("1. Paste your cURL command or headers")
471
+ print("2. After pasting, just press Enter once on an empty line")
472
+ print("3. The system will automatically detect completion")
473
+ print("-" * 60)
474
+
475
+ try:
476
+ headers_input = []
477
+ empty_line_count = 0
478
+
479
+ print("Ready for input (paste now):")
480
+
481
+ while True:
482
+ try:
483
+ line = input()
484
+
485
+ # Check for manual termination
486
+ if line.strip().upper() == "END":
487
+ break
488
+
489
+ # Smart paste detection logic
490
+ if line.strip() == "":
491
+ empty_line_count += 1
492
+
493
+ # If we have content and hit an empty line, that's likely end of paste
494
+ if headers_input and empty_line_count >= 1:
495
+ print(
496
+ f"\n[green]Detected end of paste ({len(headers_input)} lines)[/green]"
497
+ )
498
+ break
499
+ else:
500
+ # Reset empty line counter when we get content
501
+ empty_line_count = 0
502
+ headers_input.append(line)
503
+
504
+ # If this looks like a curl command, it might be a single line
505
+ if line.strip().startswith("curl") and len(line) > 100:
506
+ print(
507
+ "\n[cyan]Large cURL command detected. Press Enter to finish.[/cyan]"
508
+ )
509
+
510
+ except EOFError:
511
+ # Ctrl+D pressed - natural end
512
+ if headers_input:
513
+ print(f"\n[green]Input completed ({len(headers_input)} lines)[/green]")
514
+ break
515
+ except KeyboardInterrupt:
516
+ print("\n[yellow]Setup cancelled[/yellow]")
517
+ return False
518
+
519
+ if not headers_input:
520
+ print("[red]No headers provided[/red]")
521
+ return False
522
+
523
+ headers_raw = "\n".join(headers_input)
524
+
525
+ if not headers_raw.strip():
526
+ print("[red]No valid content provided[/red]")
527
+ return False
528
+
529
+ print(f"[green]Processing {len(headers_input)} lines of input...[/green]")
530
+ return self.setup_browser_auth(headers_raw)
531
+
532
+ except KeyboardInterrupt:
533
+ print("\n[yellow]Setup cancelled[/yellow]")
534
+ return False
535
+
536
+ def _setup_browser_simple(self) -> bool:
537
+ """Simple fallback browser auth setup"""
538
+ print("\n[yellow]Simple Input Mode[/yellow]")
539
+ print("Paste your content and press Enter, then type 'END' and press Enter:")
540
+ print("-" * 50)
541
+
542
+ try:
543
+ headers_input = []
544
+
545
+ while True:
546
+ try:
547
+ line = input()
548
+
549
+ if line.strip().upper() == "END":
550
+ break
551
+
552
+ headers_input.append(line)
553
+
554
+ except EOFError:
555
+ break
556
+ except KeyboardInterrupt:
557
+ print("\n[yellow]Setup cancelled[/yellow]")
558
+ return False
559
+
560
+ if not headers_input:
561
+ print("[red]No headers provided[/red]")
562
+ return False
563
+
564
+ headers_raw = "\n".join(headers_input)
565
+ return self.setup_browser_auth(headers_raw)
566
+
567
+ except KeyboardInterrupt:
568
+ print("\n[yellow]Setup cancelled[/yellow]")
569
+ return False
570
+
571
+ def disable_auth(self) -> bool:
572
+ """Disable authentication"""
573
+ try:
574
+ self._update_auth_config("none", enabled=False)
575
+
576
+ # Optionally remove auth files
577
+ for auth_file in [self.oauth_file, self.browser_file]:
578
+ if os.path.exists(auth_file):
579
+ response = input(f"Remove {auth_file}? (y/N): ")
580
+ if response.lower() == "y":
581
+ os.remove(auth_file)
582
+ print(f"[yellow]Removed {auth_file}[/yellow]")
583
+
584
+ print("[green]Authentication disabled[/green]")
585
+ return True
586
+
587
+ except Exception as e:
588
+ print(f"[red]Error disabling auth: {e}[/red]")
589
+ return False
590
+
591
+ def get_ytmusic_instance(self) -> YTMusic:
592
+ """Get YTMusic instance with appropriate authentication"""
593
+ if not self.is_auth_enabled():
594
+ return YTMusic()
595
+
596
+ method = self.get_auth_method()
597
+
598
+ try:
599
+ if method == "oauth" and os.path.exists(self.oauth_file):
600
+ print("[dim]Using OAuth authentication[/dim]")
601
+ return YTMusic(self.oauth_file)
602
+
603
+ elif method == "browser" and os.path.exists(self.browser_file):
604
+ print("[dim]Using browser authentication[/dim]")
605
+ return YTMusic(self.browser_file)
606
+
607
+ else:
608
+ print(
609
+ f"[yellow]Auth method '{method}' configured but credentials not found, using unauthenticated[/yellow]"
610
+ )
611
+ return YTMusic()
612
+
613
+ except Exception as e:
614
+ print(f"[red]Authentication failed: {e}[/red]")
615
+ print("[yellow]Falling back to unauthenticated access[/yellow]")
616
+ return YTMusic()
617
+
618
+ def get_auth_status(self) -> Dict[str, Any]:
619
+ """Get current authentication status"""
620
+ status = {
621
+ "enabled": self.is_auth_enabled(),
622
+ "method": self.get_auth_method(),
623
+ "oauth_file_exists": os.path.exists(self.oauth_file),
624
+ "browser_file_exists": os.path.exists(self.browser_file),
625
+ }
626
+ return status
627
+
628
+ def _update_auth_config(self, method: str, enabled: bool = True):
629
+ """Update authentication configuration"""
630
+ if "auth" not in self.config:
631
+ self.config.add_section("auth")
632
+
633
+ self.config.set("auth", "enabled", str(enabled))
634
+ self.config.set("auth", "method", method)
635
+
636
+ with open(self.config_path, "w") as f:
637
+ self.config.write(f)
638
+
639
+ def _parse_headers(self, headers_raw: str) -> Dict[str, Any]:
640
+ """Parse headers from cURL or raw format"""
641
+ auth_data = {}
642
+
643
+ # Check if it's a cURL command
644
+ if headers_raw.strip().startswith("curl"):
645
+ auth_data = self._parse_curl_headers(headers_raw)
646
+ else:
647
+ auth_data = self._parse_raw_headers(headers_raw)
648
+
649
+ return auth_data
650
+
651
+ def _parse_curl_headers(self, curl_command: str) -> Dict[str, Any]:
652
+ """Parse headers from cURL command"""
653
+ import re
654
+
655
+ auth_data = {}
656
+
657
+ # Extract headers from cURL command
658
+ header_pattern = r"-H '([^:]+): ([^']+)'"
659
+ matches = re.findall(header_pattern, curl_command)
660
+
661
+ for header_name, header_value in matches:
662
+ # Map common headers needed for ytmusicapi
663
+ if header_name.lower() == "cookie":
664
+ auth_data["Cookie"] = header_value
665
+ elif header_name.lower() == "user-agent":
666
+ auth_data["User-Agent"] = header_value
667
+ elif header_name.lower() == "x-goog-visitor-id":
668
+ auth_data["X-Goog-Visitor-Id"] = header_value
669
+ elif header_name.lower() == "x-youtube-client-name":
670
+ auth_data["X-YouTube-Client-Name"] = header_value
671
+ elif header_name.lower() == "x-youtube-client-version":
672
+ auth_data["X-YouTube-Client-Version"] = header_value
673
+
674
+ return auth_data
675
+
676
+ def _parse_raw_headers(self, headers_raw: str) -> Dict[str, Any]:
677
+ """Parse headers from raw format"""
678
+ auth_data = {}
679
+
680
+ lines = headers_raw.strip().split("\n")
681
+ for line in lines:
682
+ if ":" in line:
683
+ key, value = line.split(":", 1)
684
+ key = key.strip()
685
+ value = value.strip()
686
+
687
+ # Only include relevant headers
688
+ if key.lower() in [
689
+ "cookie",
690
+ "user-agent",
691
+ "x-goog-visitor-id",
692
+ "x-youtube-client-name",
693
+ "x-youtube-client-version",
694
+ ]:
695
+ auth_data[key] = value
696
+
697
+ return auth_data