feat(metadata): expand the local tag editor

Promote metadata editing to a routed full-page workflow with tabular per-track fields, ordered artist credits, provider identifiers, and compilation handling.

Add bounded artwork selection and retrieval through MusicBrainz, Discogs, and Last.fm. Direct and sidecar saves preflight immutable rows, verify rich tag writes, cache covers by content, and update exact database ids transactionally.

cx
This commit is contained in:
vicrodh committed 2026-08-24 19:46:59 -06:00
1 parent ed8d11e5c8
commit 75a668ed12
16 files changed
+2726 -195

No files matched your search

+571 -54
View File
@@ -1,6 +1,7 @@
// Local album metadata editor. The selected physical version is immutable for
// the lifetime of this modal: QML edits row ids and values only; Rust owns the
// file paths, preflights the whole batch and verifies direct writes.
// Full-page local album metadata editor. The historical filename is retained
// so existing qrc registrations stay stable; ContentRouter is its only mount.
// The selected physical version is immutable for the lifetime of this view:
// QML edits row ids and values only; Rust owns paths and verifies direct writes.
import QtQuick
import com.blitzfc.qbz
@@ -8,12 +9,13 @@ import "../theme"
Item {
id: root
visible: QbzTagEditor.editorOpen
enabled: visible
z: 3150
visible: true
enabled: true
property string albumTitle: ""
property string albumArtist: ""
property string albumArtistsText: ""
property bool compilation: false
property string year: ""
property string genre: ""
property string catalogNumber: ""
@@ -28,11 +30,27 @@ Item {
property var remoteResults: []
property var remoteMetadata: null
property string remoteProvider: "musicbrainz"
property string musicbrainzReleaseId: ""
property string musicbrainzReleaseGroupId: ""
property string musicbrainzAlbumArtistIdsText: ""
property string discogsReleaseId: ""
property var artwork: ({})
property var artworkResults: []
property string artworkProvider: "musicbrainz"
property int selectedTrackIndex: -1
function tr(s) { return QbzSession.tr(s, QbzSession.trRev) }
function parse(value) {
try { return JSON.parse(value) } catch (e) { return ({}) }
}
function listText(values) {
return (values || []).join("; ")
}
function splitList(value) {
return String(value || "").split(/[;\n]/).map(function(part) {
return part.trim()
}).filter(function(part) { return part.length > 0 })
}
function cloneRows(rows) {
var copy = []
for (var i = 0; i < rows.length; ++i) {
@@ -42,6 +60,13 @@ Item {
"title": rows[i].title || "",
"trackNumber": rows[i].trackNumber || "",
"discNumber": rows[i].discNumber || "",
"artistCredit": rows[i].artistCredit || "",
"artists": (rows[i].artists || []).slice(),
"composers": (rows[i].composers || []).slice(),
"performers": (rows[i].performers || []).slice(),
"musicbrainzRecordingId": rows[i].musicbrainzRecordingId || "",
"musicbrainzTrackId": rows[i].musicbrainzTrackId || "",
"musicbrainzArtistIds": (rows[i].musicbrainzArtistIds || []).slice(),
"cueBased": rows[i].cueBased === true
})
}
@@ -60,9 +85,17 @@ Item {
return
albumTitle = doc.albumTitle || ""
albumArtist = doc.albumArtist || ""
albumArtistsText = listText(doc.albumArtists)
compilation = doc.compilation === true
year = doc.year || ""
genre = doc.genre || ""
catalogNumber = doc.catalogNumber || ""
musicbrainzReleaseId = doc.musicbrainzReleaseId || ""
musicbrainzReleaseGroupId = doc.musicbrainzReleaseGroupId || ""
musicbrainzAlbumArtistIdsText = listText(doc.musicbrainzAlbumArtistIds)
discogsReleaseId = doc.discogsReleaseId || ""
artwork = doc.artwork || ({})
artworkResults = []
tracks = cloneRows(doc.tracks)
inspection = doc.inspection || ({})
canDirectWrite = doc.canDirectWrite === true
@@ -72,6 +105,7 @@ Item {
syncSecondary = false
remoteResults = []
remoteMetadata = null
selectedTrackIndex = tracks.length > 0 ? 0 : -1
seeded = true
keyScope.forceActiveFocus()
}
@@ -81,15 +115,29 @@ Item {
"id": row.id,
"title": row.title,
"trackNumber": row.trackNumber,
"discNumber": row.discNumber
"discNumber": row.discNumber,
"artistCredit": row.artistCredit,
"artists": row.artists || [],
"composers": row.composers || [],
"performers": row.performers || [],
"musicbrainzRecordingId": row.musicbrainzRecordingId || "",
"musicbrainzTrackId": row.musicbrainzTrackId || "",
"musicbrainzArtistIds": row.musicbrainzArtistIds || []
}
})
return JSON.stringify({
"albumTitle": albumTitle,
"albumArtist": albumArtist,
"albumArtists": splitList(albumArtistsText),
"compilation": compilation,
"year": year,
"genre": genre,
"catalogNumber": catalogNumber,
"musicbrainzReleaseId": musicbrainzReleaseId,
"musicbrainzReleaseGroupId": musicbrainzReleaseGroupId,
"musicbrainzAlbumArtistIds": splitList(musicbrainzAlbumArtistIdsText),
"discogsReleaseId": discogsReleaseId,
"artworkToken": artwork.token || "",
"persistence": persistence,
"id3v2Version": id3Version,
"synchronizeSecondaryTags": syncSecondary,
@@ -110,9 +158,20 @@ Item {
return
if (m.title) albumTitle = m.title
if (m.artist) albumArtist = m.artist
if (m.artist_credits && m.artist_credits.length)
albumArtistsText = listText(m.artist_credits.map(function(credit) { return credit.name }))
year = m.year ? String(m.year) : ""
genre = m.genres && m.genres.length ? m.genres.join("; ") : ""
catalogNumber = m.catalog_number || ""
if (String(m.provider) === "musicbrainz") {
musicbrainzReleaseId = m.provider_id || ""
musicbrainzReleaseGroupId = m.release_group_id || ""
musicbrainzAlbumArtistIdsText = listText((m.artist_credits || []).map(function(credit) {
return credit.provider_id || ""
}).filter(function(value) { return value.length > 0 }))
} else if (String(m.provider) === "discogs") {
discogsReleaseId = m.provider_id || ""
}
var byPosition = ({})
var remoteTracks = m.tracks || []
@@ -131,6 +190,15 @@ Item {
next[i].title = match.title || next[i].title
next[i].discNumber = String(match.disc_number || next[i].discNumber)
next[i].trackNumber = String(match.track_number || next[i].trackNumber)
next[i].artistCredit = match.artist_credit || next[i].artistCredit
if (match.artist_credits && match.artist_credits.length) {
next[i].artists = match.artist_credits.map(function(credit) { return credit.name })
next[i].musicbrainzArtistIds = match.artist_credits.map(function(credit) {
return credit.provider_id || ""
}).filter(function(value) { return value.length > 0 })
}
next[i].musicbrainzRecordingId = match.recording_id || next[i].musicbrainzRecordingId
next[i].musicbrainzTrackId = match.track_id || next[i].musicbrainzTrackId
}
}
tracks = next
@@ -150,19 +218,19 @@ Item {
}
}
onVisibleChanged: {
if (visible && !QbzTagEditor.editorLoading)
Component.onCompleted: {
if (!QbzTagEditor.editorLoading)
seed(parse(QbzTagEditor.editorJson))
if (!visible) {
seeded = false
restoreShellFocus()
}
}
Component.onDestruction: {
QbzTagEditor.leave()
restoreShellFocus()
}
Connections {
target: QbzTagEditor
function onEditorJsonChanged() {
if (root.visible && !QbzTagEditor.editorLoading)
if (!QbzTagEditor.editorLoading)
root.seed(root.parse(QbzTagEditor.editorJson))
}
// Rust publishes the immutable document before it clears loading so
@@ -170,7 +238,7 @@ Item {
// document on that second signal as well; otherwise the JSON change is
// deliberately ignored while loading and the modal spins forever.
function onEditorLoadingChanged() {
if (root.visible && !QbzTagEditor.editorLoading)
if (!QbzTagEditor.editorLoading)
root.seed(root.parse(QbzTagEditor.editorJson))
}
function onRemoteSeqChanged() {
@@ -180,6 +248,10 @@ Item {
root.remoteMetadata = null
} else if (event.kind === "metadata") {
root.remoteMetadata = event.value || null
} else if (event.kind === "artwork-results") {
root.artworkResults = event.value || []
} else if (event.kind === "artwork-selected") {
root.artwork = event.value || ({})
}
}
}
@@ -195,23 +267,12 @@ Item {
}
}
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea { anchors.fill: parent; onClicked: root.closeEditor() }
}
Rectangle {
id: panel
anchors.centerIn: parent
width: Math.min(parent.width - 64, 1040)
height: Math.min(parent.height - 48, 820)
radius: theme.radiusLg
anchors.fill: parent
radius: 0
color: theme.surfaceMain
border.width: 1
border.color: theme.borderSubtle
clip: true
MouseArea { anchors.fill: parent }
Item {
id: header
@@ -286,6 +347,171 @@ Item {
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Row {
width: parent.width
height: 154
spacing: 16
Rectangle {
width: 154
height: 154
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
clip: true
Image {
id: albumArtwork
anchors.fill: parent
source: root.artwork.previewPath || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
visible: source !== ""
}
QbzIcon {
anchors.centerIn: parent
name: "disc-3"
width: 42
height: 42
tintName: "muted"
visible: !albumArtwork.visible
}
QbzSpinner {
anchors.centerIn: parent
size: 26
visible: QbzTagEditor.artworkLoading
}
}
Column {
width: parent.width - 170
spacing: 8
Row {
id: artworkActions
width: parent.width
spacing: 8
SettingsButton {
text: root.artwork.previewPath ? root.tr("Choose another file") : root.tr("Choose file")
iconName: "image-plus"
minWidth: 0
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.chooseArtwork()
}
SettingsButton {
visible: (root.artwork.token || "") !== ""
text: root.tr("Revert selection")
iconName: "rotate-ccw"
minWidth: 0
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.clearArtwork()
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, artworkActions.width
- artworkActions.children[0].width
- artworkActions.children[1].width
- artworkActions.spacing * 2)
text: (root.artwork.source || "")
+ (root.artwork.width ? " · " + root.artwork.width + " × " + root.artwork.height : "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Row {
spacing: 8
QbzSelect {
width: 150
menuWidth: 150
options: ["MusicBrainz", "Discogs", "Last.fm"]
currentIndex: root.artworkProvider === "discogs" ? 1
: root.artworkProvider === "lastfm" ? 2 : 0
enabled: !QbzTagEditor.artworkSearching && !QbzTagEditor.artworkLoading
onSelected: function(index) {
root.artworkProvider = index === 1 ? "discogs"
: index === 2 ? "lastfm" : "musicbrainz"
}
}
SettingsButton {
text: QbzTagEditor.artworkSearching ? root.tr("Searching…") : root.tr("Find artwork")
iconName: "search"
minWidth: 0
enabled: !QbzTagEditor.artworkSearching && !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.searchArtwork(
root.artworkProvider,
root.albumTitle,
root.albumArtist,
root.catalogNumber)
}
}
ListView {
id: artworkList
width: parent.width
height: 92
orientation: ListView.Horizontal
spacing: 8
clip: true
model: root.artworkResults
delegate: Rectangle {
required property var modelData
width: 196
height: 88
radius: theme.radiusSm
color: artMouse.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Image {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 4
width: 80
source: modelData.previewUrl || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
Column {
anchors.left: parent.left
anchors.leftMargin: 92
anchors.right: parent.right
anchors.rightMargin: 7
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: modelData.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
Text {
width: parent.width
text: modelData.source || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
Text {
width: parent.width
text: modelData.detail || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
MouseArea {
id: artMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.selectArtwork(parent.modelData.id || "")
}
}
}
}
}
Grid {
width: parent.width
columns: 2
@@ -303,6 +529,32 @@ Item {
onCommitted: function(value) { root.albumTitle = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Album artists (ordered; separate with semicolons)"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.albumArtistsText
onEdited: function(value) { root.albumArtistsText = value }
onCommitted: function(value) { root.albumArtistsText = value }
}
}
Row {
width: (content.width - 16) / 2
spacing: 8
QbzToggle {
anchors.verticalCenter: parent.verticalCenter
checked: root.compilation
onToggled: function(value) { root.compilation = value }
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.tr("Compilation / Various Artists")
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
@@ -351,6 +603,50 @@ Item {
}
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz release ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzReleaseId
onEdited: function(value) { root.musicbrainzReleaseId = value }
onCommitted: function(value) { root.musicbrainzReleaseId = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz release-group ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzReleaseGroupId
onEdited: function(value) { root.musicbrainzReleaseGroupId = value }
onCommitted: function(value) { root.musicbrainzReleaseGroupId = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz album artist IDs"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzAlbumArtistIdsText
onEdited: function(value) { root.musicbrainzAlbumArtistIdsText = value }
onCommitted: function(value) { root.musicbrainzAlbumArtistIdsText = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Discogs release ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.discogsReleaseId
onEdited: function(value) { root.discogsReleaseId = value }
onCommitted: function(value) { root.discogsReleaseId = value }
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
@@ -515,7 +811,7 @@ Item {
Row {
width: parent.width
Text {
text: root.tr("Tracks") + " · " + root.tr("Disc") + " / " + root.tr("Track")
text: root.tr("Tracks")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
@@ -529,52 +825,273 @@ Item {
elide: Text.ElideRight
}
}
Rectangle {
width: parent.width
height: 32
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
Text {
width: 64
height: parent.height
leftPadding: 10
text: root.tr("Disc")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 72
height: parent.height
leftPadding: 10
text: root.tr("Track")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: Math.max(160, parent.width - 64 - 72 - 220 - 240 - 4)
height: parent.height
leftPadding: 10
text: root.tr("Title")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 220
height: parent.height
leftPadding: 10
text: root.tr("Artist credit")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 240
height: parent.height
leftPadding: 10
text: root.tr("File")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
}
}
ListView {
id: trackList
width: parent.width
height: Math.min(280, Math.max(108, content.height > scroll.height ? 180 : 280))
height: Math.min(360, Math.max(112, root.tracks.length * 36))
clip: true
spacing: 4
spacing: 0
model: root.tracks
delegate: Rectangle {
required property var modelData
width: trackList.width
height: 44
radius: theme.radiusSm
color: index % 2 ? theme.surfaceElevated : theme.surfaceCard
height: 36
color: index % 2 ? theme.surfaceElevated : "transparent"
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.margins: 5
spacing: 8
QbzLineEdit {
width: 52
text: modelData.discNumber
placeholder: root.tr("Disc")
onEdited: function(value) { modelData.discNumber = value }
onCommitted: function(value) { modelData.discNumber = value }
Rectangle {
width: 64
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.discNumber
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
onTextEdited: modelData.discNumber = text
onActiveFocusChanged: if (activeFocus) root.selectedTrackIndex = index
}
}
QbzLineEdit {
width: 58
text: modelData.trackNumber
placeholder: root.tr("Track")
onEdited: function(value) { modelData.trackNumber = value }
onCommitted: function(value) { modelData.trackNumber = value }
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: 72
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.trackNumber
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
onTextEdited: modelData.trackNumber = text
onActiveFocusChanged: if (activeFocus) root.selectedTrackIndex = index
}
}
QbzLineEdit {
width: parent.width - 52 - 58 - fileName.width - 24
text: modelData.title
onEdited: function(value) { modelData.title = value }
onCommitted: function(value) { modelData.title = value }
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: Math.max(160, parent.width - 64 - 72 - 220 - 240 - 4)
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.title
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
onTextEdited: modelData.title = text
onActiveFocusChanged: if (activeFocus) root.selectedTrackIndex = index
}
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: 220
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.artistCredit
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
onTextEdited: modelData.artistCredit = text
onActiveFocusChanged: if (activeFocus) root.selectedTrackIndex = index
}
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
id: fileName
width: Math.min(220, implicitWidth)
width: 240
height: parent.height
leftPadding: 10
rightPadding: 8
text: modelData.fileName
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideMiddle
MouseArea {
anchors.fill: parent
onClicked: root.selectedTrackIndex = index
}
}
}
}
}
Rectangle {
id: trackDetails
property var rowData: root.selectedTrackIndex >= 0
&& root.selectedTrackIndex < root.tracks.length
? root.tracks[root.selectedTrackIndex] : null
visible: rowData !== null
width: parent.width
height: visible ? 190 : 0
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
radius: theme.radiusSm
Column {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Row {
width: parent.width
Text {
text: root.tr("Selected track details")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
width: parent.width - parent.children[0].implicitWidth
horizontalAlignment: Text.AlignRight
text: trackDetails.rowData ? trackDetails.rowData.fileName : ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideMiddle
}
}
Grid {
width: parent.width
columns: 2
columnSpacing: 12
rowSpacing: 7
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Artists (ordered; semicolon separated)"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.artists) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.artists = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Composers"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.composers) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.composers = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Performers"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.performers) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.performers = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz artist IDs"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.musicbrainzArtistIds) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzArtistIds = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz recording ID"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? trackDetails.rowData.musicbrainzRecordingId : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzRecordingId = value }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz track ID"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? trackDetails.rowData.musicbrainzTrackId : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzTrackId = value }
}
}
}
}
-5
View File
@@ -736,11 +736,6 @@ Rectangle {
DiscMetaModal {
anchors.fill: parent
}
// Selected-version metadata editor. Saving republishes the album beneath
// it, so it shares the app-wide lifetime of the disc and rip modals.
TagEditorModal {
anchors.fill: parent
}
RipWizardModal {
anchors.fill: parent
}
@@ -174,6 +174,7 @@ Item {
: v === "local"
? (root.kiosk ? "../kiosk/KioskLocalLibrary.qml" : "../views/LocalLibraryView.qml")
: v === "localalbum" ? "../views/LocalAlbumView.qml"
: v === "metadataeditor" ? "../controls/TagEditorModal.qml"
: v === "album"
? (root.kiosk ? "../kiosk/KioskAlbum.qml" : "../views/AlbumView.qml")
: v === "artist"
+62 -11
View File
@@ -34,6 +34,8 @@ pub mod qbz_tag_editor_bridge {
#[qproperty(QString, remote_json)]
#[qproperty(bool, remote_searching)]
#[qproperty(bool, remote_loading)]
#[qproperty(bool, artwork_searching)]
#[qproperty(bool, artwork_loading)]
/// Bumps for every remote publish, including two byte-identical ones.
#[qproperty(i32, remote_seq)]
type QbzTagEditor = super::QbzTagEditorRust;
@@ -44,6 +46,11 @@ pub mod qbz_tag_editor_bridge {
#[qinvokable]
fn close(self: Pin<&mut QbzTagEditor>);
/// Drop the editor session because navigation already left its view.
/// Unlike `close`, this must not mutate history a second time.
#[qinvokable]
fn leave(self: Pin<&mut QbzTagEditor>);
/// The entire validated draft, encoded as JSON. File paths never come
/// back from QML; Rust resolves row ids against the open session.
#[qinvokable]
@@ -58,14 +65,28 @@ pub mod qbz_tag_editor_bridge {
);
#[qinvokable]
fn load_remote(
self: Pin<&mut QbzTagEditor>,
provider: QString,
provider_id: QString,
);
fn load_remote(self: Pin<&mut QbzTagEditor>, provider: QString, provider_id: QString);
#[qinvokable]
fn open_remote(self: Pin<&mut QbzTagEditor>, provider: QString, provider_id: QString);
#[qinvokable]
fn choose_artwork(self: Pin<&mut QbzTagEditor>);
#[qinvokable]
fn search_artwork(
self: Pin<&mut QbzTagEditor>,
provider: QString,
title: QString,
artist: QString,
catalog_number: QString,
);
#[qinvokable]
fn select_artwork(self: Pin<&mut QbzTagEditor>, candidate_id: QString);
#[qinvokable]
fn clear_artwork(self: Pin<&mut QbzTagEditor>);
}
impl cxx_qt::Threading for QbzTagEditor {}
@@ -83,6 +104,8 @@ pub struct QbzTagEditorRust {
remote_json: QString,
remote_searching: bool,
remote_loading: bool,
artwork_searching: bool,
artwork_loading: bool,
remote_seq: i32,
}
@@ -98,6 +121,8 @@ impl Default for QbzTagEditorRust {
remote_json: QString::from("{}"),
remote_searching: false,
remote_loading: false,
artwork_searching: false,
artwork_loading: false,
remote_seq: 0,
}
}
@@ -124,16 +149,15 @@ impl qbz_tag_editor_bridge::QbzTagEditor {
crate::tag_editor_qt::close();
}
pub fn leave(self: Pin<&mut Self>) {
crate::tag_editor_qt::leave();
}
pub fn save(self: Pin<&mut Self>, draft_json: QString) {
crate::tag_editor_qt::save(&draft_json.to_string());
}
pub fn search_remote(
self: Pin<&mut Self>,
provider: QString,
title: QString,
artist: QString,
) {
pub fn search_remote(self: Pin<&mut Self>, provider: QString, title: QString, artist: QString) {
crate::tag_editor_qt::search_remote(
&provider.to_string(),
&title.to_string(),
@@ -148,4 +172,31 @@ impl qbz_tag_editor_bridge::QbzTagEditor {
pub fn open_remote(self: Pin<&mut Self>, provider: QString, provider_id: QString) {
crate::tag_editor_qt::open_remote(&provider.to_string(), &provider_id.to_string());
}
pub fn choose_artwork(self: Pin<&mut Self>) {
crate::tag_editor_qt::choose_artwork();
}
pub fn search_artwork(
self: Pin<&mut Self>,
provider: QString,
title: QString,
artist: QString,
catalog_number: QString,
) {
crate::tag_editor_qt::search_artwork(
&provider.to_string(),
&title.to_string(),
&artist.to_string(),
&catalog_number.to_string(),
);
}
pub fn select_artwork(self: Pin<&mut Self>, candidate_id: QString) {
crate::tag_editor_qt::select_artwork(&candidate_id.to_string());
}
pub fn clear_artwork(self: Pin<&mut Self>) {
crate::tag_editor_qt::clear_artwork();
}
}
File diff suppressed because it is too large. Load diff