add dedupe command to remove already-created duplicate track ranges

Playlist deletes are positional (from/to index), so a network error
during dedupe just aborts that playlist instead of retrying blindly -
indices may have already shifted. Re-running dedupe is safe since it
recomputes ranges from the live playlist state each time.
This commit is contained in:
Andrew Pimenov 2026-07-16 16:28:21 +03:00
parent 55b3ee832e
commit ac955ea35a
3 changed files with 121 additions and 0 deletions

View file

@ -155,3 +155,70 @@ def apply_classification(
if failed: if failed:
summary += f", ошибок {failed}" summary += f", ошибок {failed}"
print(summary) print(summary)
def find_duplicate_ranges(track_ids: list[str]) -> list[tuple[int, int]]:
"""Индексы всех повторных вхождений track_id (кроме первого), сгруппированные в
непрерывные диапазоны [from, to) - в формате, который ожидает delete_track."""
seen: set[str] = set()
dup_indices: list[int] = []
for i, tid in enumerate(track_ids):
if tid in seen:
dup_indices.append(i)
else:
seen.add(tid)
ranges: list[tuple[int, int]] = []
for i in dup_indices:
if ranges and ranges[-1][1] == i:
ranges[-1] = (ranges[-1][0], i + 1)
else:
ranges.append((i, i + 1))
return ranges
def dedupe_playlists(client: Client, delay: float, dry_run: bool = True) -> None:
"""Убирает повторные вставки одного и того же трека в плейлистах (см. _existing_track_ids).
Удаление позиционное (from/to индекс), поэтому при сетевой ошибке не повторяем вслепую -
индексы могли уже сдвинуться. Просто прерываем обработку этого плейлиста; повторный запуск
dedupe безопасен и идемпотентен, так как каждый раз пересчитывает диапазоны от актуального
состояния плейлиста.
"""
playlists = with_retries(lambda: client.users_playlists_list())
total_ranges = 0
total_tracks = 0
for playlist in playlists:
full = with_retries(lambda: client.users_playlists(playlist.kind))
if full is None:
print(f" не удалось прочитать «{playlist.title}», пропускаю")
continue
ids = [t.track_id for t in full.tracks]
ranges = find_duplicate_ranges(ids)
if not ranges:
continue
n = sum(to - frm for frm, to in ranges)
total_ranges += len(ranges)
total_tracks += n
if dry_run:
print(f"{playlist.title}: найдено дублей {n} ({len(ranges)} диапазон(ов))")
continue
revision = full.revision or 1
for frm, to in sorted(ranges, key=lambda r: -r[0]):
updated = client.users_playlists_delete_track(playlist.kind, frm, to, revision=revision)
revision = updated.revision if updated is not None and updated.revision is not None else revision + 1
time.sleep(delay)
print(f"{playlist.title}: удалено {n} дублей")
if dry_run:
if total_tracks:
print(f"\nВсего дублей найдено: {total_tracks} в {total_ranges} диапазон(ов). Запустите с --yes для удаления.")
else:
print("\nДублей не найдено.")
else:
print(f"\nВсего удалено дублей: {total_tracks}")

View file

@ -69,6 +69,12 @@ def cmd_apply(args: argparse.Namespace) -> None:
apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit) apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit)
def cmd_dedupe(args: argparse.Namespace) -> None:
cfg = load_config()
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
apply_mod.dedupe_playlists(client, cfg.apply_request_delay, dry_run=not args.yes)
def main(argv: list[str] | None = None) -> None: def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(prog="sort_ym", description="Сортировка лайкнутых треков Яндекс.Музыки по жанру и языку") parser = argparse.ArgumentParser(prog="sort_ym", description="Сортировка лайкнутых треков Яндекс.Музыки по жанру и языку")
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@ -81,6 +87,9 @@ def main(argv: list[str] | None = None) -> None:
p_apply.add_argument("--yes", action="store_true", help="подтвердить внесение изменений в аккаунт") p_apply.add_argument("--yes", action="store_true", help="подтвердить внесение изменений в аккаунт")
p_apply.add_argument("--limit", type=int, default=None, help="ограничить количество треков (для пробного запуска)") p_apply.add_argument("--limit", type=int, default=None, help="ограничить количество треков (для пробного запуска)")
p_dedupe = sub.add_parser("dedupe", help="найти и убрать повторные вставки одного трека в плейлистах")
p_dedupe.add_argument("--yes", action="store_true", help="подтвердить удаление дублей (без флага - только отчёт)")
args = parser.parse_args(argv) args = parser.parse_args(argv)
commands = { commands = {
@ -88,6 +97,7 @@ def main(argv: list[str] | None = None) -> None:
"fetch": cmd_fetch, "fetch": cmd_fetch,
"report": cmd_report, "report": cmd_report,
"apply": cmd_apply, "apply": cmd_apply,
"dedupe": cmd_dedupe,
} }
commands[args.command](args) commands[args.command](args)

View file

@ -45,6 +45,12 @@ class FakeClient:
p.revision = revision + 1 p.revision = revision + 1
return p return p
def users_playlists_delete_track(self, kind, from_, to, revision=1):
p = self.playlists[kind]
del p.tracks[from_:to]
p.revision = revision + 1
return p
class FlakyClient: class FlakyClient:
"""insert_track кидает таймаут на первой попытке, но трек всё же """insert_track кидает таймаут на первой попытке, но трек всё же
@ -179,3 +185,41 @@ def test_apply_raises_instead_of_silently_treating_persistent_none_as_empty(monk
assert False, "должно было упасть, а не молча решить, что плейлист пуст" assert False, "должно было упасть, а не молча решить, что плейлист пуст"
except RuntimeError: except RuntimeError:
pass pass
def test_find_duplicate_ranges_no_duplicates():
assert apply_mod.find_duplicate_ranges(["1:10", "2:20", "3:30"]) == []
def test_find_duplicate_ranges_single_contiguous_block():
ids = ["1:10", "2:20", "3:30", "1:10", "2:20", "3:30"]
assert apply_mod.find_duplicate_ranges(ids) == [(3, 6)]
def test_find_duplicate_ranges_scattered_and_triple():
ids = ["1:10", "1:10", "2:20", "1:10", "3:30"]
assert apply_mod.find_duplicate_ranges(ids) == [(1, 2), (3, 4)]
def test_dedupe_playlists_dry_run_does_not_delete():
client = FakeClient()
client.playlists[100].tracks = [FakeTrackShort("1:10"), FakeTrackShort("2:20"), FakeTrackShort("1:10")]
apply_mod.dedupe_playlists(client, delay=0, dry_run=True)
assert len(client.playlists[100].tracks) == 3, "дубли не должны удаляться в dry-run режиме"
def test_dedupe_playlists_removes_duplicate_block():
client = FakeClient()
client.playlists[100].tracks = [
FakeTrackShort("1:10"),
FakeTrackShort("2:20"),
FakeTrackShort("1:10"),
FakeTrackShort("2:20"),
]
apply_mod.dedupe_playlists(client, delay=0, dry_run=False)
remaining = [t.track_id for t in client.playlists[100].tracks]
assert remaining == ["1:10", "2:20"], "должна остаться только первая копия каждого трека"