Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
textual
pyperclip
light-phone-api
inquirerpy
rapidfuzz
];
};

Expand All @@ -62,6 +64,8 @@
textual
httpx
attrs
questionary
rapidfuzz
Comment on lines +67 to +68
]))
uv
pyright
Expand Down
72 changes: 61 additions & 11 deletions light_cli_tui/light_cli_tui/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
from light_api.music import SortMode
from light_api.tools import ToolName
from light_api import with_light
from light_cli_tui.interactive import (
confirm_selection_with_repick,
fuzzy_pick_best,
fuzzy_pick_interactive,
)
from light_cli_tui.output import render, render_error
from light_cli_tui.tui import LightConfig, run_tui

Expand Down Expand Up @@ -401,16 +406,27 @@ def music_delete_all(light: Light):
@click.option(
"--album", "-b", "album_regex", help="Delete tracks whose album matches this regex pattern."
)
def music_delete(light: Light, songs, title_regex, artist_regex, album_regex):
"""Delete tracks by title, or by regex pattern.
@click.option(
"--interactive",
"-i",
is_flag=True,
default=False,
help="Immediately open interactive deletion menu."
)
def music_delete(light: Light, songs, title_regex, artist_regex, album_regex, interactive):
"""Delete tracks by fuzzy search, or by regex pattern.

Uses exact title matching. Run `light music list` to see track titles.
Each SONGS argument is fuzzy-matched against every track's title, artist,
and album. By default, the single best match per query is auto-selected;
pass --interactive to pick matches by hand instead.

If more than one of --title, --artist, --album regex patterns are given, tracks must match all of them.

**Examples:**

`light music delete "Song Title" "Another Song"`
`light music delete "Playing God"`

`light music delete -i "Playing God"`

`light music delete --title '^Live '`

Expand All @@ -428,6 +444,16 @@ def music_delete(light: Light, songs, title_regex, artist_regex, album_regex):

tracks = light.music.get_tracks()

def repick():
return fuzzy_pick_interactive(
songs,
tracks,
fields=lambda t: (t.title, t.artist, t.album),
label=lambda t: f"{t.artist} — {t.album} — {t.title}",
id_key=lambda t: t.audio_id,
console=console,
)

if regex_given:
try:
title_pattern = re.compile(title_regex) if title_regex else None
Expand All @@ -444,18 +470,42 @@ def music_delete(light: Light, songs, title_regex, artist_regex, album_regex):
and (album_pattern is None or album_pattern.match(t.album))
]
else:
titles = list(songs)
to_delete = [t for t in tracks if t.title in set(titles)]
if interactive:
selected = repick()
else:
selected = fuzzy_pick_best(
songs,
tracks,
fields=lambda t: (t.title, t.artist, t.album),
id_key=lambda t: t.audio_id,
Comment on lines +476 to +480
console=console,
)
if selected is None:
console.print("[yellow]Aborted.[/yellow]")
return
to_delete = list(selected.values())

if not to_delete:
console.print("[yellow]No matching tracks.[/yellow]")
return

console.print(f"Tracks to delete ({len(to_delete)}):")
for t in to_delete:
console.print(f" {t.artist} — {t.title}")
if not click.confirm("Proceed?"):
return
if regex_given:
console.print(f"Tracks to delete ({len(to_delete)}):")
for t in to_delete:
console.print(f" {t.artist} — {t.title}")
if not click.confirm("Proceed?"):
return
else:
result = confirm_selection_with_repick(
{t.audio_id: t for t in to_delete},
label=lambda t: f"{t.artist} — {t.album} - {t.title}",
header="Tracks to delete",
repick=repick,
console=console,
)
if result is None:
return
to_delete = result

audio_ids = {t.audio_id for t in to_delete}
light.music.delete_tracks_predicate(lambda t: t.audio_id in audio_ids)
Expand Down
54 changes: 54 additions & 0 deletions light_cli_tui/light_cli_tui/fuzzy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Fuzzy matching utilities."""

from typing import Callable, Iterable, TypeVar
from rapidfuzz import fuzz

T = TypeVar("T")

DEFAULT_FUZZY_THRESHOLD = 80


def fuzzy_score(
query: str, *fields: str, threshold: int = DEFAULT_FUZZY_THRESHOLD
) -> float | None:
"""Score a query against one or more fields.

A literal case-insensitive substring match in any field always scores 100.
Otherwise, falls back to fuzzy scoring per field. Only counted as a match if
the best score clears `threshold`,

