- Migrates Video events to imeta tags

- Removes youtu.be links from the video feed.
- Checks for video file types in uppercase as well as lowercase
This commit is contained in:
Vitor Pamplona 2024-12-05 16:41:43 -05:00
parent 81591d6f76
commit b47d9ad4d9
9 changed files with 258 additions and 146 deletions

View File

@ -1976,14 +1976,12 @@ class Account(
if (headerInfo.dim.height > headerInfo.dim.width) {
VideoVerticalEvent.create(
url = url,
magnetUri = magnetUri,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
size = headerInfo.size,
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent,
signer = signer,
) { event ->
@ -1992,14 +1990,12 @@ class Account(
} else {
VideoHorizontalEvent.create(
url = url,
magnetUri = magnetUri,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
size = headerInfo.size,
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent,
signer = signer,
) { event ->

View File

@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PictureEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoMeta
import com.vitorpamplona.quartz.events.VideoVerticalEvent
class VideoFeedFilter(
@ -66,6 +67,29 @@ class VideoFeedFilter(
return collection.filterTo(HashSet()) { acceptableEvent(it, params) }
}
fun acceptableUrls(
baseUrls: List<String>,
mimeType: String?,
): Boolean {
// we don't have an youtube player
val urls = baseUrls.filter { !it.contains("youtu.be") }
val isSupportedMimeType = mimeType?.let { SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it) } ?: false
return urls.isNotEmpty() && (urls.any { isImageOrVideoUrl(it) } || isSupportedMimeType)
}
fun acceptableiMetas(iMetas: List<VideoMeta>): Boolean =
iMetas.any {
!it.url.contains("youtu.be") && (isImageOrVideoUrl(it.url) || (it.mimeType == null || SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it.mimeType)))
}
fun acceptanceEvent(noteEvent: FileHeaderEvent) = acceptableUrls(noteEvent.urls(), noteEvent.mimeType())
fun acceptanceEvent(noteEvent: VideoVerticalEvent) = acceptableiMetas(noteEvent.imetaTags())
fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableiMetas(noteEvent.imetaTags())
fun acceptableEvent(
note: Note,
params: FilterByListParams,
@ -77,9 +101,9 @@ class VideoFeedFilter(
}
return (
(noteEvent is FileHeaderEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
(noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
(noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
(noteEvent is FileHeaderEvent && acceptanceEvent(noteEvent)) ||
(noteEvent is VideoVerticalEvent && acceptanceEvent(noteEvent)) ||
(noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) ||
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
noteEvent is PictureEvent
) &&

View File

@ -71,45 +71,41 @@ fun VideoDisplay(
nav: INav,
) {
val event = (note.event as? VideoEvent) ?: return
val fullUrl = event.url() ?: return
val imeta = event.imetaTags().firstOrNull() ?: return
val title = event.title()
val summary = event.content.ifBlank { null }?.takeIf { title != it }
val image = event.thumb() ?: event.image()
val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be")
val image = imeta.image.firstOrNull()
val isYouTube = imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be")
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val content by
remember(note) {
val blurHash = event.blurhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifBlank { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>(
if (isImage) {
MediaUrlImage(
url = fullUrl,
url = imeta.url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
hash = imeta.hash,
blurhash = imeta.blurhash,
dim = imeta.dimension,
uri = uri,
mimeType = mimeType,
mimeType = imeta.mimeType,
)
} else {
MediaUrlVideo(
url = fullUrl,
url = imeta.url,
description = description,
hash = hash,
dim = dimensions,
hash = imeta.hash,
dim = imeta.dimension,
uri = uri,
authorName = note.author?.toBestDisplayName(),
artworkUri = event.thumb() ?: event.image(),
mimeType = mimeType,
artworkUri = imeta.image.firstOrNull(),
mimeType = imeta.mimeType,
)
},
)
@ -126,7 +122,7 @@ fun VideoDisplay(
if (isYouTube) {
val uri = LocalUriHandler.current
Row(
modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } },
modifier = Modifier.clickable { runCatching { uri.openUri(imeta.url) } },
) {
image?.let {
AsyncImage(

View File

@ -42,39 +42,34 @@ fun JustVideoDisplay(
accountViewModel: AccountViewModel,
) {
val event = (note.event as? VideoEvent) ?: return
val fullUrl = event.url() ?: return
val imeta = event.imetaTags().getOrNull(0) ?: return
val content by
remember(note) {
val blurHash = event.blurhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
mutableStateOf<BaseMediaContent>(
if (isImage) {
MediaUrlImage(
url = fullUrl,
url = imeta.url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
hash = imeta.hash,
blurhash = imeta.blurhash,
dim = imeta.dimension,
uri = note.toNostrUri(),
mimeType = imeta.mimeType,
)
} else {
MediaUrlVideo(
url = fullUrl,
url = imeta.url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
hash = imeta.hash,
blurhash = imeta.blurhash,
dim = imeta.dimension,
uri = note.toNostrUri(),
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
mimeType = imeta.mimeType,
)
},
)

View File

@ -67,7 +67,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.regex.Pattern
val EMAIL_PATTERN = Pattern.compile(".+@.+\\.[a-z]+")
val EMAIL_PATTERN: Pattern = Pattern.compile(".+@.+\\.[a-z]+")
@Stable
class AccountStateViewModel : ViewModel() {

View File

@ -66,7 +66,7 @@ class RichTextParser {
} else {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
return if (isImage) {
@ -344,8 +344,11 @@ class RichTextParser {
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
.toRegex(RegexOption.IGNORE_CASE)
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
val base64contentPattern = Pattern.compile("data:image/(${imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})")

View File

@ -22,8 +22,10 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.filter
@Immutable
abstract class VideoEvent(
@ -36,52 +38,73 @@ abstract class VideoEvent(
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig),
RootScope {
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
private fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] }
private fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
private fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
private fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
private fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
private fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
private fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
private fun image() = tags.filter { it.size > 1 && it[0] == IMAGE }.map { it[1] }
private fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1)
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1)
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == SUMMARY }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == IMAGE }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1)
fun duration() = tags.firstOrNull { it.size > 1 && it[0] == DURATION }?.get(1)
fun hasUrl() = tags.any { it.size > 1 && it[0] == URL }
fun isOneOf(mimeTypes: Set<String>) = tags.any { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE && mimeTypes.contains(it[1]) }
// hack to fix pablo's bug
fun rootVideo() =
url()?.let {
VideoMeta(
url = it,
mimeType = mimeType(),
blurhash = blurhash(),
alt = alt(),
hash = hash(),
dimension = dimensions(),
size = size()?.toIntOrNull(),
service = null,
fallback = emptyList(),
image = image(),
)
}
fun imetaTags() =
tags
.map { tagArray ->
if (tagArray.size > 1 && tagArray[0] == IMETA) {
VideoMeta.parse(tagArray)
} else {
null
}
}.plus(rootVideo())
.filterNotNull()
companion object {
private const val URL = "url"
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
private const val ORIGINAL_HASH = "ox"
private const val ALT = "alt"
private const val TITLE = "title"
private const val PUBLISHED_AT = "published_at"
private const val SUMMARY = "summary"
private const val DURATION = "duration"
private const val IMAGE = "image"
@ -91,47 +114,129 @@ abstract class VideoEvent(
kind: Int,
dTag: String,
url: String,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
size: Int? = null,
duration: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
sensitiveContent: Boolean? = null,
service: String? = null,
altDescription: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (T) -> Unit,
) {
val tags =
listOfNotNull(
arrayOf("d", dTag),
arrayOf(URL, url),
magnetUri?.let { arrayOf(MAGNET_URI, it) },
mimeType?.let { arrayOf(MIME_TYPE, it) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription),
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
} else {
null
}
},
val video =
VideoMeta(
url,
mimeType,
blurhash,
dimensions,
alt,
hash,
size,
service,
emptyList(),
emptyList(),
)
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", dTag))
tags.add(arrayOf(ALT, altDescription))
if (sensitiveContent == true) {
tags.add(arrayOf("content-warning", ""))
}
duration?.let { tags.add(arrayOf(DURATION, "duration")) }
tags.add(video.toIMetaArray())
val content = alt ?: ""
signer.sign<T>(createdAt, kind, tags.toTypedArray(), content, onReady)
}
}
}
data class VideoMeta(
val url: String,
val mimeType: String?,
val blurhash: String?,
val dimension: Dimension?,
val alt: String?,
val hash: String?,
val size: Int?,
val service: String?,
val fallback: List<String>,
val image: List<String>,
) {
fun toIMetaArray(): Array<String> =
(
listOfNotNull(
"imeta",
"$URL $url",
mimeType?.let { "$MIME_TYPE $it" },
alt?.let { "$ALT $it" },
hash?.let { "$HASH $it" },
size?.let { "$FILE_SIZE $it" },
dimension?.let { "$DIMENSION $it" },
blurhash?.let { "$BLUR_HASH $it" },
service?.let { "$SERVICE $it" },
) +
fallback.map { "$FALLBACK $it" } +
image.map { "$IMAGE $it" }
).toTypedArray()
companion object {
const val URL = "url"
const val MIME_TYPE = "m"
const val FILE_SIZE = "size"
const val DIMENSION = "dim"
const val HASH = "x"
const val BLUR_HASH = "blurhash"
const val ALT = "alt"
const val FALLBACK = "fallback"
const val IMAGE = "image"
const val SERVICE = "service"
fun parse(tagArray: Array<String>): VideoMeta? {
var url: String? = null
var mimeType: String? = null
var blurhash: String? = null
var dim: Dimension? = null
var alt: String? = null
var hash: String? = null
var size: Int? = null
var service: String? = null
val fallback = mutableListOf<String>()
val images = mutableListOf<String>()
tagArray.forEach {
val parts = it.split(" ", limit = 2)
val key = parts[0]
val value = if (parts.size == 2) parts[1] else ""
if (value.isNotBlank()) {
when (key) {
URL -> url = value
MIME_TYPE -> mimeType = value
BLUR_HASH -> blurhash = value
DIMENSION -> dim = Dimension.parse(value)
ALT -> alt = value
HASH -> hash = value
FILE_SIZE -> size = value.toIntOrNull()
SERVICE -> service = value
FALLBACK -> fallback.add(value)
IMAGE -> images.add(value)
}
}
}
return url?.let {
VideoMeta(it, mimeType, blurhash, dim, alt, hash, size, service, fallback, images)
}
}
}
}

View File

@ -34,48 +34,45 @@ class VideoHorizontalEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
companion object {
const val KIND = 34235
const val ALT_DESCRIPTION = "Horizontal Video"
fun create(
url: String,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
size: Int? = null,
duration: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
sensitiveContent: Boolean? = null,
service: String? = null,
dTag: String = UUID.randomUUID().toString(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (VideoHorizontalEvent) -> Unit,
) {
create(
KIND,
dTag,
url,
magnetUri,
mimeType,
alt,
hash,
size,
dimensions,
blurhash,
originalHash,
magnetURI,
torrentInfoHash,
sensitiveContent,
ALT_DESCRIPTION,
signer,
createdAt,
onReady,
kind = KIND,
dTag = dTag,
url = url,
mimeType = mimeType,
alt = alt,
hash = hash,
size = size,
duration = duration,
dimensions = dimensions,
blurhash = blurhash,
sensitiveContent = sensitiveContent,
service = service,
altDescription = ALT_DESCRIPTION,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}

View File

@ -42,41 +42,37 @@ class VideoVerticalEvent(
fun create(
url: String,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
size: Int? = null,
duration: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
sensitiveContent: Boolean? = null,
service: String? = null,
dTag: String = UUID.randomUUID().toString(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (VideoVerticalEvent) -> Unit,
) {
create(
KIND,
dTag,
url,
magnetUri,
mimeType,
alt,
hash,
size,
dimensions,
blurhash,
originalHash,
magnetURI,
torrentInfoHash,
sensitiveContent,
ALT_DESCRIPTION,
signer,
createdAt,
onReady,
kind = KIND,
dTag = dTag,
url = url,
mimeType = mimeType,
alt = alt,
hash = hash,
size = size,
duration = duration,
dimensions = dimensions,
blurhash = blurhash,
sensitiveContent = sensitiveContent,
service = service,
altDescription = ALT_DESCRIPTION,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}