Skip to content

Commit

Permalink
feat!: use yt-dlp, add video.js player, use ids and urls in queue
Browse files Browse the repository at this point in the history
  • Loading branch information
iyzana committed Aug 12, 2022
1 parent 8516ce7 commit 42ec7bf
Show file tree
Hide file tree
Showing 14 changed files with 527 additions and 101 deletions.
4 changes: 2 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ FROM openjdk:14-alpine
RUN apk update
RUN apk add python3
RUN ln -s /usr/bin/python3 /usr/bin/python
RUN wget https://yt-dl.org/downloads/latest/youtube-dl -O /usr/local/bin/youtube-dl
RUN chmod a+rx /usr/local/bin/youtube-dl
RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp
RUN chmod a+rx /usr/local/bin/yt-dlp
COPY --from=build /app/build/libs/yt-sync-all.jar ./yt-sync-all.jar
EXPOSE 4567
ENTRYPOINT ["java", "-jar", "yt-sync-all.jar"]
8 changes: 4 additions & 4 deletions backend/src/main/kotlin/de/randomerror/ytsync/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ fun main() {
}

private fun updateYoutubeDl() {
logger.info { "checking for youtube-dl updates" }
logger.info { "checking for yt-dlp updates" }
val process = Runtime.getRuntime().exec(
arrayOf("youtube-dl", "--update")
arrayOf("yt-dlp", "--update")
)
process.waitFor()
logger.info {
process.inputStream.bufferedReader().readLines().joinToString(separator = "\n") { "youtube-dl: $it" }
process.inputStream.bufferedReader().readLines().joinToString(separator = "\n") { "yt-dlp: $it" }
}
logger.warn {
process.errorStream.bufferedReader().readLines().joinToString(separator = "\n") { "youtube-dl: $it" }
process.errorStream.bufferedReader().readLines().joinToString(separator = "\n") { "yt-dlp: $it" }
}
}

Expand Down
101 changes: 54 additions & 47 deletions backend/src/main/kotlin/de/randomerror/ytsync/Queue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,77 +8,71 @@ import java.time.Instant
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.text.RegexOption.IGNORE_CASE

val youtubeUrlRegex: Regex = Regex("""https://(?:www\.)?youtu(?:\.be|be\.com)/watch\?v=([^&]+)(?:.*)?""")
val youtubeUrlRegex: Regex =
Regex("""https://(?:www\.)?youtu(?:\.be|be\.com)/(?:watch\?v=|embed/)([^?&]+)(?:.*)?""", IGNORE_CASE)
val videoInfoFetcher: ExecutorService = Executors.newCachedThreadPool()

private val logger = KotlinLogging.logger {}

private data class VideoInfo(
val id: String,
val title: String,
val thumbnail: String?,
val extractor: String
)

