mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +02:00
feat(metadata): write primary-artist tags in native writers
Native tag writers accept artist_tag_mode=primary and keep only the first credited artist in ARTIST and ALBUMARTIST for every format: FLAC download embedding, FLAC/Opus edits, and the ID3, MP4, APE and RIFF writers. The Android finalizer's FFmpeg fallback applies the same rule. Rust and Kotlin share one separator set (comma, semicolon, ampersand, a spaced x, feat/ft/featuring/with) and test the same fixtures. The Go-parity split_vorbis pattern is unchanged. Refs #618.
This commit is contained in:
1 parent
fe30871e36
commit
41340a4a21
5 files changed
+273
-3
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -373,6 +374,27 @@ internal object NativeFinalizationPolicy {
|
||||
?: ""
|
||||
}
|
||||
|
||||
// Same separator set as the Rust writer and Dart's primaryArtistTagValue;
|
||||
// Unicode classes keep \s aligned with their whitespace handling.
|
||||
private val primaryArtistSeparator = Pattern.compile(
|
||||
"\\s*[,;&]\\s*|\\s+x\\s+|\\s+(?:feat(?:uring)?|ft|with)\\.?(?:\\s+|$)",
|
||||
Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CHARACTER_CLASS,
|
||||
).toRegex()
|
||||
|
||||
/**
|
||||
* Artist tag value for [mode]. Only "primary" changes the value: the first
|
||||
* credited artist for every format. Joined and split values pass through;
|
||||
* the backend writer owns Vorbis splitting.
|
||||
*/
|
||||
fun artistTagValue(value: String, mode: String?): String {
|
||||
if (!mode.orEmpty().trim().equals("primary", ignoreCase = true)) return value
|
||||
val trimmed = value.trim()
|
||||
return trimmed.split(primaryArtistSeparator)
|
||||
.map(String::trim)
|
||||
.firstOrNull(String::isNotEmpty)
|
||||
?: trimmed
|
||||
}
|
||||
|
||||
private fun audioFormatForPath(filePath: String, fileName: String): String? {
|
||||
for (candidate in listOf(filePath, fileName)) {
|
||||
val lower = candidate.trim().lowercase(Locale.ROOT)
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.antonkarpenko.ffmpegkit.ReturnCode
|
||||
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
|
||||
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.artistTagValue
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.authoritativeAlbumArtist
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
||||
@@ -314,6 +315,7 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path:
|
||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||
NativeFinalizationPolicy.hasUsableLyricsContent(lyrics) &&
|
||||
!lyrics.trim().equals("[instrumental:true]", ignoreCase = true)
|
||||
val artistTagMode = input.request.optString("artist_tag_mode", "")
|
||||
// FLAC, MP3, Opus, and M4A all have backend tag writers that edit the
|
||||
// tag block atomically without an ffmpeg remux (which drops foreign
|
||||
// frames and rewrites the whole container). The backend answers
|
||||
@@ -326,7 +328,7 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path:
|
||||
.put("artist", artist)
|
||||
.put("album", album)
|
||||
.put("album_artist", albumArtist)
|
||||
.put("artist_tag_mode", input.request.optString("artist_tag_mode", ""))
|
||||
.put("artist_tag_mode", artistTagMode)
|
||||
.put("date", date)
|
||||
.put("isrc", isrc)
|
||||
.put("composer", composer)
|
||||
@@ -368,11 +370,13 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path:
|
||||
val isOpus = format == "opus"
|
||||
val coverFile = if (isM4a || isOpus) downloadCoverForMetadata(context, input) else null
|
||||
val labelKey = if (isM4a) "organization" else "label"
|
||||
// The backend writer applies the artist mode itself; this FFmpeg fallback
|
||||
// must store the same primary artist when that mode is selected.
|
||||
val metadataPairs = mutableListOf(
|
||||
"title" to title,
|
||||
"artist" to artist,
|
||||
"artist" to artistTagValue(artist, artistTagMode),
|
||||
"album" to album,
|
||||
"album_artist" to albumArtist,
|
||||
"album_artist" to artistTagValue(albumArtist, artistTagMode),
|
||||
"date" to date,
|
||||
"track" to trackNumber,
|
||||
"disc" to discNumber,
|
||||
|
||||
@@ -7,6 +7,39 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeFinalizationPolicyTest {
|
||||
// Shared with the Rust and Dart primary-artist tests; keep in sync.
|
||||
@Test
|
||||
fun primaryArtistModeKeepsTheFirstCreditedArtist() {
|
||||
for ((input, expected) in listOf(
|
||||
"Calle 24, Chino Pacas" to "Calle 24",
|
||||
"Calle 24 & Chino Pacas" to "Calle 24",
|
||||
"Artist A; Artist B" to "Artist A",
|
||||
"Artist A feat. Artist B" to "Artist A",
|
||||
"Artist A Feat Artist B" to "Artist A",
|
||||
"Artist A ft. Artist B" to "Artist A",
|
||||
"Artist A featuring Artist B" to "Artist A",
|
||||
"Artist A with Artist B" to "Artist A",
|
||||
"Artist A x Artist B" to "Artist A",
|
||||
"Artist A X Artist B" to "Artist A",
|
||||
" , Artist A, Artist B" to "Artist A",
|
||||
"Malcolm X" to "Malcolm X",
|
||||
"Artist Without Fear" to "Artist Without Fear",
|
||||
"Maxx" to "Maxx",
|
||||
"AC/DC" to "AC/DC",
|
||||
" Various Artists " to "Various Artists",
|
||||
"" to "",
|
||||
)) {
|
||||
assertEquals(input, expected, NativeFinalizationPolicy.artistTagValue(input, " Primary "))
|
||||
}
|
||||
// Joined and split values are left to the backend writer unchanged.
|
||||
for (mode in listOf("joined", "split_vorbis", "", null)) {
|
||||
assertEquals(
|
||||
"Artist A, Artist B",
|
||||
NativeFinalizationPolicy.artistTagValue("Artist A, Artist B", mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun durationUsesDeclaredUnitsForShortTracksAndLongPerformances() {
|
||||
assertEquals(250L, NativeFinalizationPolicy.durationMilliseconds(250, 0))
|
||||
|
||||
Reference in new issue
Block a user