light-phone-cli-tui 0.1.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ __pycache__
2
+ *.pyc
3
+ _build
4
+ .env
5
+ .venv
6
+ tests/fixtures/
7
+ .coverage
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: light-phone-cli-tui
3
+ Version: 0.1.0
4
+ Summary: Unofficial CLI/TUI for the Light Phone
5
+ Author-email: Alexis Garado <alexisgarado@proton.me>
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: click
8
+ Requires-Dist: light-phone-api
9
+ Requires-Dist: pyperclip
10
+ Requires-Dist: rich
11
+ Requires-Dist: rich-click
12
+ Requires-Dist: textual
File without changes
@@ -0,0 +1,595 @@
1
+ """
2
+ █░░ █ █▀▀ █░█ ▀█▀   █▀▀ █░░ █   ▄█▄   ▀█▀ █░█ █
3
+ █▄▄ █ █▄█ █▀█ ░█░   █▄▄ █▄▄ █   ░▀░   ░█░ █▄█ █
4
+
5
+ Command line tools for Light devices.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ import rich_click as click
11
+ from rich.console import Console
12
+ from rich.progress import Progress, TaskID, TextColumn, BarColumn, TaskProgressColumn
13
+ from rich.table import Table
14
+
15
+ from light_api.client import Light
16
+ from light_api.music import SortMode
17
+ from light_api.tools import ToolName
18
+ from light_api import with_light
19
+ from light_cli_tui.tui import LightConfig, run_tui
20
+
21
+
22
+ click.rich_click.USE_RICH_MARKUP = True
23
+ click.rich_click.USE_MARKDOWN = True
24
+ click.rich_click.SHOW_ARGUMENTS = True
25
+ click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
26
+ click.rich_click.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (1, 3)
27
+
28
+ console = Console()
29
+ log = logging.getLogger(f"light.{__name__}")
30
+
31
+
32
+ @click.group()
33
+ @click.option("--email", default=None, help="Light account email address.")
34
+ @click.option("--email-file", default=None, help="Path to file containing email.")
35
+ @click.option("--password", default=None, help="Light account password.")
36
+ @click.option("--password-file", default=None, help="Path to file containing password.")
37
+ @click.option("--phone-number", default=None, help="Phone number.")
38
+ @click.option(
39
+ "--phone-number-file", default=None, help="Path to file containing phone number."
40
+ )
41
+ @click.option(
42
+ "--log-level",
43
+ default="WARNING",
44
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
45
+ help="Log level.",
46
+ )
47
+ @click.pass_context
48
+ def cli(
49
+ ctx,
50
+ email,
51
+ email_file,
52
+ password,
53
+ password_file,
54
+ phone_number,
55
+ phone_number_file,
56
+ log_level,
57
+ ):
58
+ """**Unofficial CLI for the Light Phone.**
59
+
60
+ Manages music, podcasts, and notes on your Light device from the terminal.
61
+
62
+ Credentials can be provided via options, files, or environment variables
63
+ (`LIGHT_EMAIL`, `LIGHT_PASSWORD`, `LIGHT_PHONE_NUMBER`).
64
+ """
65
+ logging.basicConfig(format="%(name)s %(levelname)s %(message)s")
66
+ logging.getLogger("light").setLevel(log_level.upper())
67
+
68
+ ctx.ensure_object(dict)
69
+ ctx.obj.update(
70
+ {
71
+ "email": email,
72
+ "email_file": email_file,
73
+ "password": password,
74
+ "password_file": password_file,
75
+ "phone_number": phone_number,
76
+ "phone_number_file": phone_number_file,
77
+ }
78
+ )
79
+
80
+
81
+ @cli.group()
82
+ def music():
83
+ """Music library management.
84
+
85
+ Upload tracks, delete them, sort your playlist, and update metadata.
86
+ """
87
+ pass
88
+
89
+
90
+ @cli.group()
91
+ def podcast():
92
+ """Podcast management.
93
+
94
+ Add podcasts by RSS feed URL and remove ones you no longer want.
95
+ """
96
+ pass
97
+
98
+
99
+ @cli.group()
100
+ def notes():
101
+ """Notes management.
102
+
103
+ List, add, download, and watch for changes to text and audio notes.
104
+ """
105
+ pass
106
+
107
+
108
+ # -- Podcast commands ----------------------------------------------------------
109
+
110
+
111
+ @podcast.command("add")
112
+ @with_light
113
+ @click.argument("rss_feed_url")
114
+ def podcast_add(light: Light, rss_feed_url):
115
+ """Subscribe to a podcast by RSS feed URL.
116
+
117
+ The server resolves the title and publisher automatically from the feed.
118
+
119
+ **Example:**
120
+
121
+ `light podcast add https://feeds.simplecast.com/FO6kxYGj`
122
+ """
123
+ p = light.podcast.add_podcast(rss_feed_url)
124
+ console.print(f"[green]Added:[/green] {p.title or rss_feed_url}")
125
+ if p.publisher:
126
+ console.print(f"[dim]Publisher:[/dim] {p.publisher}")
127
+
128
+
129
+ @podcast.command("list")
130
+ @with_light
131
+ def podcast_list(light: Light):
132
+ """List all followed podcasts on your device."""
133
+ podcasts = light.podcast.get_podcasts()
134
+
135
+ if not podcasts:
136
+ console.print("[dim]No podcasts followed.[/dim]")
137
+ return
138
+
139
+ table = Table(show_header=True)
140
+ table.add_column("#", style="dim", width=4)
141
+ table.add_column("Title")
142
+ table.add_column("Publisher")
143
+
144
+ for i, p in enumerate(podcasts, 1):
145
+ table.add_row(str(i), p.title, p.publisher)
146
+
147
+ console.print(table)
148
+
149
+
150
+ @podcast.command("delete")
151
+ @with_light
152
+ @click.argument("title")
153
+ def podcast_delete(light: Light, title):
154
+ """Unfollow a podcast by title.
155
+
156
+ Uses exact title matching. Run `light podcast list` to see titles.
157
+ """
158
+ podcasts = light.podcast.get_podcasts()
159
+ matches = [p for p in podcasts if p.title == title]
160
+
161
+ if not matches:
162
+ console.print(f"[yellow]No podcast found with title: {title}[/yellow]")
163
+ return
164
+
165
+ for p in matches:
166
+ console.print(f" {p.title}")
167
+ if not click.confirm("Unfollow?"):
168
+ return
169
+
170
+ light.podcast.delete_podcast_by_title(title)
171
+
172
+
173
+ # -- Music commands -------------------------------------------------------------
174
+
175
+
176
+ @music.command("upload")
177
+ @with_light
178
+ @click.argument("songs", nargs=-1, required=True)
179
+ @click.option(
180
+ "--allow-duplicates",
181
+ is_flag=True,
182
+ help="Skip duplicate checking and always upload.",
183
+ )
184
+ @click.option(
185
+ "--match-title-by",
186
+ "-m",
187
+ type=click.Choice(["filename", "metadata"]),
188
+ default="metadata",
189
+ show_default=True,
190
+ help="How to match existing tracks when checking for duplicates.",
191
+ )
192
+ @click.option(
193
+ "--no-convert-flac",
194
+ is_flag=True,
195
+ default=False,
196
+ help="Skip FLAC to MP3 conversion (conversion is on by default to preserve metadata).",
197
+ )
198
+ def music_upload(light: Light, songs, allow_duplicates, match_title_by, no_convert_flac):
199
+ """Upload one or more audio files to your device.
200
+
201
+ Duplicate detection is on by default - existing tracks with a matching
202
+ title will be replaced. Use `--allow-duplicates` to skip this.
203
+
204
+ **Example:**
205
+
206
+ `light music upload track1.mp3 track2.mp3`
207
+ """
208
+ files = list(songs)
209
+
210
+ if not allow_duplicates:
211
+ from mutagen._file import File as MutagenFile
212
+ import os as _os
213
+
214
+ if match_title_by == "metadata":
215
+ titles = []
216
+ for s in files:
217
+ f = MutagenFile(s, easy=True)
218
+ titles.append(
219
+ f.get("title", [_os.path.splitext(_os.path.basename(s))[0]])[0]
220
+ if f
221
+ else _os.path.splitext(_os.path.basename(s))[0]
222
+ )
223
+ else:
224
+ titles = [_os.path.splitext(_os.path.basename(s))[0] for s in files]
225
+
226
+ existing = light.music.get_tracks()
227
+ to_overwrite = [t for t in existing if t.title in set(titles)]
228
+
229
+ if to_overwrite:
230
+ console.print(f"Tracks to overwrite ({len(to_overwrite)}):")
231
+ for t in to_overwrite:
232
+ console.print(f" {t.artist} — {t.title}")
233
+ if not click.confirm("Proceed?"):
234
+ return
235
+
236
+ with Progress(
237
+ TextColumn("[progress.description]{task.description}"),
238
+ BarColumn(),
239
+ TaskProgressColumn(),
240
+ console=console,
241
+ ) as progress:
242
+ task_id: TaskID | None = None
243
+ current_file: str | None = None
244
+
245
+ def on_progress(filename: str, sent: int, total: int) -> None:
246
+ nonlocal task_id, current_file
247
+ if filename != current_file:
248
+ if task_id is not None:
249
+ progress.update(task_id, completed=100)
250
+ current_file = filename
251
+ task_id = progress.add_task(f"uploading {filename}", total=100)
252
+ progress.update(task_id, completed=int(sent / total * 100))
253
+
254
+ light.music.upload_tracks(
255
+ files,
256
+ allow_duplicates=allow_duplicates,
257
+ match_title_by=match_title_by,
258
+ convert_flac=not no_convert_flac,
259
+ on_progress=on_progress,
260
+ )
261
+
262
+
263
+ @music.command("delete-all")
264
+ @with_light
265
+ def music_delete_all(light: Light):
266
+ """Delete ALL tracks on device."""
267
+ if not click.confirm("This will delete ALL tracks on the device. Proceed?"):
268
+ return
269
+
270
+ if input('Type "yes i am sure" to confirm: ') != "yes i am sure":
271
+ return
272
+
273
+ light.music.delete_all_tracks()
274
+
275
+
276
+ @music.command("delete")
277
+ @with_light
278
+ @click.argument("songs", nargs=-1, required=True)
279
+ def music_delete(light: Light, songs):
280
+ """Delete tracks by title.
281
+
282
+ Uses exact title matching. Run `light music list` to see track titles.
283
+
284
+ **Example:**
285
+
286
+ `light music delete "Song Title" "Another Song"`
287
+ """
288
+ titles = list(songs)
289
+ tracks = light.music.get_tracks()
290
+ to_delete = [t for t in tracks if t.title in set(titles)]
291
+
292
+ if not to_delete:
293
+ console.print("[yellow]No matching tracks.[/yellow]")
294
+ return
295
+
296
+ console.print(f"Tracks to delete ({len(to_delete)}):")
297
+ for t in to_delete:
298
+ console.print(f" {t.artist} — {t.title}")
299
+ if not click.confirm("Proceed?"):
300
+ return
301
+
302
+ light.music.delete_tracks_by_title(titles)
303
+
304
+
305
+ @music.command("sort")
306
+ @with_light
307
+ @click.argument("field", type=click.Choice(["artist", "title", "artist-album", "none"]))
308
+ @click.option(
309
+ "--asc",
310
+ "order",
311
+ flag_value="ascending",
312
+ default=True,
313
+ help="Sort ascending (default).",
314
+ )
315
+ @click.option("--desc", "order", flag_value="descending", help="Sort descending.")
316
+ def music_sort(light: Light, field, order):
317
+ """Sort tracks by artist, title, or reset to manual order.
318
+
319
+ `none` resets to the manual ordering you set in the app.
320
+
321
+ **Examples:**
322
+
323
+ `light music sort artist --desc`
324
+
325
+ `light music sort title`
326
+
327
+ `light music sort none`
328
+ """
329
+ descending = order == "descending"
330
+
331
+ if field == "artist":
332
+ light.music.set_sort_mode(
333
+ SortMode.ARTIST_DESC if descending else SortMode.ARTIST_ASC
334
+ )
335
+ elif field == "title":
336
+ light.music.set_sort_mode(
337
+ SortMode.TITLE_DESC if descending else SortMode.TITLE_ASC
338
+ )
339
+ elif field == "artist-album":
340
+ light.music.set_sort_mode(
341
+ SortMode.ARTIST_ALBUM_DESC if descending else SortMode.ARTIST_ALBUM_ASC
342
+ )
343
+ elif field == "none":
344
+ light.music.set_sort_mode(SortMode.RANK)
345
+
346
+
347
+ @music.command("update")
348
+ @with_light
349
+ @click.argument("title")
350
+ @click.option("--new-title", default=None, help="New track title.")
351
+ @click.option("--new-artist", default=None, help="New artist name.")
352
+ @click.option("--new-album", default=None, help="New album name.")
353
+ def music_update(light: Light, title, new_title, new_artist, new_album):
354
+ """Update metadata for a track.
355
+
356
+ Matches by exact title. At least one of `--new-title`, `--new-artist`, or `--new-album` must be provided.
357
+
358
+ **Example:**
359
+
360
+ `light music update "Old Title" --new-title "New Title" --new-artist "Artist" --new-album "Album"`
361
+ """
362
+ tracks = light.music.get_tracks()
363
+ matches = [t for t in tracks if t.title == title]
364
+
365
+ if not matches:
366
+ console.print(f"[yellow]No track found with title: {title}[/yellow]")
367
+ return
368
+
369
+ for track in matches:
370
+ light.music.update_track_metadata(
371
+ track.audio_id, title=new_title, artist=new_artist, album=new_album
372
+ )
373
+
374
+
375
+ @music.command("list")
376
+ @with_light
377
+ def music_list(light: Light):
378
+ """List all tracks on your device."""
379
+ tracks = light.music.get_tracks()
380
+
381
+ table = Table(show_header=True)
382
+ table.add_column("#", style="dim", width=4)
383
+ table.add_column("Title")
384
+ table.add_column("Artist")
385
+ table.add_column("Album")
386
+
387
+ for i, track in enumerate(tracks, 1):
388
+ table.add_row(str(i), track.title, track.artist, track.album)
389
+
390
+ console.print(table)
391
+
392
+
393
+ # -- Notes commands -------------------------------------------------------------
394
+
395
+
396
+ @notes.command("list")
397
+ @with_light
398
+ @click.option(
399
+ "--id",
400
+ "-i",
401
+ "show_id",
402
+ default=False,
403
+ type=bool,
404
+ is_flag=True,
405
+ help="Include note ID in output (use with `notes watch`).",
406
+ )
407
+ @click.option(
408
+ "--content-preview",
409
+ "-c",
410
+ default=False,
411
+ type=bool,
412
+ is_flag=True,
413
+ help="Include content preview in output.",
414
+ )
415
+ def notes_list(light: Light, show_id=False, content_preview=False):
416
+ """List all notes on your device.
417
+
418
+ Shows the first line of text notes and labels audio notes.
419
+ """
420
+ all_notes = light.notes.get_notes()
421
+
422
+ if content_preview:
423
+ console.print(f"[dim]Content preview enabled. This might take a while.[/dim]")
424
+
425
+ for i, note in enumerate(all_notes, 1):
426
+ if content_preview:
427
+ content = light.notes.get_note_content(note)
428
+ else:
429
+ content = ""
430
+
431
+ if note.note_type == "audio":
432
+ preview = f"[dim](audio)[/dim] {note.title}"
433
+ elif content and content.strip():
434
+ preview = f"[dim]({note.title})[/dim] {content.splitlines()[0]}"
435
+ else:
436
+ preview = "[dim](empty)[/dim]"
437
+
438
+ id_prefix = f"{note.id} " if show_id else ""
439
+ console.print(f"[dim]{i}.[/dim] {id_prefix}{preview}")
440
+
441
+
442
+ @notes.command("download")
443
+ @with_light
444
+ @click.argument("path")
445
+ def notes_download(light: Light, path: str):
446
+ """Download all notes to a directory.
447
+
448
+ Text notes are saved as `.txt`, audio notes as `.m4a`.
449
+ If two notes share a title, the timestamp is appended to disambiguate.
450
+
451
+ **Example:**
452
+
453
+ `light notes download ~/my-notes`
454
+ """
455
+ light.notes.download_notes(path)
456
+
457
+
458
+ @notes.command("add")
459
+ @with_light
460
+ @click.argument("title")
461
+ @click.argument("content", default=None, required=False)
462
+ @click.option(
463
+ "--file",
464
+ "-f",
465
+ "content_file",
466
+ default=None,
467
+ type=click.Path(exists=True),
468
+ help="Read note content from a file instead of inline.",
469
+ )
470
+ def notes_add(light: Light, title: str, content: str | None, content_file: str | None):
471
+ """Create a new text note.
472
+
473
+ Provide content inline as an argument, or from a file with `--file`.
474
+
475
+ **Examples:**
476
+
477
+ `light notes add "Shopping list" "eggs, milk, bread"`
478
+
479
+ `light notes add "Meeting notes" --file notes.txt`
480
+ """
481
+ if content is None and content_file is None:
482
+ raise click.UsageError("Provide CONTENT or --file.")
483
+
484
+ if content is not None and content_file is not None:
485
+ raise click.UsageError("CONTENT and --file are mutually exclusive.")
486
+
487
+ if content_file:
488
+ light.notes.create_text_note(title, content_file, content_is_path=True)
489
+ else:
490
+ light.notes.create_text_note(title, content)
491
+
492
+
493
+ @notes.command("watch")
494
+ @with_light
495
+ @click.argument("note_id")
496
+ def notes_watch(light: Light, note_id: str):
497
+ """Poll a note for changes and print its content when updated.
498
+
499
+ Checks every 5 seconds and prints content when `updated_at` changes.
500
+ Useful for watching a note you're actively editing on your phone.
501
+
502
+ Run `light notes list --id` to find the note ID.
503
+
504
+ **Example:**
505
+
506
+ `light notes watch 4f1d3063-085b-4738-8ba1-582c5d1cd9ac`
507
+ """
508
+ note = light.notes.get_note_metadata(note_id)
509
+ last_updated_at = note.updated_at
510
+
511
+ while True:
512
+ time.sleep(5)
513
+ note = light.notes.get_note_metadata(note_id)
514
+ if note.updated_at != last_updated_at:
515
+ content = light.notes.get_note_content(note)
516
+ console.print(f"[green]Updated at {note.updated_at}:[/green]")
517
+ console.print(content.decode())
518
+ last_updated_at = note.updated_at
519
+
520
+
521
+ # -- Tools commands ------------------------------------------------------------
522
+
523
+
524
+ @cli.group()
525
+ def tools():
526
+ """Installed tools introspection."""
527
+ pass
528
+
529
+
530
+ @tools.command("list")
531
+ @with_light
532
+ def tools_list(light: Light):
533
+ """List all tools installed on your device."""
534
+ all_tools = light.tools.get_tools()
535
+
536
+ table = Table(show_header=True)
537
+ table.add_column("Title")
538
+ table.add_column("Namespace")
539
+
540
+ for t in all_tools:
541
+ table.add_row(t.title, t.namespace)
542
+
543
+ console.print(table)
544
+
545
+
546
+ @tools.command("add")
547
+ @with_light
548
+ @click.argument(
549
+ "name", type=click.Choice([t.value for t in ToolName], case_sensitive=False)
550
+ )
551
+ def tools_add(light: Light, name: str):
552
+ """Install a tool on your device."""
553
+ tool = light.tools.add_tool(name)
554
+ console.print(f"[green]Installed:[/green] {tool.title}")
555
+
556
+
557
+ @tools.command("remove")
558
+ @with_light
559
+ @click.argument(
560
+ "name", type=click.Choice([t.value for t in ToolName], case_sensitive=False)
561
+ )
562
+ def tools_remove(light: Light, name: str):
563
+ """Uninstall a tool from your device."""
564
+ if not click.confirm(f"Remove {name}?"):
565
+ return
566
+ light.tools.remove_tool(name)
567
+ console.print("[green]Removed.[/green]")
568
+
569
+
570
+ # -- TUI ------------------------------------------------------------------------
571
+
572
+
573
+ @cli.command()
574
+ @click.pass_context
575
+ def tui(ctx):
576
+ """Launch the interactive terminal UI.
577
+
578
+ A full-screen interface for browsing and managing your music library
579
+ with vim-style keybindings.
580
+ """
581
+ obj = ctx.obj or {}
582
+ run_tui(
583
+ LightConfig(
584
+ email=obj.get("email"),
585
+ email_file=obj.get("email_file"),
586
+ password=obj.get("password"),
587
+ password_file=obj.get("password_file"),
588
+ phone=obj.get("phone_number"),
589
+ phone_file=obj.get("phone_number_file"),
590
+ )
591
+ )
592
+
593
+
594
+ if __name__ == "__main__":
595
+ cli()