Home

dev / openkara

publicthedavidweng/OpenKara· sync paused
Code Branches Pull requestsIssuesInsights
main
Home Code PRsIssues
dev/openkara/src-tauri/tests/phase4_fetch.rs
1use std::{2    fs,3    path::{Path, PathBuf},4};56use lofty::{7    config::WriteOptions,8    tag::{ItemKey, Tag, TagExt, TagType},9};10mod support;1112use openkara_lib::{13
cache,
14 commands::lyrics::fetch_lyrics_from_connection,
15 library::Song,
16 library_root::LibraryRoot,
17 lyrics::{
18 fetch::{
19 fetch_lyrics_for_song, read_embedded_lyrics, LyricsFetchResult, LyricsSource,
20 TimedLyricsProvider,
21 },
22 lrcapi::LrcApiClient,
23 lrclib::LrcLibClient,
24 },
25};
26
27fn metadata_fixture_path(filename: &str) -> PathBuf {
28 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
29 .join("tests")
30 .join("fixtures")
31 .join("metadata")
32 .join(filename)
33}
34
35fn unique_fixture_dir() -> PathBuf {
36 support::unique_temp_path("phase4-fetch")
37}
38
39fn cleanup_dir(path: &Path) {
40 if path.exists() {
41 fs::remove_dir_all(path).expect("temporary fixture directory should be removable");
42 }
43}
44
45fn fixture_song(file_path: &Path) -> Song {
46 Song {
47 hash: "fixture-song".to_owned(),
48 file_path: Some(file_path.display().to_string()),
49 cdg_path: None,
50 media_g_container: None,
51 instrumental: false,
52 language: None,
53 audio_source_kind: "original".to_owned(),
54 title: Some("Yellow".to_owned()),
55 artist: Some("Coldplay".to_owned()),
56 album: Some("Parachutes".to_owned()),
57 duration_ms: 267_000,
58 cover_art: None,
59 has_cover_art: true,
60 artwork_thumb_path: None,
61 imported_at: 1,
62 original_ext: None,
63 }
64}
65
66#[test]
67fn fetch_chain_prefers_sidecar_without_calling_online_sources() {
68 let fixture_dir = unique_fixture_dir();
69 cleanup_dir(&fixture_dir);
70 fs::create_dir_all(&fixture_dir).expect("fixture directory should create");
71
72 let audio_path = fixture_dir.join("yellow.mp3");
73 fs::copy(metadata_fixture_path("fixture.mp3"), &audio_path).expect("fixture audio should copy");
74 fs::write(audio_path.with_extension("lrc"), "[00:10.00] from sidecar")
75 .expect("sidecar should write");
76
77 let mut server = mockito::Server::new();
78 let mock = server
79 .mock("GET", "/api/get")
80 .match_query(mockito::Matcher::Any)
81 .with_status(200)
82 .with_header("content-type", "application/json")
83 .with_body(
84 r#"{
85 "id": 1,
86 "trackName": "Yellow",
87 "artistName": "Coldplay",
88 "albumName": "Parachutes",
89 "duration": 267.0,
90 "instrumental": false,
91 "syncedLyrics": "[00:35.66] from lrclib"
92 }"#,
93 )
94 .expect_at_most(0)
95 .create();
96
97 let lrclib_client = LrcLibClient::new(server.url());
98 let lrcapi = LrcApiClient::new("http://127.0.0.1:9");
99 let providers = [
100 TimedLyricsProvider::LrcLib(&lrclib_client),
101 TimedLyricsProvider::LrcApi(&lrcapi),
102 ];
103
104 let fetched = fetch_lyrics_for_song(&providers, &fixture_song(&audio_path), &audio_path)
105 .expect("fetch chain should succeed")
106 .expect("lyrics should be returned");
107
108 assert_eq!(
109 fetched,
110 LyricsFetchResult {
111 source: LyricsSource::Sidecar,
112 raw_lrc: "[00:10.00] from sidecar".to_owned(),
113 }
114 );
115
116 mock.assert();
117 cleanup_dir(&fixture_dir);
118}
119
120#[test]
121fn fetch_chain_uses_lrcapi_when_no_local_lyrics_exist() {
122 let fixture_dir = unique_fixture_dir();
123 cleanup_dir(&fixture_dir);
124 fs::create_dir_all(&fixture_dir).expect("fixture directory should create");
125
126 let audio_path = fixture_dir.join("yellow.mp3");
127 fs::copy(metadata_fixture_path("fixture.mp3"), &audio_path).expect("fixture audio should copy");
128
129 let mut lrclib_server = mockito::Server::new();
130 let lrclib_mock = lrclib_server
131 .mock("GET", "/api/get")
132 .match_query(mockito::Matcher::Any)
133 .with_status(404)
134 .create();
135
136 let mut lrcapi_server = mockito::Server::new();
137 let lrcapi_mock = lrcapi_server
138 .mock("GET", "/jsonapi")
139 .match_query(mockito::Matcher::AllOf(vec![
140 mockito::Matcher::UrlEncoded("title".into(), "Yellow".into()),
141 mockito::Matcher::UrlEncoded("artist".into(), "Coldplay".into()),
142 mockito::Matcher::UrlEncoded("album".into(), "Parachutes".into()),
143 ]))
144 .with_status(200)
145 .with_header("content-type", "application/json")
146 .with_body(
147 r#"[
148 {
149 "id": "2",
150 "title": "Yellow",
151 "artist": "Coldplay",
152 "album": "Parachutes",
153 "score": 99.0,
154 "lrc": "[00:33.64] from lrcapi",
155 "lrc_ttml": null,
156 "lyric_path": "/lyrics/yellow"
157 }
158 ]"#,
159 )
160 .create();
161
162 let lrclib_client = LrcLibClient::new(lrclib_server.url());
163 let lrcapi = LrcApiClient::new(lrcapi_server.url());
164 let providers = [
165 TimedLyricsProvider::LrcLib(&lrclib_client),
166 TimedLyricsProvider::LrcApi(&lrcapi),
167 ];
168
169 let fetched = fetch_lyrics_for_song(&providers, &fixture_song(&audio_path), &audio_path)
170 .expect("fetch chain should succeed")
171 .expect("LrcApi lyrics should be returned");
172
173 assert_eq!(
174 fetched,
175 LyricsFetchResult {
176 source: LyricsSource::LrcApi,
177 raw_lrc: "[00:33.64] from lrcapi".to_owned(),
178 }
179 );
180
181 lrclib_mock.assert();
182 lrcapi_mock.assert();
183 cleanup_dir(&fixture_dir);
184}
185
186#[test]
187fn fetch_chain_returns_none_when_online_sources_miss_and_no_local_lyrics() {
188 let fixture_dir = unique_fixture_dir();
189 cleanup_dir(&fixture_dir);
190 fs::create_dir_all(&fixture_dir).expect("fixture directory should create");
191
192 let audio_path = fixture_dir.join("yellow.mp3");
193 fs::copy(metadata_fixture_path("fixture.mp3"), &audio_path).expect("fixture audio should copy");
194
195 let mut lrclib_server = mockito::Server::new();
196 let lrclib_mock = lrclib_server
197 .mock("GET", "/api/get")
198 .match_query(mockito::Matcher::Any)
199 .with_status(404)
200 .create();
201
202 let mut lrcapi_server = mockito::Server::new();
203 let lrcapi_mock = lrcapi_server
204 .mock("GET", "/jsonapi")
205 .match_query(mockito::Matcher::Any)
206 .with_status(200)
207 .with_header("content-type", "application/json")
208 .with_body(r#"{"message":"未找到歌词"}"#)
209 .create();
210
211 let lrclib_client = LrcLibClient::new(lrclib_server.url());
212 let lrcapi = LrcApiClient::new(lrcapi_server.url());
213 let providers = [
214 TimedLyricsProvider::LrcLib(&lrclib_client),
215 TimedLyricsProvider::LrcApi(&lrcapi),
216 ];
217
218 let fetched = fetch_lyrics_for_song(&providers, &fixture_song(&audio_path), &audio_path)
219 .expect("fetch chain should succeed");
220
221 assert!(fetched.is_none());
222
223 lrclib_mock.assert();
224 lrcapi_mock.assert();
225 cleanup_dir(&fixture_dir);
226}
227
228#[test]
229fn reads_embedded_lyrics_from_mp4_audio_even_when_extension_is_aac() {
230 let fixture_dir = unique_fixture_dir();
231 cleanup_dir(&fixture_dir);
232 fs::create_dir_all(&fixture_dir).expect("fixture directory should create");
233
234 let tagged_m4a_path = fixture_dir.join("lyrics-source.m4a");
235 fs::copy(metadata_fixture_path("fixture.m4a"), &tagged_m4a_path)
236 .expect("fixture m4a should copy");
237
238 let mut tag = Tag::new(TagType::Mp4Ilst);
239 tag.insert_text(ItemKey::Lyrics, "[00:10.00] embedded line".to_owned());
240 tag.save_to_path(&tagged_m4a_path, WriteOptions::default())
241 .expect("lyrics tag should save");
242
243 let disguised_aac_path = fixture_dir.join("lyrics-source.aac");
244 fs::copy(&tagged_m4a_path, &disguised_aac_path).expect("tagged m4a should copy to .aac");
245
246 let embedded =
247 read_embedded_lyrics(&disguised_aac_path).expect("embedded lyrics read should succeed");
248
249 assert_eq!(embedded.as_deref(), Some("[00:10.00] embedded line"));
250
251 cleanup_dir(&fixture_dir);
252}
253
254/// Regression test for the `[offset:]` metadata tag being propagated through
255/// `fetch_lyrics_from_connection`. Before the fix, all four caching paths
256/// hardcoded `offset_ms: 0` even when the LRC contained an `[offset:]` tag.
257/// This test exercises the sidecar path end-to-end: a sidecar `.lrc` with an
258/// `[offset:-250]` tag should produce a `LyricsPayload` with `offset_ms: -250`.
259#[test]
260fn fetch_lyrics_from_connection_propagates_lrc_offset_tag() {
261 let lib_dir = support::unique_temp_path("phase4-offset");
262 cleanup_dir(&lib_dir);
263 let library = LibraryRoot::create(&lib_dir).expect("library should create");
264
265 // Copy the fixture audio into the library's media directory.
266 let audio_path = library.resolve("media/song.mp3");
267 fs::copy(metadata_fixture_path("fixture.mp3"), &audio_path).expect("fixture audio should copy");
268
269 // Write a sidecar LRC with an [offset:] tag.
270 let lrc_content = "[offset:-250]\n[00:10.00]Look at the stars\n";
271 fs::write(audio_path.with_extension("lrc"), lrc_content).expect("sidecar LRC should write");
272
273 // Set up an in-memory database with the song registered.
274 let connection =
275 rusqlite::Connection::open(library.database_path()).expect("library database should open");
276 cache::apply_migrations(&connection).expect("migrations should succeed");
277 cache::upsert_song(
278 &connection,
279 &Song {
280 hash: "offset-test-song".to_owned(),
281 file_path: Some("media/song.mp3".to_owned()),
282 cdg_path: None,
283 media_g_container: None,
284 instrumental: false,
285 language: None,
286 audio_source_kind: "original".to_owned(),
287 title: Some("Yellow".to_owned()),
288 artist: Some("Coldplay".to_owned()),
289 album: Some("Parachutes".to_owned()),
290 duration_ms: 267_000,
291 cover_art: None,
292 has_cover_art: true,
293 artwork_thumb_path: None,
294 imported_at: 1,
295 original_ext: None,
296 },
297 )
298 .expect("song insert should succeed");
299
300 // Point both online providers at unreachable addresses so the sidecar
301 // is the only source that can return lyrics.
302 let lrclib_client = LrcLibClient::new("http://127.0.0.1:9");
303 let lrcapi_client = LrcApiClient::new("http://127.0.0.1:9");
304
305 let payload = fetch_lyrics_from_connection(
306 &connection,
307 &library,
308 &lrclib_client,
309 &lrcapi_client,
310 "offset-test-song",
311 )
312 .expect("fetch_lyrics_from_connection should succeed");
313
314 assert_eq!(
315 payload.offset_ms, -250,
316 "offset_ms should be extracted from the [offset:] tag, not hardcoded to 0"
317 );
318 assert_eq!(payload.source, Some(LyricsSource::Sidecar));
319
320 // Verify the offset was also persisted to the cache.
321 let cached = cache::lyrics::get_lyrics_cache_entry(&connection, "offset-test-song")
322 .expect("cache lookup should succeed")
323 .expect("cache entry should exist");
324 assert_eq!(cached.offset_ms, -250);
325
326 cleanup_dir(&lib_dir);
327}
328
329/// When the LRC has no `[offset:]` tag, `offset_ms` should default to 0
330/// (via `unwrap_or(0)`), matching the pre-fix behavior for tagless LRC.
331#[test]
332fn fetch_lyrics_from_connection_defaults_offset_to_zero_without_tag() {
333 let lib_dir = support::unique_temp_path("phase4-offset-none");
334 cleanup_dir(&lib_dir);
335 let library = LibraryRoot::create(&lib_dir).expect("library should create");
336
337 let audio_path = library.resolve("media/song.mp3");
338 fs::copy(metadata_fixture_path("fixture.mp3"), &audio_path).expect("fixture audio should copy");
339
340 // Sidecar LRC without an [offset:] tag.
341 let lrc_content = "[00:10.00]Look at the stars\n";
342 fs::write(audio_path.with_extension("lrc"), lrc_content).expect("sidecar LRC should write");
343
344 let connection =
345 rusqlite::Connection::open(library.database_path()).expect("library database should open");
346 cache::apply_migrations(&connection).expect("migrations should succeed");
347 cache::upsert_song(
348 &connection,
349 &Song {
350 hash: "no-offset-test-song".to_owned(),
351 file_path: Some("media/song.mp3".to_owned()),
352 cdg_path: None,
353 media_g_container: None,
354 instrumental: false,
355 language: None,
356 audio_source_kind: "original".to_owned(),
357 title: Some("Yellow".to_owned()),
358 artist: Some("Coldplay".to_owned()),
359 album: Some("Parachutes".to_owned()),
360 duration_ms: 267_000,
361 cover_art: None,
362 has_cover_art: true,
363 artwork_thumb_path: None,
364 imported_at: 1,
365 original_ext: None,
366 },
367 )
368 .expect("song insert should succeed");
369
370 let lrclib_client = LrcLibClient::new("http://127.0.0.1:9");
371 let lrcapi_client = LrcApiClient::new("http://127.0.0.1:9");
372
373 let payload = fetch_lyrics_from_connection(
374 &connection,
375 &library,
376 &lrclib_client,
377 &lrcapi_client,
378 "no-offset-test-song",
379 )
380 .expect("fetch_lyrics_from_connection should succeed");
381
382 assert_eq!(
383 payload.offset_ms, 0,
384 "offset_ms should default to 0 when no [offset:] tag is present"
385 );
386
387 cleanup_dir(&lib_dir);
388}
389