Skip to content

Commit 4ae2175

Browse files
committed
use search api for search-replace + add test coverage
1 parent 3ec2537 commit 4ae2175

4 files changed

Lines changed: 225 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ htmlcov/
1212
.idea/
1313
.vscode/
1414
.DS_Store
15+
demo/

src/ytstudio/commands/videos.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -367,27 +367,26 @@ def search_replace(
367367
):
368368
"""Bulk update videos using search and replace"""
369369
service = get_data_service()
370-
uploads_playlist_id = get_channel_uploads_playlist(service)
371370

372371
changes = []
373372
page_token = None
374373

375-
while len(changes) < limit:
376-
# Fetch batch of videos
377-
playlist_response = api(
378-
service.playlistItems().list(
379-
part="snippet,contentDetails",
380-
playlistId=uploads_playlist_id,
374+
while True:
375+
search_response = api(
376+
service.search().list(
377+
part="id",
378+
forMine=True,
379+
type="video",
381380
maxResults=50,
382381
pageToken=page_token,
383382
)
384383
)
385384

386-
items = playlist_response.get("items", [])
385+
items = search_response.get("items", [])
387386
if not items:
388387
break
389388

390-
video_ids = [item["contentDetails"]["videoId"] for item in items]
389+
video_ids = [item["id"]["videoId"] for item in items]
391390

392391
videos_response = api(
393392
service.videos().list(
@@ -397,9 +396,6 @@ def search_replace(
397396
)
398397

399398
for video in videos_response.get("items", []):
400-
if len(changes) >= limit:
401-
break
402-
403399
old_value = video["snippet"].get(field, "")
404400
if regex:
405401
new_value = re.sub(search, replace, old_value)
@@ -416,10 +412,12 @@ def search_replace(
416412
}
417413
)
418414

419-
page_token = playlist_response.get("nextPageToken")
415+
page_token = search_response.get("nextPageToken")
420416
if not page_token:
421417
break
422418

419+
changes = changes[:limit]
420+
423421
if not changes:
424422
console.print("[yellow]No matches found[/yellow]")
425423
return

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ def create_mock_service():
127127
}
128128
service.commentThreads.return_value.list.return_value = comments_list
129129

130+
search_list = MagicMock()
131+
search_list.execute.return_value = {
132+
"items": [{"id": {"videoId": MOCK_VIDEO["id"]}}],
133+
}
134+
service.search.return_value.list.return_value = search_list
135+
130136
return service
131137

132138

tests/test_videos.py

Lines changed: 207 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest.mock import patch
1+
from unittest.mock import MagicMock, patch
22

33
import typer
44
from typer.testing import CliRunner
@@ -8,6 +8,35 @@
88
runner = CliRunner()
99

1010

11+
def make_search_video(video_id, title, description=""):
12+
return {
13+
"id": video_id,
14+
"snippet": {
15+
"title": title,
16+
"description": description,
17+
"publishedAt": "2020-01-01T00:00:00Z",
18+
"tags": [],
19+
"categoryId": "22",
20+
},
21+
"statistics": {"viewCount": "0", "likeCount": "0", "commentCount": "0"},
22+
"contentDetails": {"duration": "PT1M"},
23+
"status": {"privacyStatus": "public"},
24+
}
25+
26+
27+
def setup_search_mock(mock_service, videos):
28+
"""Configure the search and videos.list mocks for search-replace tests"""
29+
search_list = MagicMock()
30+
search_list.execute.return_value = {
31+
"items": [{"id": {"videoId": v["id"]}} for v in videos],
32+
}
33+
mock_service.search.return_value.list.return_value = search_list
34+
35+
videos_list = MagicMock()
36+
videos_list.execute.return_value = {"items": videos}
37+
mock_service.videos.return_value.list.return_value = videos_list
38+
39+
1140
class TestVideosCommands:
1241
def test_list(self, mock_auth):
1342
result = runner.invoke(app, ["videos", "list"])
@@ -41,3 +70,180 @@ def test_not_authenticated(self):
4170
):
4271
result = runner.invoke(app, ["videos", "list"])
4372
assert result.exit_code == 1
73+
74+
75+
class TestSearchReplace:
76+
def test_dry_run_shows_preview(self, mock_auth):
77+
videos = [make_search_video("vid1", "OLDNAME Episode 1")]
78+
setup_search_mock(mock_auth, videos)
79+
80+
result = runner.invoke(
81+
app,
82+
[
83+
"videos",
84+
"search-replace",
85+
"-s",
86+
"OLDNAME",
87+
"-r",
88+
"NewName",
89+
"-f",
90+
"title",
91+
],
92+
)
93+
assert result.exit_code == 0
94+
assert "NewName Episode 1" in result.stdout
95+
assert "Pending" in result.stdout
96+
assert "--execute" in result.stdout
97+
mock_auth.videos.return_value.update.assert_not_called()
98+
99+
def test_execute_applies_changes(self, mock_auth):
100+
videos = [make_search_video("vid1", "OLDNAME Episode 1")]
101+
setup_search_mock(mock_auth, videos)
102+
103+
result = runner.invoke(
104+
app,
105+
[
106+
"videos",
107+
"search-replace",
108+
"-s",
109+
"OLDNAME",
110+
"-r",
111+
"NewName",
112+
"-f",
113+
"title",
114+
"--execute",
115+
],
116+
)
117+
assert result.exit_code == 0
118+
assert "1 updated" in result.stdout
119+
mock_auth.videos.return_value.update.assert_called_once()
120+
121+
def test_no_matches(self, mock_auth):
122+
videos = [make_search_video("vid1", "Some Other Title")]
123+
setup_search_mock(mock_auth, videos)
124+
125+
result = runner.invoke(
126+
app,
127+
[
128+
"videos",
129+
"search-replace",
130+
"-s",
131+
"OLDNAME",
132+
"-r",
133+
"NewName",
134+
"-f",
135+
"title",
136+
],
137+
)
138+
assert result.exit_code == 0
139+
assert "No matches found" in result.stdout
140+
141+
def test_regex_replace(self, mock_auth):
142+
videos = [make_search_video("vid1", "Episode 01 - Test")]
143+
setup_search_mock(mock_auth, videos)
144+
145+
result = runner.invoke(
146+
app,
147+
[
148+
"videos",
149+
"search-replace",
150+
"-s",
151+
r"Episode (\d+)",
152+
"-r",
153+
r"Ep.\1",
154+
"-f",
155+
"title",
156+
"--regex",
157+
],
158+
)
159+
assert result.exit_code == 0
160+
assert "Ep.01" in result.stdout
161+
162+
def test_limit_caps_matches(self, mock_auth):
163+
videos = [make_search_video(f"vid{i}", f"OLDNAME Episode {i}") for i in range(5)]
164+
setup_search_mock(mock_auth, videos)
165+
166+
result = runner.invoke(
167+
app,
168+
[
169+
"videos",
170+
"search-replace",
171+
"-s",
172+
"OLDNAME",
173+
"-r",
174+
"NewName",
175+
"-f",
176+
"title",
177+
"-n",
178+
"2",
179+
],
180+
)
181+
assert result.exit_code == 0
182+
assert "2 changes" in result.stdout
183+
184+
def test_description_field(self, mock_auth):
185+
videos = [make_search_video("vid1", "Title", description="Visit OLDSITE.com")]
186+
setup_search_mock(mock_auth, videos)
187+
188+
result = runner.invoke(
189+
app,
190+
[
191+
"videos",
192+
"search-replace",
193+
"-s",
194+
"OLDSITE.com",
195+
"-r",
196+
"newsite.com",
197+
"-f",
198+
"description",
199+
],
200+
)
201+
assert result.exit_code == 0
202+
assert "newsite.com" in result.stdout
203+
204+
def test_pagination_reaches_all_videos(self, mock_auth):
205+
page1_videos = [make_search_video("vid1", "No Match Here")]
206+
page2_videos = [make_search_video("vid2", "OLDNAME Old Video")]
207+
208+
call_count = 0
209+
210+
def search_side_effect():
211+
nonlocal call_count
212+
call_count += 1
213+
if call_count == 1:
214+
return {
215+
"items": [{"id": {"videoId": "vid1"}}],
216+
"nextPageToken": "page2token",
217+
}
218+
return {
219+
"items": [{"id": {"videoId": "vid2"}}],
220+
}
221+
222+
mock_auth.search.return_value.list.return_value.execute.side_effect = search_side_effect
223+
224+
videos_call_count = 0
225+
226+
def videos_side_effect():
227+
nonlocal videos_call_count
228+
videos_call_count += 1
229+
if videos_call_count == 1:
230+
return {"items": page1_videos}
231+
return {"items": page2_videos}
232+
233+
mock_auth.videos.return_value.list.return_value.execute.side_effect = videos_side_effect
234+
235+
result = runner.invoke(
236+
app,
237+
[
238+
"videos",
239+
"search-replace",
240+
"-s",
241+
"OLDNAME",
242+
"-r",
243+
"NewName",
244+
"-f",
245+
"title",
246+
],
247+
)
248+
assert result.exit_code == 0
249+
assert "NewName Old Video" in result.stdout

0 commit comments

Comments
 (0)