Returns:
The match score, or None if nothing cleared the bar.
"""
query_lower = query.lower()
if any(query_lower in field.lower() for field in fields):
return 100.0
Comment on lines +23 to +25

best = max((fuzz.WRatio(query, field) for field in fields), default=0.0)
return best if best >= threshold else None


def fuzzy_filter(
query: str,
items: Iterable[T],
key: Callable[[T], tuple[str, ...]],
threshold: int = DEFAULT_FUZZY_THRESHOLD,
) -> list[tuple[float, T]]:
"""Score and filter items against a query, best match first.

Args:
query: Search string.
items: Items to score.
key: Maps an item to the fields to match against (e.g. title/artist/album).
threshold: Minimum fuzzy score required when there's no substring match.

Returns:
(score, item) pairs that matched, sorted best-first.
"""
scored = []
for item in items:
score = fuzzy_score(query, *key(item), threshold=threshold)
if score is not None:
scored.append((score, item))
scored.sort(key=lambda pair: -pair[0])
return scored
129 changes: 129 additions & 0 deletions light_cli_tui/light_cli_tui/interactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Interactive fuzzy-pick and confirm helpers."""

import click

from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from rich.console import Console
from typing import Callable, Hashable, Iterable, TypeVar

from light_cli_tui.fuzzy import fuzzy_filter

T = TypeVar("T")

MAX_FUZZY_CANDIDATES = 30


def fuzzy_pick_interactive(
queries: Iterable[str],
items: Iterable[T],
fields: Callable[[T], tuple[str, ...]],
label: Callable[[T], str],
id_key: Callable[[T], Hashable],
console: Console,
) -> dict[Hashable, T] | None:
"""Prompt the user to pick items matching each query via a checkbox list.

Returns:
Selected items keyed by `id_key`, or None if the user aborted a picker.
"""
items = list(items)
selected: dict[Hashable, T] = {}

for query in queries:
scored = fuzzy_filter(query, items, key=fields)

if not scored:
console.print(f"[yellow]No matches for {query!r}.[/yellow]")
continue

truncated = len(scored) > MAX_FUZZY_CANDIDATES
candidates = scored[:MAX_FUZZY_CANDIDATES]
if truncated:
console.print(
f"[dim]{len(scored)} items matched {query!r}; "
f"showing top {MAX_FUZZY_CANDIDATES}. Narrow your search to see more.[/dim]"
)

lookup = {id_key(t): t for _, t in candidates}

picked_ids = inquirer.checkbox(
message=f"Select items matching {query!r}:",
choices=[Choice(value=id_key(t), name=label(t)) for _, t in candidates],
raise_keyboard_interrupt=False,
mandatory=False,
long_instruction="(space to select, enter to confirm, ctrl-c to cancel)",
).execute()

if picked_ids is None:
return None

for pid in picked_ids:
selected[pid] = lookup[pid]

return selected


def fuzzy_pick_best(
queries: Iterable[str],
items: Iterable[T],
fields: Callable[[T], tuple[str, ...]],
id_key: Callable[[T], Hashable],
console: Console,
) -> dict[Hashable, T]:
"""Auto-select every item tied for the top fuzzy score, for each query."""
items = list(items)
selected: dict[Hashable, T] = {}

for query in queries:
scored = fuzzy_filter(query, items, key=fields)
if not scored:
console.print(f"[yellow]No matches for {query!r}.[/yellow]")
continue
top_score = scored[0][0]
for score, t in scored:
if score == top_score:
selected[id_key(t)] = t

return selected


def confirm_selection_with_repick(
initial: dict[Hashable, T],
label: Callable[[T], str],
header: str,
repick: Callable[[], dict[Hashable, T] | None],
console: Console,
) -> list[T] | None:
"""Show a confirm loop: [Y]es proceeds, [n]o aborts, [p]ick re-invokes `repick`.

Returns:
The final list of selected items, or None if aborted/declined.
"""
selected = initial
while True:
items = list(selected.values())
console.print(f"{header} ({len(items)}):")
for t in items:
console.print(f" {label(t)}")

try:
choice = click.prompt(
"Proceed? [Y]es / [n]o / [p]ick matches by hand instead",
default="y",
show_default=False,
type=click.Choice(["y", "n", "p"], case_sensitive=False),
)
except (click.Abort, KeyboardInterrupt):
console.print("\n[yellow]Aborted.[/yellow]")
return None
if choice == "n":
return None
if choice == "y":
return items

repicked = repick()
if not repicked:
console.print("[yellow]Aborted.[/yellow]")
return None
selected = repicked
Comment on lines +125 to +129
2 changes: 2 additions & 0 deletions light_cli_tui/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ dependencies = [
"textual",
"pyperclip",
"light-phone-api",
"rapidfuzz>=3.14.5",
"inquirerpy>=0.3.4",
]

[project.scripts]
Expand Down
Loading