Adds an iMeta cache for Picture and Video kinds and speeds up the procedure to verify if a video or picture is supported.

This commit is contained in:
Vitor Pamplona
2025-07-30 12:47:31 -04:00
parent 2c6a084808
commit fa53ff2c78
8 changed files with 176 additions and 156 deletions

View File

@@ -0,0 +1,46 @@
/**
* 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.amethyst.ui.screen.loggedIn.video.dal
class SupportedContent(
val blockedUrls: List<String>,
val mimeTypes: Set<String>,
val supportedFileExtensions: Set<String>,
) {
private fun validExtension(fullUrl: String): Boolean {
val queryIndex = fullUrl.indexOf('?')
if (queryIndex > 0) {
return supportedFileExtensions.any { fullUrl.startsWith(it, queryIndex - it.length) }
}
val fragmentIndex = fullUrl.indexOf('#')
if (fragmentIndex > 0) {
return supportedFileExtensions.any { fullUrl.startsWith(it, fragmentIndex - it.length) }
}
return supportedFileExtensions.any { fullUrl.endsWith(it) }
}
fun acceptableUrl(
url: String,
mimeType: String?,
) = blockedUrls.none { url.contains(it) } && ((mimeType != null && mimeTypes.contains(mimeType)) || validExtension(url))
}

View File

@@ -43,6 +43,13 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
class VideoFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
val videoFeedSupport =
SupportedContent(
blockedUrls = listOf("youtu.be", "youtube.com"),
mimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES_SET,
supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(),
)
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value
override fun limit() = 300
@@ -76,24 +83,11 @@ class VideoFeedFilter(
fun acceptableUrls(
baseUrls: List<String>,
mimeType: String?,
): Boolean {
// we don't have an youtube player
val urls = baseUrls.filter { !(it.contains("youtu.be") || it.contains("youtube.com")) }
) = baseUrls.any { videoFeedSupport.acceptableUrl(it, mimeType) }
val isSupportedMimeType = mimeType?.let { SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it) } == true
fun acceptableVideoiMetas(iMetas: List<VideoMeta>): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) }
return urls.isNotEmpty() && (urls.any { RichTextParser.Companion.isImageOrVideoUrl(it) } || isSupportedMimeType)
}
fun acceptableVideoiMetas(iMetas: List<VideoMeta>): Boolean =
iMetas.any {
!it.url.contains("youtu.be") && !it.url.contains("youtube.com") && (RichTextParser.isImageOrVideoUrl(it.url) || (it.mimeType == null || SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it.mimeType)))
}
fun acceptablePictureiMetas(iMetas: List<PictureMeta>): Boolean =
iMetas.any {
!it.url.contains("youtu.be") && !it.url.contains("youtube.com") && (RichTextParser.isImageOrVideoUrl(it.url) || (it.mimeType == null || SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it.mimeType)))
}
fun acceptablePictureiMetas(iMetas: List<PictureMeta>): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) }
fun acceptanceEvent(noteEvent: PictureEvent) = acceptablePictureiMetas(noteEvent.imetaTags())
@@ -117,14 +111,8 @@ class VideoFeedFilter(
(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 &&
acceptanceEvent(noteEvent)
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
(noteEvent is PictureEvent && acceptanceEvent(noteEvent))
) &&
params.match(noteEvent) &&
(params.isHiddenList || account.isAcceptable(note))

View File

@@ -0,0 +1,56 @@
/**
* 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.amethyst.ui.screen.loggedIn.video.dal
import junit.framework.TestCase.assertTrue
import org.junit.Test
class SupportedContentTest {
@Test
fun acceptableUrl() {
val supportedExtensions = setOf(".mp4", ".webm", ".jpg", ".png")
val mimeTypes = setOf("video/mp4", "video/webm", "image/jpeg", "image/png")
val blockedUrls = listOf("youtube.com", "youtu.be")
val testVideoUrl = "https://example.com/video.mp4"
val blockedUrl = "https://youtube.com/watch?v=example"
val urlWithQuery = "https://example.com/media.jpg?param=1"
val urlWithFragment = "https://example.com/data.png#section"
val contentSupport = SupportedContent(blockedUrls, mimeTypes, supportedExtensions)
// Valid scenarios
assertTrue(contentSupport.acceptableUrl(testVideoUrl, "video/mp4")) // Valid extension and mime
assertTrue(contentSupport.acceptableUrl(urlWithQuery, "image/jpeg")) // Valid query param extension
assertTrue(contentSupport.acceptableUrl(urlWithFragment, "image/png")) // Valid fragment extension
assertTrue(contentSupport.acceptableUrl(testVideoUrl, null))
assertTrue(contentSupport.acceptableUrl(urlWithQuery, null))
assertTrue(contentSupport.acceptableUrl(urlWithFragment, null))
// Blocked URL scenarios
assertTrue(!contentSupport.acceptableUrl(blockedUrl, null)) // Blocked URL
// Invalid scenarios
assertTrue(!contentSupport.acceptableUrl("https://example.com/file.docx", "application/docx")) // Unsupported extension/mime
assertTrue(!contentSupport.acceptableUrl("https://example.com/file.docx", null)) // Unsupported extension/mime
}
}

View File

@@ -367,11 +367,11 @@ class RichTextParser {
private fun removeQueryParamsForExtensionComparison(fullUrl: String): String =
if (fullUrl.contains("?")) {
fullUrl.split("?")[0].lowercase()
fullUrl.split("?")[0]
} else if (fullUrl.contains("#")) {
fullUrl.split("#")[0].lowercase()
fullUrl.split("#")[0]
} else {
fullUrl.lowercase()
fullUrl
}
fun isImageOrVideoUrl(url: String): Boolean {

View File

@@ -24,25 +24,16 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip68Picture.tags.LocationTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -55,63 +46,21 @@ class PictureEvent(
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
// ---------------
// current
// --------------
@Transient var iMetas: List<PictureMeta>? = null
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
/** old standard didnt use IMetas **/
private fun url() = tags.firstNotNullOfOrNull(UrlTag::parse)
fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse)
private fun urls() = tags.mapNotNull(UrlTag::parse)
fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse)
private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse)
fun hashtags() = tags.hashtags()
private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse)
fun geohashes() = tags.geohashes()
private fun size() = tags.firstNotNullOfOrNull(SizeTag::parse)
fun location() = tags.mapNotNull(LocationTag::parse)
private fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse)
private fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse)
private fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse)
private fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse)
private fun hasUrl() = tags.any(UrlTag::isTag)
private fun isOneOf(mimeTypes: Set<String>) = tags.any(MimeTypeTag::isIn, mimeTypes)
private fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
private fun images() = tags.mapNotNull(ImageTag::parse)
private fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse)
private fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse)
private fun fallbacks() = tags.mapNotNull(FallbackTag::parse)
// hack to fix pablo's bug
fun rootImage() =
url()?.let {
PictureMeta(
url = it,
mimeType = mimeType(),
blurhash = blurhash(),
alt = alt(),
hash = hash(),
dimension = dimensions(),
size = size(),
service = service(),
fallback = fallbacks(),
annotations = emptyList(),
)
}
fun imetaTags() = imetas().map { PictureMeta.parse(it) }.plus(rootImage()).filterNotNull()
fun imetaTags() = iMetas ?: imetas().map { PictureMeta.parse(it) }.also { iMetas = it }
companion object {
const val KIND = 20
@@ -142,7 +91,7 @@ class PictureEvent(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PictureEvent>.() -> Unit = {},
) = eventTemplate(KIND, description, createdAt) {
alt(ClassifiedsEvent.ALT_DESCRIPTION)
alt(ALT_DESCRIPTION)
initializer()
}
}

