dvanh commited on
Commit
c35d661
·
1 Parent(s): 8e08e46
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
- ok_count = 0
 
 
 
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
- ok_count += 1
 
 
 
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: {ok_count}/{len(urls)} link(s) downloaded successfully.\n"
 
 
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()