Skip to content

Commit 484ecad

Browse files
committed
feat: add RSS video thumbnails
1 parent d797660 commit 484ecad

4 files changed

Lines changed: 78 additions & 2 deletions

File tree

openapi/paths/rss.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ RssFeedDocument:
108108
- { name: token, in: query, required: true, schema: { type: string } }
109109
responses:
110110
'200':
111-
description: RSS document
111+
description: RSS 2.0 document. Video items include a media:thumbnail element when a HTTP(S) thumbnail URL is available.
112112
headers:
113113
ETag: { schema: { type: string } }
114114
Last-Modified: { schema: { type: string } }

src/main/kotlin/dev/typetype/server/services/RssDocumentRenderer.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dev.typetype.server.services
33
import dev.typetype.server.models.RssFeedItem
44
import dev.typetype.server.models.VideoItem
55
import java.io.ByteArrayOutputStream
6+
import java.net.URI
67
import java.net.URLEncoder
78
import java.nio.charset.StandardCharsets
89
import java.time.Instant
@@ -23,6 +24,7 @@ internal object RssDocumentRenderer {
2324
writer.writeStartDocument(StandardCharsets.UTF_8.name(), "1.0")
2425
writer.writeStartElement("rss")
2526
writer.writeAttribute("version", "2.0")
27+
writer.writeNamespace("media", MEDIA_NAMESPACE)
2628
writer.writeStartElement("channel")
2729
writer.element("title", feed.name)
2830
writer.element("link", publicBaseUrl)
@@ -52,6 +54,11 @@ internal object RssDocumentRenderer {
5254
writeEndElement()
5355
element("author", video.uploaderName)
5456
video.shortDescription?.takeIf(String::isNotBlank)?.let { element("description", it) }
57+
video.thumbnailUrl.httpUrlOrNull()?.let { thumbnailUrl ->
58+
writeStartElement("media", "thumbnail", MEDIA_NAMESPACE)
59+
writeAttribute("url", thumbnailUrl)
60+
writeEndElement()
61+
}
5562
RssVideoMetadata.publishedAtMillis(video).takeIf { it > 0 }
5663
?.let { element("pubDate", RFC_1123.format(Instant.ofEpochMilli(it))) }
5764
writeEndElement()
@@ -63,5 +70,14 @@ internal object RssDocumentRenderer {
6370
writeEndElement()
6471
}
6572

73+
private fun String.httpUrlOrNull(): String? = runCatching {
74+
URI(this).takeIf { uri ->
75+
uri.isAbsolute && uri.host != null &&
76+
(uri.scheme.equals("http", ignoreCase = true) ||
77+
uri.scheme.equals("https", ignoreCase = true))
78+
}?.toString()
79+
}.getOrNull()
80+
81+
private const val MEDIA_NAMESPACE = "http://search.yahoo.com/mrss/"
6682
private val RFC_1123 = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC)
6783
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package dev.typetype.server
2+
3+
import dev.typetype.server.models.RssFeedItem
4+
import dev.typetype.server.services.RssDocumentRenderer
5+
import org.junit.jupiter.api.Assertions.assertFalse
6+
import org.junit.jupiter.api.Assertions.assertTrue
7+
import org.junit.jupiter.api.Test
8+
9+
class RssDocumentRendererTest {
10+
@Test
11+
fun `renders a media thumbnail for HTTP(S) video thumbnails`() {
12+
val video = testVideoItem().copy(thumbnailUrl = "https://img.example/thumb.jpg")
13+
14+
val xml = RssDocumentRenderer.render(feed(), listOf(video), "https://video.example", 1_000L)
15+
.toString(Charsets.UTF_8)
16+
17+
assertTrue(xml.contains("xmlns:media=\"http://search.yahoo.com/mrss/\""))
18+
assertTrue(xml.contains("<media:thumbnail url=\"https://img.example/thumb.jpg\""))
19+
}
20+
21+
@Test
22+
fun `omits a media thumbnail when the video has no HTTP(S) thumbnail`() {
23+
val xml = RssDocumentRenderer.render(feed(), listOf(testVideoItem()), "https://video.example", 1_000L)
24+
.toString(Charsets.UTF_8)
25+
26+
assertFalse(xml.contains("media:thumbnail"))
27+
}
28+
29+
@Test
30+
fun `omits a media thumbnail for unsupported or relative URLs`() {
31+
val video = testVideoItem().copy(thumbnailUrl = "ftp://img.example/thumb.jpg")
32+
33+
val xml = RssDocumentRenderer.render(feed(), listOf(video), "https://video.example", 1_000L)
34+
.toString(Charsets.UTF_8)
35+
36+
assertFalse(xml.contains("media:thumbnail"))
37+
38+
val relativeScheme = testVideoItem().copy(thumbnailUrl = "https:/img.example/thumb.jpg")
39+
val relativeXml = RssDocumentRenderer.render(feed(), listOf(relativeScheme), "https://video.example", 1_000L)
40+
.toString(Charsets.UTF_8)
41+
42+
assertFalse(relativeXml.contains("media:thumbnail"))
43+
}
44+
45+
private fun feed() = RssFeedItem(
46+
id = "feed-id",
47+
name = "Feed",
48+
scope = "all",
49+
channelUrls = emptyList(),
50+
serviceIds = listOf(0, 5, 6),
51+
includeVideos = true,
52+
includeShorts = true,
53+
includeLive = true,
54+
includeUpcoming = true,
55+
enabled = true,
56+
createdAt = 1L,
57+
updatedAt = 1L,
58+
lastUsedAt = null,
59+
)
60+
}

src/test/kotlin/dev/typetype/server/RssFeedReaderRoutesTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class RssFeedReaderRoutesTest {
7070
assertEquals(HttpStatusCode.OK, first.status)
7171
assertEquals("application/rss+xml; charset=utf-8", first.headers[HttpHeaders.ContentType])
7272
assertEquals("private, max-age=300, must-revalidate", first.headers[HttpHeaders.CacheControl])
73-
assertTrue(first.bodyAsText().contains("<rss version=\"2.0\">"))
73+
assertTrue(first.bodyAsText().contains("<rss version=\"2.0\""))
7474
assertTrue(first.bodyAsText().contains("https://video.example/watch?v="))
7575
val etag = first.headers[HttpHeaders.ETag]!!
7676
val lastModified = first.headers[HttpHeaders.LastModified]!!

0 commit comments

Comments
 (0)