View File

@@ -0,0 +1,41 @@
/**
* 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.nip68Picture.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class LocationTag {
companion object {
const val TAG_NAME = "location"
@JvmStatic
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@JvmStatic
fun assemble(locationName: String) = arrayOf(TAG_NAME, locationName)
}
}

View File

@@ -23,29 +23,17 @@ package com.vitorpamplona.quartz.nip71Video
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip71Video.tags.DurationTag
import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.FallbackTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MagnetTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag
@Immutable
abstract class VideoEvent(
@@ -58,59 +46,7 @@ abstract class VideoEvent(
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig),
RootScope {
/** old standard didnt use IMetas **/
private fun url() = tags.firstNotNullOfOrNull(UrlTag::parse)
private fun urls() = tags.mapNotNull(UrlTag::parse)
private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse)
private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse)
private fun size() = tags.firstNotNullOfOrNull(SizeTag::parse)
private fun dimensions() = tags.firstNotNullOfOrNull(DimensionTag::parse)
private fun magnetURI() = tags.firstNotNullOfOrNull(MagnetTag::parse)
private fun torrentInfoHash() = tags.firstNotNullOfOrNull(TorrentInfoHash::parse)
private fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse)
private fun hasUrl() = tags.any(UrlTag::isTag)
private fun isOneOf(mimeTypes: Set<String>) = tags.any(MimeTypeTag::isIn, mimeTypes)
private fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
private fun images() = tags.mapNotNull(ImageTag::parse)
private fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse)
private fun service() = tags.firstNotNullOfOrNull(ServiceTag::parse)
private fun fallbacks() = tags.mapNotNull(FallbackTag::parse)
// 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(),
service = service(),
fallback = fallbacks(),
image = images().map { it.imageUrl },
)
}
// ---------------
// current
// --------------
@Transient var iMetas: List<VideoMeta>? = null
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
@@ -126,5 +62,9 @@ abstract class VideoEvent(
fun hashtags() = tags.hashtags()
fun imetaTags() = imetas().map { VideoMeta.parse(it) }.plus(rootVideo()).filterNotNull()
private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse)
private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse)
fun imetaTags() = iMetas ?: imetas().map { VideoMeta.parse(it) }.also { iMetas = it }
}

View File

@@ -48,7 +48,7 @@ class IMetaTag(
ensure(tag[1].isNotEmpty()) { return null }
val allTags = parseIMeta(tag)
val url = allTags.get(ANCHOR_PROPERTY)?.firstOrNull()
val url = allTags[ANCHOR_PROPERTY]?.firstOrNull()
return if (url != null) {
IMetaTag(url, allTags.minus(ANCHOR_PROPERTY))