add
Browse files
acestep/ui/gradio/events/training/youtube_download_registry.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-folder record of YouTube video IDs already downloaded by the bulk importer.
|
| 2 |
+
|
| 3 |
+
The Dataset Builder's "Download All" flow stores each downloaded video's
|
| 4 |
+
``v=`` key (the YouTube video ID) in a small JSON file inside the target
|
| 5 |
+
folder, mapping ID -> saved mp3 filename. On the next run, links whose ID is
|
| 6 |
+
already recorded (and whose mp3 still exists) are skipped instead of being
|
| 7 |
+
downloaded again.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
from typing import Optional
|
| 14 |
+
from urllib.parse import parse_qs, urlparse
|
| 15 |
+
|
| 16 |
+
from loguru import logger
|
| 17 |
+
|
| 18 |
+
REGISTRY_FILENAME = ".youtube_downloaded.json"
|
| 19 |
+
|
| 20 |
+
_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
| 21 |
+
_PATH_ID_PREFIXES = ("/shorts/", "/live/", "/embed/")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def extract_video_id(url: str) -> Optional[str]:
|
| 25 |
+
"""Return the YouTube video ID (the ``v=`` key) of ``url``, or ``None``.
|
| 26 |
+
|
| 27 |
+
Handles ``watch?v=``, ``youtu.be/<id>``, ``/shorts/<id>``, ``/live/<id>``
|
| 28 |
+
and ``/embed/<id>`` links; extra query params (``list=``, ``t=``...) are
|
| 29 |
+
ignored.
|
| 30 |
+
"""
|
| 31 |
+
parsed = urlparse((url or "").strip())
|
| 32 |
+
host = (parsed.hostname or "").lower()
|
| 33 |
+
candidate = None
|
| 34 |
+
if host.endswith("youtu.be"):
|
| 35 |
+
candidate = parsed.path.lstrip("/").split("/")[0]
|
| 36 |
+
elif host.endswith("youtube.com"):
|
| 37 |
+
candidate = (parse_qs(parsed.query).get("v") or [None])[0]
|
| 38 |
+
if candidate is None:
|
| 39 |
+
for prefix in _PATH_ID_PREFIXES:
|
| 40 |
+
if parsed.path.startswith(prefix):
|
| 41 |
+
candidate = parsed.path[len(prefix):].split("/")[0]
|
| 42 |
+
break
|
| 43 |
+
if candidate and _VIDEO_ID_RE.match(candidate):
|
| 44 |
+
return candidate
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _registry_path(target_dir: str) -> str:
|
| 49 |
+
"""Return the registry file path inside ``target_dir``."""
|
| 50 |
+
return os.path.join(target_dir, REGISTRY_FILENAME)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_registry(target_dir: str) -> dict[str, str]:
|
| 54 |
+
"""Load the ``{video_id: mp3_filename}`` map for ``target_dir``.
|
| 55 |
+
|
| 56 |
+
Returns an empty dict when the file is missing or unreadable.
|
| 57 |
+
"""
|
| 58 |
+
path = _registry_path(target_dir)
|
| 59 |
+
if not os.path.isfile(path):
|
| 60 |
+
return {}
|
| 61 |
+
try:
|
| 62 |
+
with open(path, encoding="utf-8") as fh:
|
| 63 |
+
data = json.load(fh)
|
| 64 |
+
except (OSError, ValueError) as exc:
|
| 65 |
+
logger.warning(f"[youtube_registry] Ignoring unreadable registry {path}: {exc}")
|
| 66 |
+
return {}
|
| 67 |
+
if not isinstance(data, dict):
|
| 68 |
+
return {}
|
| 69 |
+
return {str(k): str(v) for k, v in data.items()}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def save_registry(target_dir: str, registry: dict[str, str]) -> None:
|
| 73 |
+
"""Write the ``{video_id: mp3_filename}`` map to ``target_dir``."""
|
| 74 |
+
path = _registry_path(target_dir)
|
| 75 |
+
with open(path, "w", encoding="utf-8") as fh:
|
| 76 |
+
json.dump(registry, fh, ensure_ascii=False, indent=2, sort_keys=True)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def is_already_downloaded(registry: dict[str, str], video_id: str, target_dir: str) -> bool:
|
| 80 |
+
"""Return whether ``video_id`` is recorded and its mp3 still exists in ``target_dir``."""
|
| 81 |
+
filename = registry.get(video_id)
|
| 82 |
+
return bool(filename) and os.path.isfile(os.path.join(target_dir, filename))
|
acestep/ui/gradio/events/training/youtube_import.py
CHANGED
|
@@ -19,10 +19,39 @@ from loguru import logger
|
|
| 19 |
|
| 20 |
from acestep.youtube_download import YoutubeDownloadError, download_youtube_audio
|
| 21 |
from .training_utils import LORA_TRAINING_AUDIO_DIR
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
_URL_SPLIT_RE = re.compile(r"[,\n]+")
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def _split_urls(urls_text: str) -> list[str]:
|
| 27 |
"""Split a paste of one or more YouTube links, comma- and/or newline-separated."""
|
| 28 |
raw = (urls_text or "").strip()
|
|
@@ -67,25 +96,43 @@ def download_youtube_batch(urls_text: str, target_dir: str):
|
|
| 67 |
log_lines = [f"\U0001f680 Downloading {len(urls)} link(s) into: {target_dir}"]
|
| 68 |
yield "\n".join(log_lines)
|
| 69 |
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
| 71 |
for idx, url in enumerate(urls, start=1):
|
| 72 |
prefix = f"[{idx}/{len(urls)}]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
log_lines.append(f"{prefix} \U0001f4e5 Downloading: {url}")
|
| 74 |
yield "\n".join(log_lines)
|
| 75 |
try:
|
| 76 |
saved_path = download_youtube_audio(url, output_dir=target_dir)
|
| 77 |
except YoutubeDownloadError as exc:
|
| 78 |
logger.warning(f"[youtube_import] Download failed for {url}: {exc}")
|
|
|
|
| 79 |
log_lines.append(f"{prefix} ⚠️ Skipped (error): {exc}")
|
| 80 |
yield "\n".join(log_lines)
|
| 81 |
continue
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
| 84 |
log_lines.append(f"{prefix} ✅ Saved: {os.path.basename(saved_path)}")
|
| 85 |
yield "\n".join(log_lines)
|
| 86 |
|
|
|
|
|
|
|
| 87 |
log_lines.append(
|
| 88 |
-
f"\n\U0001f3c1 Done: {
|
|
|
|
|
|
|
| 89 |
f"Now click \"Scan New Directory\" above with this folder to build the dataset:\n{target_dir}"
|
| 90 |
)
|
| 91 |
yield "\n".join(log_lines)
|
|
|
|
| 19 |
|
| 20 |
from acestep.youtube_download import YoutubeDownloadError, download_youtube_audio
|
| 21 |
from .training_utils import LORA_TRAINING_AUDIO_DIR
|
| 22 |
+
from .youtube_download_registry import (
|
| 23 |
+
extract_video_id,
|
| 24 |
+
is_already_downloaded,
|
| 25 |
+
load_registry,
|
| 26 |
+
save_registry,
|
| 27 |
+
)
|
| 28 |
|
| 29 |
_URL_SPLIT_RE = re.compile(r"[,\n]+")
|
| 30 |
|
| 31 |
|
| 32 |
+
def _save_registry_safely(target_dir: str, registry: dict[str, str]) -> None:
|
| 33 |
+
"""Persist the downloaded-ID registry, logging (not raising) on write failure."""
|
| 34 |
+
try:
|
| 35 |
+
save_registry(target_dir, registry)
|
| 36 |
+
except OSError as exc:
|
| 37 |
+
logger.warning(f"[youtube_import] Could not save download registry: {exc}")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _format_index_summary(
|
| 41 |
+
downloaded_idx: list[int], skipped_idx: list[int], failed_idx: list[int]
|
| 42 |
+
) -> str:
|
| 43 |
+
"""Format the 1-based link numbers of each outcome, e.g. ``Downloaded: #1, #4``."""
|
| 44 |
+
|
| 45 |
+
def _fmt(indices: list[int]) -> str:
|
| 46 |
+
return ", ".join(f"#{i}" for i in indices) or "-"
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
f"✅ Downloaded: {_fmt(downloaded_idx)}\n"
|
| 50 |
+
f"⏭️ Already downloaded: {_fmt(skipped_idx)}\n"
|
| 51 |
+
f"⚠️ Failed: {_fmt(failed_idx)}"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
def _split_urls(urls_text: str) -> list[str]:
|
| 56 |
"""Split a paste of one or more YouTube links, comma- and/or newline-separated."""
|
| 57 |
raw = (urls_text or "").strip()
|
|
|
|
| 96 |
log_lines = [f"\U0001f680 Downloading {len(urls)} link(s) into: {target_dir}"]
|
| 97 |
yield "\n".join(log_lines)
|
| 98 |
|
| 99 |
+
registry = load_registry(target_dir)
|
| 100 |
+
downloaded_idx: list[int] = []
|
| 101 |
+
skipped_idx: list[int] = []
|
| 102 |
+
failed_idx: list[int] = []
|
| 103 |
for idx, url in enumerate(urls, start=1):
|
| 104 |
prefix = f"[{idx}/{len(urls)}]"
|
| 105 |
+
video_id = extract_video_id(url)
|
| 106 |
+
if video_id and is_already_downloaded(registry, video_id, target_dir):
|
| 107 |
+
skipped_idx.append(idx)
|
| 108 |
+
log_lines.append(f"{prefix} ⏭️ Already downloaded ({video_id}): {registry[video_id]}")
|
| 109 |
+
yield "\n".join(log_lines)
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
log_lines.append(f"{prefix} \U0001f4e5 Downloading: {url}")
|
| 113 |
yield "\n".join(log_lines)
|
| 114 |
try:
|
| 115 |
saved_path = download_youtube_audio(url, output_dir=target_dir)
|
| 116 |
except YoutubeDownloadError as exc:
|
| 117 |
logger.warning(f"[youtube_import] Download failed for {url}: {exc}")
|
| 118 |
+
failed_idx.append(idx)
|
| 119 |
log_lines.append(f"{prefix} ⚠️ Skipped (error): {exc}")
|
| 120 |
yield "\n".join(log_lines)
|
| 121 |
continue
|
| 122 |
|
| 123 |
+
downloaded_idx.append(idx)
|
| 124 |
+
if video_id:
|
| 125 |
+
registry[video_id] = os.path.basename(saved_path)
|
| 126 |
+
_save_registry_safely(target_dir, registry)
|
| 127 |
log_lines.append(f"{prefix} ✅ Saved: {os.path.basename(saved_path)}")
|
| 128 |
yield "\n".join(log_lines)
|
| 129 |
|
| 130 |
+
summary = _format_index_summary(downloaded_idx, skipped_idx, failed_idx)
|
| 131 |
+
logger.info(f"[youtube_import] Batch finished in {target_dir}:\n{summary}")
|
| 132 |
log_lines.append(
|
| 133 |
+
f"\n\U0001f3c1 Done: {len(downloaded_idx)}/{len(urls)} link(s) downloaded successfully, "
|
| 134 |
+
f"{len(skipped_idx)} already downloaded (skipped), {len(failed_idx)} failed.\n"
|
| 135 |
+
f"{summary}\n"
|
| 136 |
f"Now click \"Scan New Directory\" above with this folder to build the dataset:\n{target_dir}"
|
| 137 |
)
|
| 138 |
yield "\n".join(log_lines)
|
acestep/ui/gradio/events/training/youtube_import_test.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the bulk YouTube importer's skip-already-downloaded behavior."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
import unittest
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
|
| 9 |
+
from acestep.ui.gradio.events.training import youtube_import
|
| 10 |
+
from acestep.ui.gradio.events.training.youtube_download_registry import (
|
| 11 |
+
REGISTRY_FILENAME,
|
| 12 |
+
extract_video_id,
|
| 13 |
+
load_registry,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ExtractVideoIdTests(unittest.TestCase):
|
| 18 |
+
"""Verify the ``v=`` key is pulled from the common YouTube URL shapes."""
|
| 19 |
+
|
| 20 |
+
def test_watch_url_with_playlist_params(self):
|
| 21 |
+
"""Extra list/start_radio params should not affect the extracted ID."""
|
| 22 |
+
url = "https://www.youtube.com/watch?v=JFLLHYWkIxc&list=RDEMMKSMwpeSGs9aNG657TmCfA&start_radio=1"
|
| 23 |
+
self.assertEqual(extract_video_id(url), "JFLLHYWkIxc")
|
| 24 |
+
|
| 25 |
+
def test_short_and_shorts_urls(self):
|
| 26 |
+
"""youtu.be and /shorts/ links should both yield the ID."""
|
| 27 |
+
self.assertEqual(extract_video_id("https://youtu.be/JFLLHYWkIxc?t=5"), "JFLLHYWkIxc")
|
| 28 |
+
self.assertEqual(extract_video_id("https://youtube.com/shorts/JFLLHYWkIxc"), "JFLLHYWkIxc")
|
| 29 |
+
|
| 30 |
+
def test_non_youtube_or_invalid_returns_none(self):
|
| 31 |
+
"""Non-YouTube hosts and malformed IDs should return None."""
|
| 32 |
+
self.assertIsNone(extract_video_id("https://vimeo.com/watch?v=JFLLHYWkIxc"))
|
| 33 |
+
self.assertIsNone(extract_video_id("https://www.youtube.com/watch?v=short"))
|
| 34 |
+
self.assertIsNone(extract_video_id(""))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class DownloadYoutubeBatchSkipTests(unittest.TestCase):
|
| 38 |
+
"""Verify recorded IDs are skipped and new downloads are recorded."""
|
| 39 |
+
|
| 40 |
+
def setUp(self):
|
| 41 |
+
"""Create a scratch target folder per test."""
|
| 42 |
+
self._tmp = tempfile.TemporaryDirectory()
|
| 43 |
+
self.target_dir = self._tmp.name
|
| 44 |
+
|
| 45 |
+
def tearDown(self):
|
| 46 |
+
"""Remove the scratch folder."""
|
| 47 |
+
self._tmp.cleanup()
|
| 48 |
+
|
| 49 |
+
def _fake_download(self, url, output_dir):
|
| 50 |
+
"""Write a dummy mp3 named after the video ID and return its path."""
|
| 51 |
+
path = os.path.join(output_dir, f"song-{extract_video_id(url)}.mp3")
|
| 52 |
+
with open(path, "wb") as fh:
|
| 53 |
+
fh.write(b"mp3")
|
| 54 |
+
return path
|
| 55 |
+
|
| 56 |
+
def _run(self, urls_text):
|
| 57 |
+
"""Run the batch with a mocked downloader, returning (mock, final log)."""
|
| 58 |
+
with patch.object(
|
| 59 |
+
youtube_import, "download_youtube_audio", side_effect=self._fake_download
|
| 60 |
+
) as mock_dl:
|
| 61 |
+
log = list(youtube_import.download_youtube_batch(urls_text, self.target_dir))[-1]
|
| 62 |
+
return mock_dl, log
|
| 63 |
+
|
| 64 |
+
def test_new_download_is_recorded(self):
|
| 65 |
+
"""A successful download should store its ID -> filename in the registry."""
|
| 66 |
+
mock_dl, _ = self._run("https://www.youtube.com/watch?v=JFLLHYWkIxc")
|
| 67 |
+
self.assertEqual(mock_dl.call_count, 1)
|
| 68 |
+
self.assertEqual(load_registry(self.target_dir), {"JFLLHYWkIxc": "song-JFLLHYWkIxc.mp3"})
|
| 69 |
+
|
| 70 |
+
def test_second_run_and_duplicates_are_skipped(self):
|
| 71 |
+
"""Already-downloaded IDs (earlier run or same paste) should not be re-downloaded."""
|
| 72 |
+
self._run("https://www.youtube.com/watch?v=JFLLHYWkIxc")
|
| 73 |
+
urls = (
|
| 74 |
+
"https://youtu.be/JFLLHYWkIxc,"
|
| 75 |
+
"https://www.youtube.com/watch?v=OuRZl8f3FeM\n"
|
| 76 |
+
"https://www.youtube.com/watch?v=OuRZl8f3FeM&list=RDx"
|
| 77 |
+
)
|
| 78 |
+
mock_dl, log = self._run(urls)
|
| 79 |
+
self.assertEqual(mock_dl.call_count, 1)
|
| 80 |
+
self.assertIn("2 already downloaded", log)
|
| 81 |
+
self.assertIn("✅ Downloaded: #2", log)
|
| 82 |
+
self.assertIn("⏭️ Already downloaded: #1, #3", log)
|
| 83 |
+
self.assertIn("⚠️ Failed: -", log)
|
| 84 |
+
|
| 85 |
+
def test_redownloads_when_recorded_file_is_missing(self):
|
| 86 |
+
"""A recorded ID whose mp3 was deleted should be downloaded again."""
|
| 87 |
+
with open(os.path.join(self.target_dir, REGISTRY_FILENAME), "w", encoding="utf-8") as fh:
|
| 88 |
+
json.dump({"JFLLHYWkIxc": "deleted.mp3"}, fh)
|
| 89 |
+
mock_dl, _ = self._run("https://www.youtube.com/watch?v=JFLLHYWkIxc")
|
| 90 |
+
self.assertEqual(mock_dl.call_count, 1)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
unittest.main()
|