Skip to content

Commit

Permalink
feat: show loading indicator in queue list, proper favicon loading
Browse files Browse the repository at this point in the history
  • Loading branch information
iyzana committed Jan 7, 2024
1 parent b61df8d commit cc1ad28
Show file tree
Hide file tree
Showing 18 changed files with 341 additions and 108 deletions.
2 changes: 2 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ dependencies {
implementation("ch.qos.logback:logback-classic:1.4.5")
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")

implementation("com.mohamedrejeb.ksoup:ksoup-html:0.1.4")

testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
}
Expand Down
134 changes: 134 additions & 0 deletions backend/src/main/kotlin/de/randomerror/ytsync/Favicon.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package de.randomerror.ytsync

import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlHandler
import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser
import java.net.MalformedURLException
import java.net.URL
import kotlin.time.Duration.Companion.seconds

private val FAVICON_CACHE = mutableMapOf<String, String>()

fun getInitialFavicon(query: String, youtubeId: String?): String? {
val isYoutube = youtubeId != null || !query.matches(Regex("^(ftp|https?)://.*"))
val url = if (isYoutube) {
URL("https://www.youtube.com/")
} else {
try {
URL(query)
} catch (e: MalformedURLException) {
return null
}
}

return if (url.authority in FAVICON_CACHE) {
FAVICON_CACHE[url.authority]
} else {
url.toURI().resolve("/favicon.ico").toString()
}
}
fun getFavicon(query: String, videoUrl: String): String? {
val url = try {
URL(query)
} catch (e: MalformedURLException) {
URL(videoUrl)
}
if (url.authority in FAVICON_CACHE) {
return FAVICON_CACHE[url.authority]
}
val favicon = fetchFavicon(url)
FAVICON_CACHE[url.authority] = favicon
return favicon
}

private fun fetchFavicon(url: URL): String {
try {
val icons = mutableListOf<Pair<String, FaviconSizes>>()
val handler = KsoupHtmlHandler.Builder()
.onOpenTag { name, attributes, _ ->
val href = attributes["href"]
val rel = attributes["rel"]
if (name == "link" && (rel == "icon" || rel == "shortcut icon") && href != null) {
val icon = url.toURI().resolve(href).toString()
val type = attributes["type"]
val sizesSpec = attributes["sizes"]
val sizes = if (type != null && type.startsWith("image/svg") || sizesSpec == "any" && !href.endsWith(".ico")) {
FaviconSizes.Any
} else if (sizesSpec == null || sizesSpec == "any" && href.endsWith(".ico")) {
FaviconSizes.Unknown
} else {
val sizes = sizesSpec.split(' ')
.onEach { println("found size: $it") }
.map { it.split('x', ignoreCase = true)[0] }
.map { it.toInt() }
FaviconSizes.Sizes(sizes)
}
icons.add(icon to sizes)
}
}
.build()
val conn = url.openConnection()
conn.connectTimeout = 5.seconds.inWholeMilliseconds.toInt()
conn.readTimeout = 5.seconds.inWholeMilliseconds.toInt()
val bytes = conn.getInputStream().use { it.readNBytes(1024 * 64) }
val parser = KsoupHtmlParser(handler)
parser.write(String(bytes))
parser.end()
val favicon = icons.maxBy { it.second }.first
FAVICON_CACHE[url.authority] = favicon
return favicon
} catch (e: Exception) {
return url.toURI().resolve("/favicon.ico").toString()
}
}

private const val IDEAL_FAVICON_SIZE = 16

sealed class FaviconSizes : Comparable<FaviconSizes> {
object Unknown : FaviconSizes() {
override fun compareTo(other: FaviconSizes): Int {
return -1
}
}

object Any : FaviconSizes() {
override fun compareTo(other: FaviconSizes): Int {
return 1
}
}

class Sizes(val sizes: List<Int>) : FaviconSizes() {
override fun compareTo(other: FaviconSizes): Int {
if (other !is Sizes) {
return -other.compareTo(this)
}
val best = bestSize()
val otherBest = other.bestSize()
return compareSize(best, otherBest)
}

fun bestSize(): Int {
return sizes.maxWith(this::compareSize)
}

private fun compareSize(sizeA: Int, sizeB: Int): Int {
return if (sizeA == IDEAL_FAVICON_SIZE && sizeB != IDEAL_FAVICON_SIZE) {
1
} else if (sizeA != IDEAL_FAVICON_SIZE && sizeB == IDEAL_FAVICON_SIZE) {
-1
} else if (sizeA < IDEAL_FAVICON_SIZE) {
if (sizeB > IDEAL_FAVICON_SIZE) {
-1
} else {
sizeA.compareTo(sizeB)
}
} else {
if (sizeB < IDEAL_FAVICON_SIZE) {
1
} else {
sizeB.compareTo(sizeA)
}
}
}
}
}

6 changes: 4 additions & 2 deletions backend/src/main/kotlin/de/randomerror/ytsync/Model.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ data class VideoSource(
)

