Add conflict modal and partially working LRCLIB edit lyrics flow

This commit is contained in:
tranxuanthang
2026-04-17 14:46:16 +07:00
parent 57e686b845
commit 2fc352e3f3
7 changed files with 218 additions and 9 deletions

View File

@@ -396,9 +396,11 @@ Users can pick:
- [x] Phase 4: LRCLIB lyricsfile integration
- [x] Migration 12: Add `lrclib_instance` and `lrclib_id` columns to `lyricsfiles` table
- [x] Add `prepare_lrclib_lyricsfile` command to fetch/create standalone lyricsfiles
- [x] Add `refresh_lrclib_lyricsfile` command to force re-download from LRCLIB
- [x] Update `save_lyrics` command to support standalone lyricsfiles
- [x] Update `AssociateTrackModal.vue` - remove "Edit without audio" option
- [x] Update `SearchResult.vue` - use `prepare_lrclib_lyricsfile` command
- [x] Create `LyricsfileConflictModal.vue` - ask user to redownload or continue when lyrics already exist
- [x] Update `EditLyricsV2.vue` - accept `lyricsfileId` and `initialLyricsfile` props
- [x] Update `useEditLyricsV2Document.js` - support initialization from external lyricsfile
- [ ] Phase 5: Remove legacy `EditLyrics.vue` (after testing)

View File

@@ -201,7 +201,8 @@ Implements `From<PersistentTrack>` for seamless conversion from database entitie
| `retrieve_lyrics/by_id()` | Get raw LRCLIB response |
| `search_lyrics()` | Search LRCLIB database |
| `apply_lyrics()` | Save a selected LRCLIB result into database-backed lyrics storage |
| `prepare_lrclib_lyricsfile(lrclib_id)` | Get or create lyricsfile from LRCLIB. Checks local cache first, fetches from API if needed. Saves to `lyricsfiles` table with `lrclib_instance` + `lrclib_id`. Returns `lyricsfile_id` + content. |
| `prepare_lrclib_lyricsfile(lrclib_id)` | Get or create lyricsfile from LRCLIB. Checks local cache first, fetches from API if needed. Saves to `lyricsfiles` table with `lrclib_instance` + `lrclib_id`. Returns `lyricsfile_id` + content + `exists_in_db` flag. |
| `refresh_lrclib_lyricsfile(lrclib_id)` | Force re-download lyrics from LRCLIB API. Updates existing record in `lyricsfiles` table. Returns refreshed `lyricsfile_id` + content. |
| `save_lyrics(track_id?, lyricsfile_id?, plain?, synced?, lyricsfile?)` | Save lyrics edits. For library tracks: provide `track_id`. For standalone LRCLIB lyrics: provide `lyricsfile_id`. Prefers `lyricsfile` format. |
| `publish_lyrics(title, album, artist, duration, plain?, synced?, lyricsfile?)` | Upload to LRCLIB (with PoW; accepts Lyricsfile-only payloads) |
| `export_lyrics(track_id, formats, lyricsfile?)` | Manual export to `.txt`, `.lrc`, or embedded tags |

View File