fun enqueue(session: Session, query: String): String {
val room = getRoom(session)
val fallbackInfo = tryExtractVideoId(query)
if (fallbackInfo != null) {

val youtubeIdMatch = youtubeUrlRegex.find(query)?.let { it.groups[1]!!.value }
if (youtubeIdMatch != null) {
synchronized(room.queue) {
if (room.queue.size == 0) {
val fallbackVideo = getFallbackYoutubeVideo(query, youtubeIdMatch)
// this is the first video it does not go into the queue, we don't need any video info
val queueItem = QueueItem(fallbackInfo.id, fallbackInfo.title, fallbackInfo.thumbnail)
room.queue.add(queueItem)
room.broadcastAll(session, "video ${fallbackInfo.id}")
room.queue.add(fallbackVideo)
room.broadcastAll(session, "video ${fallbackVideo.url}")
return "queue"
}
}
}
videoInfoFetcher.execute {
val fromYoutube = youtubeIdMatch != null || !query.matches(Regex("^(ftp|https?)://.*"))
// try to get video info, but if it fails, use the fallback info so that the video at least plays
val video = fetchVideoInfo(query) ?: fallbackInfo
if (video == null || video.extractor != "youtube") {
val video = fetchVideoInfo(query, fromYoutube) ?: youtubeIdMatch?.let { getFallbackYoutubeVideo(query, it) }
if (video == null) {
log(session, "queue err not-found")
session.remote.sendStringByFuture("queue err not-found")
return@execute
}
val queueItem = QueueItem(video.id, video.title, video.thumbnail)
synchronized(room.queue) {
if (room.queue.any { it.id == video.id }) {
if (room.queue.any { it.url == video.url || it.originalQuery == query }) {
session.remote.sendStringByFuture("queue err duplicate")
return@execute
}
room.queue.add(queueItem)
room.queue.add(video)
if (room.queue.size == 1) {
room.broadcastAll(session, "video ${video.id}")
room.broadcastAll(session, "video ${video.url}")
} else {
room.broadcastAll(session, "queue add ${gson.toJson(queueItem)}")
room.broadcastAll(session, "queue add ${gson.toJson(video)}")
}
}
}
return "queue"
}

private fun tryExtractVideoId(query: String): VideoInfo? {
val match = youtubeUrlRegex.find(query) ?: return null
val id = match.groups[1]!!.value
return VideoInfo(
id,
"unknown video $id",
"https://i.ytimg.com/vi/$id/maxresdefault.jpg",
"youtube"
private fun getFallbackYoutubeVideo(query: String, match: String): QueueItem {
return QueueItem(
"https://www.youtube.com/watch?v=$match",
query,
"Unknown video $match",
"https://i.ytimg.com/vi/$match/maxresdefault.jpg"
)
}

fun dequeue(session: Session, videoId: String): String {
fun dequeue(session: Session, queueId: String): String {
val room = getRoom(session)
// first in queue is currently playing song
if (room.queue.isNotEmpty() && room.queue[0].id == videoId) {
if (room.queue.isNotEmpty() && room.queue[0].url == queueId) {
return "queue rm deny"
}
room.queue.removeAll { it.id == videoId }
room.broadcastAll(session, "queue rm $videoId")
room.queue.removeAll { it.id == queueId }
room.broadcastAll(session, "queue rm $queueId")
return "queue rm"
}

Expand All @@ -99,17 +93,8 @@ fun reorder(session: Session, order: String): String {
return "queue order ok"
}

private fun fetchVideoInfo(query: String): VideoInfo? {
val process = Runtime.getRuntime().exec(
arrayOf(
"youtube-dl",
"--default-search", "ytsearch",
"--no-playlist",
"--dump-json",
"--",
query
)
)
private fun fetchVideoInfo(query: String, fromYoutube: Boolean): QueueItem? {
val process = Runtime.getRuntime().exec(buildYtDlpCommand(fromYoutube, query))
val result = StringWriter()
process.inputStream.bufferedReader().copyTo(result)
if (!process.waitFor(5, TimeUnit.SECONDS)) {
Expand All @@ -125,16 +110,38 @@ private fun fetchVideoInfo(query: String): VideoInfo? {
val videoData = result.buffer.toString()
val video = JsonParser.parseString(videoData).asJsonObject
return try {
val id = video.get("id").asString
val urlElement = if (fromYoutube) {
video["webpage_url"]
} else {
video["manifest_url"] ?: video["url"] ?: return null
}
val title = video["title"].asString
val thumbnail = video["thumbnail"].asString
val extractor = video["extractor"].asString
VideoInfo(id, title, thumbnail, extractor)
val thumbnail = video["thumbnail"]?.asString
QueueItem(urlElement.asString, query, title, thumbnail)
} catch (e: Exception) {
null
}
}

private fun buildYtDlpCommand(fromYoutube: Boolean, query: String): Array<String> {
val command = mutableListOf(
"yt-dlp",
"--default-search", "ytsearch",
"--no-playlist",
"--dump-json",
)
if (!fromYoutube) {
// only allow pre-merged formats except from youtube
// m3u8 can be problematic if the hoster does not set a access-control-allow-origin header
command.add("-f")
command.add("b")
}
command.add("--")
command.add(query)
println("command = $command")
return command.toTypedArray()
}

fun skip(session: Session): String {
val room = getRoom(session)
synchronized(room.queue) {
Expand Down
18 changes: 10 additions & 8 deletions backend/src/main/kotlin/de/randomerror/ytsync/Room.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ package de.randomerror.ytsync
import org.eclipse.jetty.websocket.api.Session
import java.time.Instant
import java.util.*
import kotlin.collections.HashMap
import kotlin.concurrent.thread

val sessions: MutableMap<Session, RoomId> = HashMap()
val rooms: MutableMap<RoomId, Room> = HashMap()
private val random = Random()

inline class RoomId(val roomId: String)
@JvmInline
value class RoomId(val roomId: String)

data class Room(
val participants: MutableList<User>,
Expand All @@ -22,9 +22,11 @@ data class Room(
)

data class QueueItem(
val id: String,
var title: String,
var thumbnail: String?
val url: String,
val originalQuery: String,
val title: String,
val thumbnail: String?,
val id: String = UUID.nameUUIDFromBytes(url.toByteArray()).toString(),
)

data class User(
Expand Down Expand Up @@ -85,9 +87,9 @@ fun joinRoom(roomId: RoomId, session: Session): String {
}
room.broadcastAll(session, "users ${room.participants.size}")
if (room.queue.isNotEmpty()) {
val playingId = room.queue[0].id
log(session, "video $playingId")
session.remote.sendStringByFuture("video $playingId")
val playingUrl = room.queue[0].url
log(session, "video $playingUrl")
session.remote.sendStringByFuture("video $playingUrl")
for (item in room.queue.drop(1)) {
val videoJson = gson.toJson(item)
log(session, "queue add $videoJson")
Expand Down
10 changes: 5 additions & 5 deletions backend/src/main/kotlin/de/randomerror/ytsync/Sync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import org.eclipse.jetty.websocket.api.Session
import java.time.Instant
import kotlin.math.abs

inline class TimeStamp(val second: Double)
@JvmInline
value class TimeStamp(val second: Double)

fun String.asTimeStamp(): TimeStamp {
return TimeStamp(toDouble())
Expand Down Expand Up @@ -168,9 +169,8 @@ fun sync(session: Session): String {
}
}

fun setEnded(session: Session, videoId: String): String {
fun setEnded(session: Session, queueId: String): String {
val room = getRoom(session)
val user = room.getUser(session)

synchronized(room.queue) {
val ignoreEndTill = room.ignoreEndTill
Expand All @@ -179,7 +179,7 @@ fun setEnded(session: Session, videoId: String): String {
}

if (room.queue.isEmpty()) return "end empty"
if (room.queue[0].id != videoId) return "end old"
if (room.queue[0].url != queueId) return "end old"

room.ignoreEndTill = Instant.now().plusSeconds(2)
playNext(session, room)
Expand All @@ -194,7 +194,7 @@ fun playNext(session: Session, room: Room) {
if (room.queue.isNotEmpty()) {
val next = room.queue[0]
room.broadcastAll(session, "queue rm ${next.id}")
room.broadcastAll(session, "video ${next.id}")
room.broadcastAll(session, "video ${next.url}")
}
}

Expand Down
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
"@types/node": "^17.0.5",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.3",
"@types/video.js": "^7.3.44",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-scripts": "^5.0.1",
"react-sortablejs": "6.1.1",
"react-youtube": "^9.0.1",
"sortablejs": "^1.12.0",
"typescript": "^4.6.4"
"typescript": "^4.6.4",
"video.js": "^7.20.1"
},
"scripts": {
"start": "react-scripts start",
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ function App() {
const queueItem: QueueItem = JSON.parse(msgParts.slice(2).join(' '));
setQueue((queue) => [...queue, queueItem]);
} else if (msg.startsWith('queue rm')) {
const videoId = msg.split(' ')[2];
setQueue((queue) => queue.filter((video) => video.id !== videoId));
const id = msg.split(' ')[2];
setQueue((queue) => queue.filter((video) => video.id !== id));
} else if (msg.startsWith('queue order')) {
const order = msg.split(' ')[2].split(',');
setQueue((queue) => {
Expand Down Expand Up @@ -120,13 +120,14 @@ function App() {
},
[queue, setQueue],
);
const sendMessage = useCallback((message: string) => ws.send(message), []);
return (
<div className="container">
<main className="with-sidebar">
<div>
<section className="video">
<div className="embed">
<Player msg={msg} sendMessage={(message) => ws.send(message)} />
<Player msg={msg} sendMessage={sendMessage} />
</div>
</section>
<section className="aside">
Expand Down
47 changes: 39 additions & 8 deletions frontend/src/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { faPause, faSyncAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useEffect, useState } from 'react';
import YoutubePlayer from './YoutubePlayer';
import VideoJsPlayer from './VideoJsPlayer';

function getOverlay(overlay: 'PAUSED' | 'SYNCING' | null) {
switch (overlay) {
Expand Down Expand Up @@ -30,9 +31,22 @@ interface PlayerProps {
sendMessage: (message: string) => void;
}

export interface EmbeddedPlayerProps {
msg: string | null;
videoUrl: string;
sendMessage: (msg: string) => void;
setOverlay: (state: 'PAUSED' | 'SYNCING' | null) => void;
volume: number | null;
setVolume: (volume: number) => void;
initialized: boolean;
setInitialized: (initialized: boolean) => void;
}

function Player({ msg, sendMessage }: PlayerProps) {
const [overlay, setOverlay] = useState<'PAUSED' | 'SYNCING' | null>(null);
const [videoUrl, setVideoUrl] = useState<string>('');
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [volume, setVolume] = useState<number | null>(null);
const [initialized, setInitialized] = useState<boolean>(false);

useEffect(() => {
if (!msg) {
Expand All @@ -50,16 +64,33 @@ function Player({ msg, sendMessage }: PlayerProps) {

return (
<div className="aspect-ratio">
{videoUrl === null || videoUrl === '' || videoUrl === undefined ? (
{videoUrl === null ? (
<div className="aspect-ratio-inner empty-player">NO VIDEO</div>
) : (
<div className="aspect-ratio-inner">
<YoutubePlayer
msg={msg}
videoUrl={videoUrl}
sendMessage={sendMessage}
setOverlay={setOverlay}
/>
{videoUrl.startsWith('https://www.youtube.com/') ? (
<YoutubePlayer
msg={msg}
videoUrl={videoUrl}
sendMessage={sendMessage}
setOverlay={setOverlay}
volume={volume}
setVolume={setVolume}
initialized={initialized}
setInitialized={setInitialized}
/>
) : (
<VideoJsPlayer
msg={msg}
videoUrl={videoUrl}
sendMessage={sendMessage}
setOverlay={setOverlay}
volume={volume}
setVolume={setVolume}
initialized={initialized}
setInitialized={setInitialized}
/>
)}
{getOverlay(overlay)}
</div>
)}
Expand Down
Loading

0 comments on commit 42ec7bf

Please sign in to comment.