data class QueueItem(
val source: VideoSource,
val source: VideoSource?,
val originalQuery: String,
val title: String?,
val thumbnail: String?,
val id: String = UUID.nameUUIDFromBytes(source.url.toByteArray()).toString(),
val favicon: String?,
val loading: Boolean,
val id: String = UUID.randomUUID().toString(),
)

data class User(
Expand Down
46 changes: 35 additions & 11 deletions backend/src/main/kotlin/de/randomerror/ytsync/Queue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,41 +24,65 @@ fun enqueue(session: Session, query: String): String {
if (youtubeId != null) {
synchronized(room.queue) {
if (room.queue.size == 0) {
// this is the first video, it does not go into the queue, we don't need any video info
val fallbackVideo = getFallbackYoutubeVideo(query, youtubeId)
// this is the first video it does not go into the queue, we don't need any video info
room.queue.add(fallbackVideo)
room.broadcastAll(session, "video ${gson.toJson(fallbackVideo.source)}")
return "queue"
return "queue immediate"
}
}
}
if (room.queue.any { it.originalQuery == query }) {
session.remote.sendStringByFuture("queue err duplicate")
return "queue err duplicate"
}
val loadingQueueItem = getInitialVideoInfo(query, youtubeId)
room.broadcastAll(session, "queue add ${gson.toJson(loadingQueueItem)}")
videoInfoFetcher.execute {
// try to get video info, but if it fails, use the fallback info so that the video at least plays
val video = fetchVideoInfo(query, youtubeId) ?: youtubeId?.let { getFallbackYoutubeVideo(query, it) }
if (video == null) {
val videoInfo = (fetchVideoInfo(query, youtubeId) ?: youtubeId?.let { getFallbackYoutubeVideo(query, it) })
if (videoInfo == null) {
log(session, "queue err not-found")
room.broadcastAll(session, "queue rm ${loadingQueueItem.id}")
session.remote.sendStringByFuture("queue err not-found")
return@execute
}
val queueItem =
videoInfo.copy(id = loadingQueueItem.id, favicon = videoInfo.favicon ?: loadingQueueItem.favicon)
synchronized(room.queue) {
if (room.queue.any { it.source.url == video.source.url || it.originalQuery == query }) {
if (room.queue.any { it.source != null && it.source.url == queueItem.source?.url }) {
session.remote.sendStringByFuture("queue err duplicate")
room.broadcastAll(session, "queue rm ${loadingQueueItem.id}")
return@execute
}
room.queue.add(video)
room.queue.add(queueItem)
if (room.queue.size == 1) {
room.broadcastAll(session, "video ${gson.toJson(video.source)}")
room.broadcastAll(session, "queue rm ${queueItem.id}")
room.broadcastAll(session, "video ${gson.toJson(queueItem.source)}")
return@execute
} else {
room.broadcastAll(session, "queue add ${gson.toJson(video)}")
room.broadcastAll(session, "queue add ${gson.toJson(queueItem)}")
}
}
val favicon = getFavicon(query, queueItem.source!!.url)
println(favicon)
if (favicon != null && favicon != queueItem.favicon) {
synchronized(room.queue) {
val index = room.queue.indexOfFirst { it.id == queueItem.id }
if (index > 0) {
val queueItemWithFavicon = queueItem.copy(favicon = favicon)
room.broadcastAll(session, "queue add ${gson.toJson(queueItemWithFavicon)}")
room.queue[index] = queueItemWithFavicon
}
}
}
}
return "queue"
return "queue fetching"
}

fun dequeue(session: Session, queueId: String): String {
val room = getRoom(session)
// first in queue is currently playing song
// first in queue is currently playing video
if (room.queue.isNotEmpty() && room.queue[0].id == queueId) {
return "queue rm deny"
}
Expand Down Expand Up @@ -87,7 +111,7 @@ fun reorder(session: Session, order: String): String {
fun skip(session: Session): String {
val room = getRoom(session)
synchronized(room.queue) {
// first in queue is currently playing song
// first in queue is currently playing video
if (room.queue.size < 2) {
return "skip deny"
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/main/kotlin/de/randomerror/ytsync/Sync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ fun setEnded(session: Session, videoUrl: String): String {

synchronized(room.queue) {
if (room.queue.isEmpty()) return "end empty"
if (room.queue[0].source.url != videoUrl) return "end old"
if (room.queue[0].source?.url != videoUrl) return "end old"

room.ignoreSkipTill = Instant.now().plusSeconds(IGNORE_DURATION)
playNext(session, room)
Expand Down
40 changes: 32 additions & 8 deletions backend/src/main/kotlin/de/randomerror/ytsync/VideoInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,41 @@ import java.lang.UnsupportedOperationException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread

private const val YT_DLP_TIMEOUT = 5L
private const val YT_DLP_TIMEOUT = 15L

private val logger = KotlinLogging.logger {}

fun getFallbackYoutubeVideo(query: String, match: String): QueueItem {
fun getInitialVideoInfo(query: String, youtubeId: String?): QueueItem {
val favicon = getInitialFavicon(query, youtubeId)
if (youtubeId !== null) {
return QueueItem(
VideoSource(
"https://www.youtube.com/watch?v=$youtubeId",
null
),
query,
null,
"https://i.ytimg.com/vi/$youtubeId/mqdefault.jpg",
favicon,
true
)
}
return QueueItem(null, query, null, null, favicon, true)
}

fun getFallbackYoutubeVideo(query: String, youtubeId: String): QueueItem {
return QueueItem(
VideoSource(
"https://www.youtube.com/watch?v=$match",
"https://www.youtube.com/watch?v=$youtubeId",
null
),
query,
"Unknown video $match",
"https://i.ytimg.com/vi/$match/mqdefault.jpg"
"Unknown video $youtubeId",
"https://i.ytimg.com/vi/$youtubeId/mqdefault.jpg",
getInitialFavicon(query, youtubeId),
false
)
}

Expand All @@ -46,7 +67,7 @@ private fun fetchVideoInfoYouTubeOEmbed(query: String, youtubeId: String): Queue
val video = JsonParser.parseString(videoData).asJsonObject
val title = video.getNullable("title")?.asString
val thumbnail = "https://i.ytimg.com/vi/$youtubeId/mqdefault.jpg"
QueueItem(VideoSource(query, null), query, title, thumbnail)
QueueItem(VideoSource(query, null), query, title, thumbnail, null, false)
} catch (e: JsonParseException) {
logger.warn("failed to parse oembed response for query $query", e)
null
Expand All @@ -63,7 +84,9 @@ private fun fetchVideoInfoYtDlp(youtubeId: String?, query: String): QueueItem? {
val isYoutube = youtubeId != null || !query.matches(Regex("^(ftp|https?)://.*"))
val process = Runtime.getRuntime().exec(buildYtDlpCommand(query, isYoutube))
val result = StringWriter()
process.inputStream.bufferedReader().copyTo(result)
val reader = thread(isDaemon = true) {
process.inputStream.bufferedReader().copyTo(result)
}
if (!process.waitFor(YT_DLP_TIMEOUT, TimeUnit.SECONDS)) {
logger.warn("ytdl timeout")
process.destroy()
Expand All @@ -74,6 +97,7 @@ private fun fetchVideoInfoYtDlp(youtubeId: String?, query: String): QueueItem? {
logger.warn(process.errorStream.bufferedReader().readText())
return null
}
reader.join()
val videoData = result.buffer.toString()
return parseYtDlpOutput(videoData, query, isYoutube)
}
Expand All @@ -93,7 +117,7 @@ private fun parseYtDlpOutput(
val title = video.getNullable("title")?.asString
val thumbnail = video.getNullable("thumbnail")?.asString
val contentType = if (isYoutube) null else getContentType(url)
QueueItem(VideoSource(url, contentType), query, title, thumbnail)
QueueItem(VideoSource(url, contentType), query, title, thumbnail, null, false)
} catch (e: JsonParseException) {
logger.warn("failed to parse ytdlp output for query $query", e)
null
Expand Down
1 change: 1 addition & 0 deletions frontend/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ start: build-vendor

git-submodules:
git submodule init
git submodule update

install-vendor: git-submodules
cd vendor/react-youtube/packages/react-youtube && yarn
Expand Down
8 changes: 4 additions & 4 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
"@testing-library/user-event": "^14.1.1",
"@types/jest": "^29.2.0",
"@types/node": "^18.11.4",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.3",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/sortablejs": "^1.15.0",
"@types/video.js": "^7.3.44",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-scripts": "^5.0.1",
"react-sortablejs": "6.1.4",
"react-youtube": "file:vendor/react-youtube/packages/react-youtube",
Expand Down
19 changes: 3 additions & 16 deletions frontend/src/component/FavIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,17 @@ import { useState } from 'react';
import QueueItem from '../model/QueueItem';
import './FavIcon.css';

const faviconUrl = (url: string, originalQuery: string) => {
let baseUrl;
try {
baseUrl = new URL(originalQuery);
} catch (e) {
baseUrl = new URL(url);
}
baseUrl.search = '';
baseUrl.pathname = 'favicon.ico';
return baseUrl;
};

interface FavIconProps {
item: QueueItem;
}

function FavIcon({ item }: FavIconProps) {
const [error, setError] = useState(false);
const favicon = faviconUrl(item.source.url, item.originalQuery);
return error ? null : (
return error || !item.favicon ? null : (
<img
className="favicon"
src={favicon.toString()}
alt={`favicon of ${favicon.host}`}
src={item.favicon}
alt={`favicon of ${new URL(item.favicon).host}`}
onError={() => setError(true)}
></img>
);
Expand Down
Loading

0 comments on commit cc1ad28

Please sign in to comment.