@@ -796,6 +796,7 @@ struct PrepareLyricsfileResult {
pub plain_lyrics: String,
pub synced_lyrics: String,
pub is_instrumental: bool,
pub exists_in_db: bool,
}
#[tauri::command]
@@ -826,6 +827,7 @@ async fn prepare_lrclib_lyricsfile(
plain_lyrics: parsed.plain_lyrics.unwrap_or_default(),
synced_lyrics: parsed.synced_lyrics.unwrap_or_default(),
is_instrumental: parsed.is_instrumental,
exists_in_db: true,
});
}
@@ -881,6 +883,75 @@ async fn prepare_lrclib_lyricsfile(
plain_lyrics: parsed.plain_lyrics.unwrap_or_default(),
synced_lyrics: parsed.synced_lyrics.unwrap_or_default(),
is_instrumental: parsed.is_instrumental,
exists_in_db: false,
})
}
#[tauri::command]
async fn refresh_lrclib_lyricsfile(
lrclib_id: i64,
app_handle: AppHandle,
) -> Result<PrepareLyricsfileResult, String> {
// Get config for LRCLIB instance URL
let config = app_handle
.db(|db: &Connection| db::get_config(db))
.map_err(|err| err.to_string())?;
let lrclib_instance = config.lrclib_instance;
// Fetch fresh data from LRCLIB API (always re-download)
let lrclib_response = lrclib::get_by_id::request_raw(lrclib_id, &lrclib_instance)
.await
.map_err(|err| err.to_string())?;
// Extract metadata from LRCLIB response
let title = lrclib_response.name.unwrap_or_default();
let album_name = lrclib_response.album_name.unwrap_or_default();
let artist_name = lrclib_response.artist_name.unwrap_or_default();
let duration = lrclib_response.duration.unwrap_or(0.0);
// Build or use existing lyricsfile content
let lyricsfile_content = if let Some(lyricsfile) = lrclib_response.lyricsfile {
// LRCLIB provided a lyricsfile, use it directly
lyricsfile
} else {
// Need to build lyricsfile from plain/synced lyrics
let metadata =
lyricsfile::LyricsfileTrackMetadata::new(&title, &album_name, &artist_name, duration);
let plain = lrclib_response.plain_lyrics.as_deref();
let synced = lrclib_response.synced_lyrics.as_deref();
lyricsfile::build_lyricsfile(&metadata, plain, synced)
.ok_or("Failed to build lyricsfile from LRCLIB response")?
};
// Parse for return values
let parsed = lyricsfile::parse_lyricsfile(&lyricsfile_content).map_err(|e| e.to_string())?;
// Save to database (will update existing if lrclib_instance + lrclib_id match)
let lyricsfile_id = app_handle
.db(|db: &Connection| {
db::upsert_lyricsfile_for_lrclib(
&lrclib_instance,
lrclib_id,
&title,
&album_name,
&artist_name,
duration,
&lyricsfile_content,
db,
)
})
.map_err(|err| err.to_string())?;
Ok(PrepareLyricsfileResult {
lyricsfile_id,
lyricsfile: lyricsfile_content,
plain_lyrics: parsed.plain_lyrics.unwrap_or_default(),
synced_lyrics: parsed.synced_lyrics.unwrap_or_default(),
is_instrumental: parsed.is_instrumental,
exists_in_db: true, // After refresh, it definitely exists
})
}
@@ -1552,6 +1623,7 @@ async fn main() {
get_audio_metadata,
prepare_search_query,
prepare_lrclib_lyricsfile,
refresh_lrclib_lyricsfile,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -79,7 +79,7 @@ Module-level ref composables (singletons by design):
- _V2 Instrumental Support_: Tracks can be marked as instrumental via `PlainLyricsEmptyState.vue` or `SyncedLyricsEmptyState.vue`. When marked, the plain/synced tab switcher is disabled and a centered popup appears with an "Unmark as instrumental" button. The instrumental state is stored in the `lyricsfile` metadata and managed by `useEditLyricsV2Document.js`.
- **Export (Mass)**: `LibraryHeader.vue` has an export button (with dropdown) that emits `exportAllLyrics``Library.vue` opens `ExportViewer.vue` modal → `useExporter()` composable manages queue → invokes `export_track_lyrics` command per track. Exports to `.txt`, `.lrc`, and/or embedded metadata.
- **My LRCLIB**: User workflows (preview, edit, publish, flag) in `my-lrclib/`
- **Track Association (My LRCLIB Edit Flow)**: When editing lyrics from My LRCLIB search results, the flow is: (1) Call `prepare_lrclib_lyricsfile(lrclibId)` to fetch/create the lyricsfile in the database (stored with `lrclib_instance` and `lrclib_id`), (2) Show `AssociateTrackModal.vue` for selecting either a library track or a file from the computer, (3) Open `EditLyricsV2.vue` with the selected audio source and the standalone lyricsfile. The `get_audio_metadata` backend command extracts metadata from selected files using the existing scanner logic. Supports playback for both library tracks and arbitrary file-based tracks.
- **Track Association (My LRCLIB Edit Flow)**: When editing lyrics from My LRCLIB search results, the flow is: (1) Call `prepare_lrclib_lyricsfile(lrclibId)` to fetch/create the lyricsfile in the database (stored with `lrclib_instance` and `lrclib_id`). If lyrics already exist locally, show `LyricsfileConflictModal.vue` to ask if user wants to redownload from LRCLIB or continue with local version. (2) Show `AssociateTrackModal.vue` for selecting either a library track or a file from the computer. (3) Open `EditLyricsV2.vue` with the selected audio source and the standalone lyricsfile. The `get_audio_metadata` backend command extracts metadata from selected files using the existing scanner logic. Supports playback for both library tracks and arbitrary file-based tracks.
Utils: `src/utils/` (parsing, linting), Composables: `composables/edit-lyrics/`, `composables/edit-lyrics-v2/`, `composables/export.js`

View File

@@ -139,18 +139,32 @@ import { useGlobalState } from '@/composables/global-state.js'
import { usePlayer } from '@/composables/player.js'
const props = defineProps({
track: {
// Audio source for playback (library track or file-based track)
audioSource: {
type: Object,
default: null,
},
// Source type: 'track' for normal library editing, 'lrclib' for LRCLIB flow
source: {
type: String,
default: 'track',
validator: (value) => ['track', 'lrclib'].includes(value),
},
// For LRCLIB flow: standalone lyricsfile ID
lyricsfileId: {
type: Number,
default: null,
},
initialLyricsfile: {
// For LRCLIB flow: initial lyricsfile content
lyricsfileContent: {
type: String,
default: null,
},
// For LRCLIB flow: lyricsfile metadata (title, artist, album, duration_ms)
lyricsfileMetadata: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close'])

View File

@@ -0,0 +1,76 @@
<template>
<BaseModal
title="Existing Lyrics Found"
content-class="w-full max-w-md"
:close-button="true"
@close="emit('close')"
>
<div>Lyrics for this track were previously downloaded.</div>
<template #footer>
<div class="flex justify-end gap-2">
<button
class="button button-normal px-4 py-2 rounded-full text-sm"
:disabled="isLoading"
@click="redownload"
>
<Loading v-if="isLoading" class="animate-spin mr-1" />
<Refresh v-else class="mr-1" />
Redownload
</button>
<button
class="button button-primary px-4 py-2 rounded-full text-sm"
@click="continueEditing"
>
<Pencil class="mr-1" />
Continue Editing
</button>
</div>
</template>
</BaseModal>
</template>
<script setup>
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { useToast } from 'vue-toastification'
import BaseModal from '@/components/common/BaseModal.vue'
import Loading from '~icons/mdi/loading'
import Refresh from '~icons/mdi/refresh'
import Pencil from '~icons/mdi/pencil'
const props = defineProps({
lrclibId: {
type: Number,
required: true,
},
existingResult: {
type: Object,
required: true,
},
})
const emit = defineEmits(['close', 'redownload', 'continue'])
const toast = useToast()
const isLoading = ref(false)
const redownload = async () => {
isLoading.value = true
try {
const result = await invoke('refresh_lrclib_lyricsfile', { lrclibId: props.lrclibId })
emit('redownload', result)
emit('close')
} catch (error) {
console.error('Error refreshing lyrics:', error)
toast.error('Failed to redownload lyrics from LRCLIB')
} finally {
isLoading.value = false
}
}
const continueEditing = () => {
emit('continue', props.existingResult)
emit('close')
}
</script>

View File

@@ -116,6 +116,7 @@ import EditLyrics from './EditLyrics.vue'
import PreviewLyrics from './PreviewLyrics.vue'
import FlagLyrics from './FlagLyrics.vue'
import AssociateTrackModal from './AssociateTrackModal.vue'
import LyricsfileConflictModal from './LyricsfileConflictModal.vue'
import EditLyricsV2 from '../EditLyricsV2.vue'
import { useModal } from 'vue-final-modal'
@@ -225,6 +226,36 @@ const { open: openAssociateTrackModal, close: closeAssociateTrackModal } = useMo
},
})
// Conflict modal state
const conflictModalLrclibId = ref(null)
const conflictModalExistingResult = ref(null)
const { open: openConflictModal, close: closeConflictModal } = useModal({
component: LyricsfileConflictModal,
attrs: {
lrclibId: conflictModalLrclibId,
existingResult: conflictModalExistingResult,
onClose() {
closeConflictModal()
},
onClosed() {
conflictModalLrclibId.value = null
conflictModalExistingResult.value = null
},
onRedownload(result) {
// User chose to redownload - proceed with refreshed data
proceedToAssociateTrackModal(conflictModalLrclibTrack.value, result)
},
onContinue(result) {
// User chose to continue with existing - proceed with existing data
proceedToAssociateTrackModal(conflictModalLrclibTrack.value, result)
},
},
})
// Store the track temporarily while showing conflict modal
const conflictModalLrclibTrack = ref(null)
onMounted(async () => {
const config = await invoke('get_config')
showLineCount.value = config.show_line_count
@@ -259,17 +290,30 @@ const setShowingTrack = async track => {
}
}
const proceedToAssociateTrackModal = (track, result) => {
// Set the data and open associate modal
associateModalLrclibTrack.value = track
associateModalLyricsfileId.value = result.lyricsfileId
associateModalInitialLyricsfile.value = result.lyricsfile
openAssociateTrackModal()
}
const setEditingTrack = async track => {
isOpeningTrack.value = true
try {
// Prepare lyricsfile from LRCLIB (fetches or gets from cache)
const result = await invoke('prepare_lrclib_lyricsfile', { lrclibId: track.id })
// Show association modal
associateModalLrclibTrack.value = track
associateModalLyricsfileId.value = result.lyricsfileId
associateModalInitialLyricsfile.value = result.lyricsfile
openAssociateTrackModal()
if (result.existsInDb) {
// Lyrics already exist - show conflict modal
conflictModalLrclibTrack.value = track
conflictModalLrclibId.value = track.id
conflictModalExistingResult.value = result
openConflictModal()
} else {
// New lyrics - proceed directly to associate modal
proceedToAssociateTrackModal(track, result)
}
} catch (error) {
toast.error('An error occurred while opening the lyrics. Please try again.')
console.error(error)