diff --git a/sort_ym/apply.py b/sort_ym/apply.py index e7e54e8..eb28c03 100644 --- a/sort_ym/apply.py +++ b/sort_ym/apply.py @@ -155,3 +155,70 @@ def apply_classification( if failed: summary += f", ошибок {failed}" 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}") diff --git a/sort_ym/cli.py b/sort_ym/cli.py index 5291904..372baf2 100644 --- a/sort_ym/cli.py +++ b/sort_ym/cli.py @@ -69,6 +69,12 @@ def cmd_apply(args: argparse.Namespace) -> None: 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: parser = argparse.ArgumentParser(prog="sort_ym", description="Сортировка лайкнутых треков Яндекс.Музыки по жанру и языку") 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("--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) commands = { @@ -88,6 +97,7 @@ def main(argv: list[str] | None = None) -> None: "fetch": cmd_fetch, "report": cmd_report, "apply": cmd_apply, + "dedupe": cmd_dedupe, } commands[args.command](args) diff --git a/tests/test_apply.py b/tests/test_apply.py index ce95525..dc8cd4c 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -45,6 +45,12 @@ class FakeClient: p.revision = revision + 1 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: """insert_track кидает таймаут на первой попытке, но трек всё же @@ -179,3 +185,41 @@ def test_apply_raises_instead_of_silently_treating_persistent_none_as_empty(monk assert False, "должно было упасть, а не молча решить, что плейлист пуст" except RuntimeError: 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"], "должна остаться только первая копия каждого трека"