Merge pull request #3090 from vitorpamplona/claude/exciting-meitner-KeUBf

Add NIP-71 audio-track support with language and bitrate tags
This commit is contained in:
Vitor Pamplona
2026-05-28 17:36:56 -04:00
committed by GitHub
7 changed files with 295 additions and 0 deletions
@@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nip71Video
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag
import com.vitorpamplona.quartz.nip71Video.tags.DurationTag
import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -38,6 +41,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
/**
* Contains the IMeta tags that are used by Video events.
@@ -73,3 +77,19 @@ fun IMetaTagBuilder.summary(summary: HexKey) = add(SummaryTag.TAG_NAME, summary)
fun IMetaTagBuilder.fallback(fallback: HexKey) = add(FallbackTag.TAG_NAME, fallback)
fun IMetaTagBuilder.service(service: HexKey) = add(ServiceTag.TAG_NAME, service)
fun IMetaTagBuilder.bitrate(bitsPerSecond: Int) = add(BitrateTag.TAG_NAME, bitsPerSecond.toString())
fun IMetaTagBuilder.duration(seconds: Double) = add(DurationTag.TAG_NAME, seconds.toString())
fun IMetaTagBuilder.duration(seconds: Int) = add(DurationTag.TAG_NAME, seconds.toString())
fun IMetaTagBuilder.waveform(wave: List<Float>) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWaveFloat(wave))
fun IMetaTagBuilder.language(language: LanguageImetaTag) = add(LanguageImetaTag.TAG_NAME, language.toPropertyValue())
fun IMetaTagBuilder.language(
code: String,
standard: String = LanguageImetaTag.DEFAULT_STANDARD,
originalVersion: Boolean = false,
) = language(LanguageImetaTag(code, standard, originalVersion))
@@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.nip71Video
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag
import com.vitorpamplona.quartz.nip71Video.tags.DurationTag
import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -37,6 +40,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
/**
* Contains the IMeta tags that are used by Video events.
@@ -72,3 +76,18 @@ fun IMetaTag.summary() = properties.get(SummaryTag.TAG_NAME)
fun IMetaTag.fallback() = properties.get(FallbackTag.TAG_NAME)
fun IMetaTag.service() = properties.get(ServiceTag.TAG_NAME)
fun IMetaTag.bitrate(): Int? = properties.get(BitrateTag.TAG_NAME)?.firstOrNull()?.toIntOrNull()
fun IMetaTag.duration(): Double? = properties.get(DurationTag.TAG_NAME)?.firstOrNull()?.toDoubleOrNull()
fun IMetaTag.waveform(): List<Float>? = properties.get(WaveformTag.TAG_NAME)?.firstOrNull()?.let { WaveformTag.parseWave(it) }
fun IMetaTag.language(): LanguageImetaTag? = properties.get(LanguageImetaTag.TAG_NAME)?.firstNotNullOfOrNull { LanguageImetaTag.parseValue(it) }
// NIP-71 PR #2255: audio-only variants are advertised as `imeta` entries
// with an `m audio/...` property so clients can prefer them over in-video
// audio while switching video resolution.
fun IMetaTag.isAudioTrack(): Boolean = mimeType()?.firstOrNull()?.startsWith("audio/") == true
fun IMetaTag.isVideoTrack(): Boolean = mimeType()?.firstOrNull()?.startsWith("video/") == true
@@ -47,4 +47,17 @@ interface VideoEvent : IEvent {
fun hash(): String?
fun imetaTags(): List<VideoMeta>
/**
* NIP-71 PR #2255: imeta entries whose mime type starts with `video/`
* (or entries without a mime type, treated as legacy video variants).
*/
fun videoTracks(): List<VideoMeta> = imetaTags().filter { it.isVideo || it.mimeType == null }
/**
* NIP-71 PR #2255: imeta entries whose mime type starts with `audio/`.
* Clients should prefer these over in-video audio to enable smooth
* resolution switching without interrupting audio.
*/
fun audioTracks(): List<VideoMeta> = imetaTags().filter { it.isAudio }
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip71Video
import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -36,7 +37,18 @@ data class VideoMeta(
val fallback: List<String> = emptyList(),
val image: List<String> = emptyList(),
val thumbhash: String? = null,
// NIP-71 PR #2255 — audio-track imeta properties
val bitrate: Int? = null,
val duration: Double? = null,
val waveform: List<Float>? = null,
val language: LanguageImetaTag? = null,
) {
val isAudio: Boolean
get() = mimeType?.startsWith("audio/") == true
val isVideo: Boolean
get() = mimeType?.startsWith("video/") == true
fun toIMetaArray(): Array<String> =
IMetaTagBuilder(url)
.apply {
@@ -50,6 +62,10 @@ data class VideoMeta(
service?.let { service(it) }
fallback.forEach { fallback(it) }
image.forEach { image(it) }
bitrate?.let { bitrate(it) }
duration?.let { duration(it) }
waveform?.let { waveform(it) }
language?.let { language(it) }
}.build()
.toTagArray()
@@ -67,6 +83,10 @@ data class VideoMeta(
fallback = iMeta.fallback() ?: emptyList(),
image = iMeta.image() ?: emptyList(),
thumbhash = iMeta.thumbhash()?.firstOrNull(),
bitrate = iMeta.bitrate(),
duration = iMeta.duration(),
waveform = iMeta.waveform(),
language = iMeta.language(),
)
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip71Video.tags
class BitrateTag {
companion object {
const val TAG_NAME = "bitrate"
fun assemble(bitsPerSecond: Int) = arrayOf(TAG_NAME, bitsPerSecond.toString())
}
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip71Video.tags
import androidx.compose.runtime.Immutable
/**
* NIP-71 audio-track language tag (lives inside an `imeta` tag).
*
* Format: `l <code> <standard> [ov]`
*
* - `code` — language code, e.g. `en`
* - `standard` — namespace, e.g. `ISO-639-1` (default when missing)
* - `ov` — optional flag marking the original-version track
*/
@Immutable
data class LanguageImetaTag(
val code: String,
val standard: String = DEFAULT_STANDARD,
val originalVersion: Boolean = false,
) {
fun toPropertyValue(): String =
buildString {
append(code)
append(' ')
append(standard)
if (originalVersion) {
append(' ')
append(ORIGINAL_VERSION_MARKER)
}
}
companion object {
const val TAG_NAME = "l"
const val DEFAULT_STANDARD = "ISO-639-1"
const val ORIGINAL_VERSION_MARKER = "ov"
fun parseValue(value: String): LanguageImetaTag? {
val parts = value.split(' ').filter { it.isNotEmpty() }
if (parts.isEmpty()) return null
val code = parts[0]
val standard = parts.getOrNull(1) ?: DEFAULT_STANDARD
val ov = parts.drop(2).any { it == ORIGINAL_VERSION_MARKER }
return LanguageImetaTag(code, standard, ov)
}
}
}
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip71Video
import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class VideoAudioImetaTest {
/**
* NIP-71 PR #2255 spec example for an audio-only imeta variant.
*/
@Test
fun audioImetaRoundTripMatchesSpecExample() {
val waveform =
listOf(
0f,
7f,
35f,
8f,
100f,
100f,
49f,
8f,
4f,
16f,
8f,
10f,
7f,
2f,
20f,
10f,
100f,
100f,
100f,
100f,
)
val original =
VideoMeta(
url = "https://myaudio.com/audio/en/12345.mp3",
mimeType = "audio/mp3",
hash = "b2e0a7a82ac9f3f3a71f1d9a78c381d5be9d1cf19dce258765c17c8a76287c93",
service = "nip96",
fallback =
listOf(
"https://myotherserver.com/audio/en/12345.mp3",
"https://andanotherserver.com/audio/en/12345.mp3",
),
bitrate = 320000,
duration = 29.24,
waveform = waveform,
language = LanguageImetaTag("en", "ISO-639-1", originalVersion = true),
)
val tag = original.toIMetaArray()
val parsed = IMetaTag.parse(tag)?.firstOrNull()
assertNotNull(parsed)
val round = VideoMeta.parse(parsed)
assertEquals(original.url, round.url)
assertEquals("audio/mp3", round.mimeType)
assertEquals(original.hash, round.hash)
assertEquals("nip96", round.service)
assertEquals(original.fallback, round.fallback)
assertEquals(320000, round.bitrate)
assertEquals(29.24, round.duration)
assertEquals(waveform, round.waveform)
assertEquals(LanguageImetaTag("en", "ISO-639-1", originalVersion = true), round.language)
assertTrue(round.isAudio)
}
@Test
fun audioAndVideoTracksAreSplitByMimeType() {
val video =
VideoMeta(
url = "https://example.com/video.mp4",
mimeType = "video/mp4",
)
val audio =
VideoMeta(
url = "https://example.com/audio.mp3",
mimeType = "audio/mp3",
language = LanguageImetaTag("en"),
)
assertTrue(video.isVideo)
assertTrue(audio.isAudio)
}
@Test
fun languageImetaTagParsesOriginalVersionFlag() {
val parsed = LanguageImetaTag.parseValue("en ISO-639-1 ov")
assertNotNull(parsed)
assertEquals("en", parsed.code)
assertEquals("ISO-639-1", parsed.standard)
assertTrue(parsed.originalVersion)
}
@Test
fun languageImetaTagDefaultsStandardWhenMissing() {
val parsed = LanguageImetaTag.parseValue("pt")
assertNotNull(parsed)
assertEquals("pt", parsed.code)
assertEquals("ISO-639-1", parsed.standard)
assertEquals(false, parsed.originalVersion)
}
}