mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 15:42: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:
|
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}")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"], "должна остаться только первая копия каждого трека"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue