store outputs under the input-relative path
- add _output_dir_for(): output/<relpath_without_ext>\n- use it in sequential, staged, LRC and embed pipelines\n- keep track folders unique for equal stems in different albums\n- log the layout and report folder collisions\n- add a safe one-shot flat-to-path migration script
This commit is contained in:
@@ -12,7 +12,8 @@ from .convert import convert_to_wav
|
|||||||
from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc
|
from .lrc import embed_lrc, is_lrc, read_embedded_lyrics, segments_to_lrc
|
||||||
from .metadata import read_metadata
|
from .metadata import read_metadata
|
||||||
from .pipeline import (
|
from .pipeline import (
|
||||||
FileResult, _artifact_belongs_to, _save_unrecognized, collect_audio_files,
|
FileResult, _artifact_belongs_to, _output_dir_for, _save_unrecognized,
|
||||||
|
collect_audio_files,
|
||||||
)
|
)
|
||||||
from .transcribe import CudaOOMError, transcribe_aligned
|
from .transcribe import CudaOOMError, transcribe_aligned
|
||||||
|
|
||||||
@@ -23,11 +24,13 @@ OverallFn = Callable[[int, int], None] # completed_files, total_files
|
|||||||
|
|
||||||
|
|
||||||
def _pick_audio_source(
|
def _pick_audio_source(
|
||||||
f: Path, output_dir: Path, config: PipelineConfig, work_dir: Path, log: LogFn,
|
f: Path, input_root: Path, output_dir: Path, config: PipelineConfig,
|
||||||
|
work_dir: Path, log: LogFn,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
mode = (config.lrc.audio_source or "auto").lower()
|
mode = (config.lrc.audio_source or "auto").lower()
|
||||||
vocals = output_dir / f.stem / "vocals.wav"
|
track_out = _output_dir_for(f, input_root, output_dir)
|
||||||
convert = output_dir / f.stem / "convert.wav"
|
vocals = track_out / "vocals.wav"
|
||||||
|
convert = track_out / "convert.wav"
|
||||||
if mode == "vocals":
|
if mode == "vocals":
|
||||||
return vocals
|
return vocals
|
||||||
if mode == "convert":
|
if mode == "convert":
|
||||||
@@ -100,7 +103,7 @@ def run_lrc_pipeline(
|
|||||||
break
|
break
|
||||||
log(f"\n▶ [{idx}/{total}] {f.name}")
|
log(f"\n▶ [{idx}/{total}] {f.name}")
|
||||||
|
|
||||||
track_out = output_dir / f.stem
|
track_out = _output_dir_for(f, input_dir, output_dir)
|
||||||
track_out.mkdir(parents=True, exist_ok=True)
|
track_out.mkdir(parents=True, exist_ok=True)
|
||||||
lrc_path = track_out / "text.lrc"
|
lrc_path = track_out / "text.lrc"
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
@@ -125,7 +128,9 @@ def run_lrc_pipeline(
|
|||||||
results.append(FileResult(f, None, track_out, None, "embedded+lrc"))
|
results.append(FileResult(f, None, track_out, None, "embedded+lrc"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
audio_path = _pick_audio_source(f, output_dir, config, tmp, log)
|
audio_path = _pick_audio_source(
|
||||||
|
f, input_dir, output_dir, config, tmp, log,
|
||||||
|
)
|
||||||
if not audio_path.exists():
|
if not audio_path.exists():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"Источник аудио не найден: {audio_path}. "
|
f"Источник аудио не найден: {audio_path}. "
|
||||||
@@ -217,7 +222,8 @@ def run_embed_pipeline(
|
|||||||
log("⏹ Отменено")
|
log("⏹ Отменено")
|
||||||
break
|
break
|
||||||
|
|
||||||
lrc_path = output_dir / f.stem / "text.lrc"
|
track_out = _output_dir_for(f, input_dir, output_dir)
|
||||||
|
lrc_path = track_out / "text.lrc"
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -231,7 +237,7 @@ def run_embed_pipeline(
|
|||||||
if config.lrc.embed_in_place:
|
if config.lrc.embed_in_place:
|
||||||
target = f
|
target = f
|
||||||
else:
|
else:
|
||||||
target = output_dir / f.stem / f.name
|
target = track_out / f.name
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if not target.exists() or config.overwrite:
|
if not target.exists() or config.overwrite:
|
||||||
shutil.copy2(f, target)
|
shutil.copy2(f, target)
|
||||||
|
|||||||
@@ -179,15 +179,24 @@ def _merge_metadata_file(existing: Path, data: Optional[dict]) -> Optional[dict]
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _output_dir_for(f: Path, input_root: Path, output_root: Path) -> Path:
|
||||||
|
"""Path-based track folder: output/<relpath_without_ext>/."""
|
||||||
|
try:
|
||||||
|
rel = f.relative_to(input_root)
|
||||||
|
except ValueError:
|
||||||
|
rel = Path(f.name)
|
||||||
|
return output_root / rel.with_suffix("")
|
||||||
|
|
||||||
|
|
||||||
def _existing_artifacts(
|
def _existing_artifacts(
|
||||||
output_root: Path, input_path: Path,
|
input_root: Path, output_root: Path, input_path: Path,
|
||||||
) -> tuple[bool, bool, bool]:
|
) -> tuple[bool, bool, bool]:
|
||||||
"""Return (has_text, has_vocals, has_convert) for the matching output folder.
|
"""Return (has_text, has_vocals, has_convert) for the matching output folder.
|
||||||
|
|
||||||
Artifacts are ignored when ``metadata.json`` says the folder belongs to a
|
Artifacts are ignored when ``metadata.json`` says the folder belongs to a
|
||||||
different input file with the same stem.
|
different input file with the same stem.
|
||||||
"""
|
"""
|
||||||
track_out = output_root / input_path.stem
|
track_out = _output_dir_for(input_path, input_root, output_root)
|
||||||
if not _artifact_belongs_to(track_out, input_path):
|
if not _artifact_belongs_to(track_out, input_path):
|
||||||
return False, False, False
|
return False, False, False
|
||||||
return (
|
return (
|
||||||
@@ -230,7 +239,9 @@ def _is_track_finished(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _needs_work(config: PipelineConfig, output_root: Path, f: Path) -> bool:
|
def _needs_work(
|
||||||
|
config: PipelineConfig, input_root: Path, output_root: Path, f: Path,
|
||||||
|
) -> bool:
|
||||||
"""True if at least one enabled stage still has to do something for ``f``.
|
"""True if at least one enabled stage still has to do something for ``f``.
|
||||||
|
|
||||||
This mirrors the resume rules used by the staged/sequential pipelines:
|
This mirrors the resume rules used by the staged/sequential pipelines:
|
||||||
@@ -240,9 +251,11 @@ def _needs_work(config: PipelineConfig, output_root: Path, f: Path) -> bool:
|
|||||||
Files with no pending work are filtered out before batching, so batches
|
Files with no pending work are filtered out before batching, so batches
|
||||||
stay full instead of being filled by already-finished tracks.
|
stay full instead of being filled by already-finished tracks.
|
||||||
"""
|
"""
|
||||||
track_out = output_root / f.stem
|
track_out = _output_dir_for(f, input_root, output_root)
|
||||||
belongs = _artifact_belongs_to(track_out, f)
|
belongs = _artifact_belongs_to(track_out, f)
|
||||||
has_text, has_vocals, has_convert = _existing_artifacts(output_root, f)
|
has_text, has_vocals, has_convert = _existing_artifacts(
|
||||||
|
input_root, output_root, f,
|
||||||
|
)
|
||||||
|
|
||||||
if config.skip_existing and _is_track_finished(track_out, config, belongs):
|
if config.skip_existing and _is_track_finished(track_out, config, belongs):
|
||||||
return False
|
return False
|
||||||
@@ -660,7 +673,7 @@ def run_pipeline(
|
|||||||
# Pre-scan before chunking: remove files that are already complete for the
|
# Pre-scan before chunking: remove files that are already complete for the
|
||||||
# current set of enabled stages. Without this, skipped tracks consumed
|
# current set of enabled stages. Without this, skipped tracks consumed
|
||||||
# batch slots and a staged run produced many half-empty batches.
|
# batch slots and a staged run produced many half-empty batches.
|
||||||
pending = [f for f in files if _needs_work(config, output_dir, f)]
|
pending = [f for f in files if _needs_work(config, input_dir, output_dir, f)]
|
||||||
already_done = total_all - len(pending)
|
already_done = total_all - len(pending)
|
||||||
if already_done:
|
if already_done:
|
||||||
log(f" ✓ Уже готово: {already_done} — в батчи не попадут")
|
log(f" ✓ Уже готово: {already_done} — в батчи не попадут")
|
||||||
@@ -678,10 +691,12 @@ def run_pipeline(
|
|||||||
return _run_staged(
|
return _run_staged(
|
||||||
config, files, log, on_progress, stop_event, on_overall_progress,
|
config, files, log, on_progress, stop_event, on_overall_progress,
|
||||||
overall_base=already_done, overall_total=total_all,
|
overall_base=already_done, overall_total=total_all,
|
||||||
|
input_root=input_dir,
|
||||||
)
|
)
|
||||||
return _run_sequential(
|
return _run_sequential(
|
||||||
config, files, log, on_progress, stop_event, on_overall_progress,
|
config, files, log, on_progress, stop_event, on_overall_progress,
|
||||||
overall_base=already_done, overall_total=total_all,
|
overall_base=already_done, overall_total=total_all,
|
||||||
|
input_root=input_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -691,18 +706,22 @@ def _run_sequential(
|
|||||||
on_overall_progress: Optional[OverallFn] = None,
|
on_overall_progress: Optional[OverallFn] = None,
|
||||||
overall_base: int = 0,
|
overall_base: int = 0,
|
||||||
overall_total: Optional[int] = None,
|
overall_total: Optional[int] = None,
|
||||||
|
input_root: Optional[Path] = None,
|
||||||
) -> list[FileResult]:
|
) -> list[FileResult]:
|
||||||
total = len(files)
|
total = len(files)
|
||||||
if overall_total is None:
|
if overall_total is None:
|
||||||
overall_total = total
|
overall_total = overall_base + total
|
||||||
|
if input_root is None:
|
||||||
|
input_root = Path(config.input_dir)
|
||||||
results: list[FileResult] = []
|
results: list[FileResult] = []
|
||||||
output_root = Path(config.output_dir)
|
output_root = Path(config.output_dir)
|
||||||
|
log("Раскладка: output/<relpath_from_input>/<stem>/")
|
||||||
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
|
ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
|
||||||
ident_files = files
|
ident_files = files
|
||||||
if config.skip_existing:
|
if config.skip_existing:
|
||||||
ident_files = [
|
ident_files = [
|
||||||
f for f in files
|
f for f in files
|
||||||
if not (output_root / f.stem / "text.txt").is_file()
|
if not (_output_dir_for(f, input_root, output_root) / "text.txt").is_file()
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
ident_map = _run_identification_parallel(
|
ident_map = _run_identification_parallel(
|
||||||
@@ -719,9 +738,14 @@ def _run_sequential(
|
|||||||
on_progress("file", idx - 1, total)
|
on_progress("file", idx - 1, total)
|
||||||
log(f"\n▶ [{idx}/{total}] {f.name}")
|
log(f"\n▶ [{idx}/{total}] {f.name}")
|
||||||
|
|
||||||
track_out = output_root / f.stem
|
track_out = _output_dir_for(f, input_root, output_root)
|
||||||
track_out.mkdir(parents=True, exist_ok=True)
|
track_out.mkdir(parents=True, exist_ok=True)
|
||||||
belongs = _artifact_belongs_to(track_out, f)
|
belongs = _artifact_belongs_to(track_out, f)
|
||||||
|
if not belongs and any(
|
||||||
|
(track_out / name).exists()
|
||||||
|
for name in ("text.txt", "vocals.wav", "convert.wav")
|
||||||
|
):
|
||||||
|
log(f" ⚠ коллизия раскладки: {track_out} занят другим треком — обрабатываю заново")
|
||||||
|
|
||||||
if config.skip_existing and _is_track_finished(track_out, config, belongs):
|
if config.skip_existing and _is_track_finished(track_out, config, belongs):
|
||||||
existing_text = track_out / "text.txt"
|
existing_text = track_out / "text.txt"
|
||||||
@@ -924,13 +948,17 @@ def _run_staged(
|
|||||||
on_overall_progress: Optional[OverallFn] = None,
|
on_overall_progress: Optional[OverallFn] = None,
|
||||||
overall_base: int = 0,
|
overall_base: int = 0,
|
||||||
overall_total: Optional[int] = None,
|
overall_total: Optional[int] = None,
|
||||||
|
input_root: Optional[Path] = None,
|
||||||
) -> list[FileResult]:
|
) -> list[FileResult]:
|
||||||
"""Chunked staged mode: process `chunk_size` files, free memory, repeat."""
|
"""Chunked staged mode: process `chunk_size` files, free memory, repeat."""
|
||||||
output_root = Path(config.output_dir)
|
output_root = Path(config.output_dir)
|
||||||
|
if input_root is None:
|
||||||
|
input_root = Path(config.input_dir)
|
||||||
total = len(files)
|
total = len(files)
|
||||||
if overall_total is None:
|
if overall_total is None:
|
||||||
overall_total = overall_base + total
|
overall_total = overall_base + total
|
||||||
chunk_size = max(1, int(config.chunk_size or total))
|
chunk_size = max(1, int(config.chunk_size or total))
|
||||||
|
log("Раскладка: output/<relpath_from_input>/<stem>/")
|
||||||
|
|
||||||
if chunk_size >= total:
|
if chunk_size >= total:
|
||||||
log(f"Один батч: {total} файлов (chunk_size={chunk_size})")
|
log(f"Один батч: {total} файлов (chunk_size={chunk_size})")
|
||||||
@@ -939,6 +967,7 @@ def _run_staged(
|
|||||||
overall_offset=overall_base, overall_total=overall_total,
|
overall_offset=overall_base, overall_total=overall_total,
|
||||||
on_overall_progress=on_overall_progress,
|
on_overall_progress=on_overall_progress,
|
||||||
overall_counter=[overall_base],
|
overall_counter=[overall_base],
|
||||||
|
input_root=input_root,
|
||||||
)
|
)
|
||||||
_free_memory()
|
_free_memory()
|
||||||
_log_summary(results, log)
|
_log_summary(results, log)
|
||||||
@@ -966,6 +995,7 @@ def _run_staged(
|
|||||||
overall_offset=overall_base + start, overall_total=overall_total,
|
overall_offset=overall_base + start, overall_total=overall_total,
|
||||||
on_overall_progress=on_overall_progress,
|
on_overall_progress=on_overall_progress,
|
||||||
overall_counter=overall_counter,
|
overall_counter=overall_counter,
|
||||||
|
input_root=input_root,
|
||||||
)
|
)
|
||||||
all_results.extend(batch_results)
|
all_results.extend(batch_results)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
@@ -989,9 +1019,12 @@ def _run_staged_batch(
|
|||||||
overall_total: Optional[int] = None,
|
overall_total: Optional[int] = None,
|
||||||
on_overall_progress: Optional[OverallFn] = None,
|
on_overall_progress: Optional[OverallFn] = None,
|
||||||
overall_counter: Optional[list[int]] = None,
|
overall_counter: Optional[list[int]] = None,
|
||||||
|
input_root: Optional[Path] = None,
|
||||||
) -> list[FileResult]:
|
) -> list[FileResult]:
|
||||||
"""One chunk of the staged pipeline. All temps are tracked and removed
|
"""One chunk of the staged pipeline. All temps are tracked and removed
|
||||||
in the `finally` block at the end of this call."""
|
in the `finally` block at the end of this call."""
|
||||||
|
if input_root is None:
|
||||||
|
input_root = Path(config.input_dir)
|
||||||
if overall_total is None:
|
if overall_total is None:
|
||||||
overall_total = len(batch_files)
|
overall_total = len(batch_files)
|
||||||
|
|
||||||
@@ -1048,7 +1081,7 @@ def _run_staged_batch(
|
|||||||
resumed_partial = 0
|
resumed_partial = 0
|
||||||
mismatched_folders = 0
|
mismatched_folders = 0
|
||||||
for f in batch_files:
|
for f in batch_files:
|
||||||
track_out = output_root / f.stem
|
track_out = _output_dir_for(f, input_root, output_root)
|
||||||
audio_path: Path = f
|
audio_path: Path = f
|
||||||
skip_convert = False
|
skip_convert = False
|
||||||
skip_separate = False
|
skip_separate = False
|
||||||
@@ -1064,7 +1097,7 @@ def _run_staged_batch(
|
|||||||
)
|
)
|
||||||
if has_artifacts:
|
if has_artifacts:
|
||||||
mismatched_folders += 1
|
mismatched_folders += 1
|
||||||
log(f" ⚠ {f.name}: папка {track_out.name} относится к другому треку — обрабатываю заново")
|
log(f" ⚠ коллизия раскладки: {track_out} занят другим треком — обрабатываю заново")
|
||||||
|
|
||||||
text_file = track_out / "text.txt"
|
text_file = track_out / "text.txt"
|
||||||
if config.skip_existing and _is_track_finished(track_out, config, belongs):
|
if config.skip_existing and _is_track_finished(track_out, config, belongs):
|
||||||
@@ -1138,7 +1171,7 @@ def _run_staged_batch(
|
|||||||
ident.metadata, ident.shazam, ident.source,
|
ident.metadata, ident.shazam, ident.source,
|
||||||
input_path=f,
|
input_path=f,
|
||||||
)
|
)
|
||||||
track_out = output_root / f.stem
|
track_out = _output_dir_for(f, input_root, output_root)
|
||||||
track_out.mkdir(parents=True, exist_ok=True)
|
track_out.mkdir(parents=True, exist_ok=True)
|
||||||
if ident.text:
|
if ident.text:
|
||||||
(track_out / "text.txt").write_text(ident.text, encoding="utf-8")
|
(track_out / "text.txt").write_text(ident.text, encoding="utf-8")
|
||||||
@@ -1173,7 +1206,7 @@ def _run_staged_batch(
|
|||||||
# convert.enabled=False src == f, so every track would
|
# convert.enabled=False src == f, so every track would
|
||||||
# overwrite input_dir/vocals.wav. Only reuse the track
|
# overwrite input_dir/vocals.wav. Only reuse the track
|
||||||
# output folder when the source already lives there.
|
# output folder when the source already lives there.
|
||||||
track_dir = output_root / f.stem
|
track_dir = _output_dir_for(f, input_root, output_root)
|
||||||
if src.parent == track_dir:
|
if src.parent == track_dir:
|
||||||
# Already in the persistent track folder (resume).
|
# Already in the persistent track folder (resume).
|
||||||
target = src.parent / "vocals.wav"
|
target = src.parent / "vocals.wav"
|
||||||
@@ -1208,7 +1241,7 @@ def _run_staged_batch(
|
|||||||
# separate resumed/original files one by one and batch the rest.
|
# separate resumed/original files one by one and batch the rest.
|
||||||
resumed = [
|
resumed = [
|
||||||
f for f in todo
|
f for f in todo
|
||||||
if state[f]["audio_path"].parent == output_root / f.stem
|
if state[f]["audio_path"].parent == _output_dir_for(f, input_root, output_root)
|
||||||
or state[f]["audio_path"].parent == f.parent
|
or state[f]["audio_path"].parent == f.parent
|
||||||
]
|
]
|
||||||
resumed_set = set(resumed)
|
resumed_set = set(resumed)
|
||||||
@@ -1336,7 +1369,7 @@ def _run_staged_batch(
|
|||||||
if isinstance(src_audio, Path):
|
if isinstance(src_audio, Path):
|
||||||
vocal = src_audio.parent / "vocals.wav"
|
vocal = src_audio.parent / "vocals.wav"
|
||||||
if vocal.exists():
|
if vocal.exists():
|
||||||
track_out = output_root / f.stem
|
track_out = _output_dir_for(f, input_root, output_root)
|
||||||
track_out.mkdir(parents=True, exist_ok=True)
|
track_out.mkdir(parents=True, exist_ok=True)
|
||||||
dst = track_out / "vocals.wav"
|
dst = track_out / "vocals.wav"
|
||||||
if dst.exists():
|
if dst.exists():
|
||||||
@@ -1377,7 +1410,7 @@ def _run_staged_batch(
|
|||||||
)
|
)
|
||||||
s["metadata_dict"] = metadata_dict
|
s["metadata_dict"] = metadata_dict
|
||||||
|
|
||||||
track_out = output_root / f.stem
|
track_out = _output_dir_for(f, input_root, output_root)
|
||||||
track_out.mkdir(parents=True, exist_ok=True)
|
track_out.mkdir(parents=True, exist_ok=True)
|
||||||
if _artifact_belongs_to(track_out, f):
|
if _artifact_belongs_to(track_out, f):
|
||||||
metadata_dict = _merge_metadata_file(
|
metadata_dict = _merge_metadata_file(
|
||||||
@@ -1427,7 +1460,7 @@ def _run_staged_batch(
|
|||||||
return [
|
return [
|
||||||
FileResult(
|
FileResult(
|
||||||
f, state[f]["text"],
|
f, state[f]["text"],
|
||||||
output_root / f.stem if state[f]["error"] is None else None,
|
_output_dir_for(f, input_root, output_root) if state[f]["error"] is None else None,
|
||||||
state[f]["error"], state[f]["source"], state[f]["metadata_dict"],
|
state[f]["error"], state[f]["source"], state[f]["metadata_dict"],
|
||||||
)
|
)
|
||||||
for f in batch_files
|
for f in batch_files
|
||||||
|
|||||||
86
migrate_output_to_paths.py
Normal file
86
migrate_output_to_paths.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Migrate flat output/<stem>/ to output/<relpath>/<stem>/ (one-shot)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse, json, shutil, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--input-root", required=True, type=Path)
|
||||||
|
ap.add_argument("--output-root", required=True, type=Path)
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
input_root = args.input_root.resolve()
|
||||||
|
output_root = args.output_root.resolve()
|
||||||
|
|
||||||
|
if not input_root.is_dir():
|
||||||
|
print(f"input root not found: {input_root}", file=sys.stderr); return 1
|
||||||
|
if not output_root.is_dir():
|
||||||
|
print(f"output root not found: {output_root}", file=sys.stderr); return 1
|
||||||
|
|
||||||
|
moved = legacy = conflicts = errors = skipped = 0
|
||||||
|
artifact_names = (
|
||||||
|
"metadata.json", "text.txt", "text.lrc",
|
||||||
|
"vocals.wav", "convert.wav",
|
||||||
|
)
|
||||||
|
for track_dir in sorted(output_root.iterdir()):
|
||||||
|
if not track_dir.is_dir():
|
||||||
|
continue
|
||||||
|
if track_dir.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
# Never treat an album/parent folder as a flat track folder.
|
||||||
|
if not any((track_dir / name).is_file() for name in artifact_names):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
target = output_root / "_legacy" / track_dir.name
|
||||||
|
meta_file = track_dir / "metadata.json"
|
||||||
|
if meta_file.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||||
|
stored = data.get("path") if isinstance(data, dict) else None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
stored = None
|
||||||
|
if stored:
|
||||||
|
try:
|
||||||
|
rel = Path(stored).relative_to(input_root)
|
||||||
|
target = output_root / rel.with_suffix("")
|
||||||
|
except ValueError:
|
||||||
|
target = output_root / "_legacy" / track_dir.name
|
||||||
|
|
||||||
|
if target == track_dir:
|
||||||
|
continue
|
||||||
|
if track_dir in target.parents:
|
||||||
|
print(f"skip: {track_dir.name} -> target inside itself")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if target.exists():
|
||||||
|
print(f"conflict: {track_dir.name} -> "
|
||||||
|
f"{target.relative_to(output_root)}")
|
||||||
|
conflicts += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"DRY: {track_dir.name} -> {target.relative_to(output_root)}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(track_dir), str(target))
|
||||||
|
if "_legacy" in target.parts:
|
||||||
|
legacy += 1
|
||||||
|
else:
|
||||||
|
moved += 1
|
||||||
|
except OSError as e:
|
||||||
|
print(f"error: {track_dir.name}: {e}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
print(f"\nmoved: {moved} quarantined: {legacy} "
|
||||||
|
f"conflicts: {conflicts} skipped: {skipped} errors: {errors} "
|
||||||
|
f"(dry_run={args.dry_run})")
|
||||||
|
return 0 if errors == 0 else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user