mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-11 01:02:02 +03:00
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:
parent
55b3ee832e
commit
ac955ea35a
3 changed files with 121 additions and 0 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"], "должна остаться только первая копия каждого трека"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue