aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Types.hx24
-rw-r--r--src/client/Buttons.hx34
-rw-r--r--src/client/ClientSettings.hx3
-rw-r--r--src/client/IPlayer.hx2
-rw-r--r--src/client/JsApi.hx5
-rw-r--r--src/client/Main.hx74
-rw-r--r--src/client/Player.hx43
-rw-r--r--src/client/players/Iframe.hx7
-rw-r--r--src/client/players/Raw.hx5
-rw-r--r--src/client/players/Youtube.hx55
-rw-r--r--src/server/Cache.hx165
-rw-r--r--src/server/Main.hx55
-rw-r--r--src/server/ServerState.hx3
-rw-r--r--src/server/YoutubeFallback.hx142
-rw-r--r--src/utils/YoutubeUtils.hx6
15 files changed, 352 insertions, 271 deletions
diff --git a/src/Types.hx b/src/Types.hx
index 8567f7b..0f56392 100644
--- a/src/Types.hx
+++ b/src/Types.hx
@@ -1,7 +1,12 @@
package;
import Client.ClientData;
-import utils.YoutubeUtils.YouTubeVideoInfo;
+
+enum abstract PlayerType(String) {
+ var RawType;
+ var YoutubeType;
+ var IframeType;
+}
typedef VideoDataRequest = {
final url:String;
@@ -14,7 +19,7 @@ typedef VideoData = {
var ?url:String;
var ?subs:String;
var ?voiceOverTrack:String;
- var ?isIframe:Bool;
+ var ?playerType:PlayerType;
}
typedef Config = {
@@ -32,6 +37,7 @@ typedef Config = {
templateUrl:String,
youtubeApiKey:String,
youtubePlaylistLimit:Int,
+ cacheStorageLimitGiB:Float,
permissions:Permissions,
emotes:Array<Emote>,
filters:Array<Filter>,
@@ -109,7 +115,8 @@ typedef VideoItem = {
var ?subs:String;
var ?voiceOverTrack:String;
var isTemp:Bool;
- var isIframe:Bool;
+ var doCache:Bool;
+ var playerType:PlayerType;
}
private class VideoItemTools {
@@ -122,7 +129,8 @@ private class VideoItemTools {
subs: item.subs,
voiceOverTrack: item.voiceOverTrack,
isTemp: item.isTemp,
- isIframe: item.isIframe
+ doCache: item.doCache,
+ playerType: item.playerType
};
}
}
@@ -145,7 +153,8 @@ typedef WsEvent = {
videoList:Array<VideoItem>,
isPlaylistOpen:Bool,
itemPos:Int,
- globalIp:String
+ globalIp:String,
+ playersCacheSupport:Array<PlayerType>,
},
?login:{
clientName:String,
@@ -226,10 +235,6 @@ typedef WsEvent = {
?dump:{
data:String
},
- ?getYoutubeVideoInfo:{
- url:String,
- ?response:YouTubeVideoInfo
- }
}
enum abstract WsEventType(String) {
@@ -267,5 +272,4 @@ enum abstract WsEventType(String) {
var UpdatePlaylist;
var TogglePlaylistLock;
var Dump;
- var GetYoutubeVideoInfo;
}
diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx
index c3581e2..f5f26e8 100644
--- a/src/client/Buttons.hx
+++ b/src/client/Buttons.hx
@@ -22,6 +22,12 @@ class Buttons {
setSplitSize(settings.chatSize);
initChatInput(main);
+ for (item in settings.checkboxes) {
+ if (item.checked == null) continue;
+ final checkbox:InputElement = ge('#${item.id}') ?? continue;
+ checkbox.checked = item.checked;
+ }
+
final passIcon = ge("#guestpass_icon");
passIcon.onclick = e -> {
final icon = passIcon.firstElementChild;
@@ -213,12 +219,15 @@ class Buttons {
final mediaUrl:InputElement = cast ge("#mediaurl");
mediaUrl.oninput = () -> {
- final value = mediaUrl.value;
- final isRawSingleVideo = value != "" && main.isRawPlayerLink(value)
- && main.isSingleVideoLink(value);
- ge("#mediatitleblock").style.display = isRawSingleVideo ? "" : "none";
- ge("#subsurlblock").style.display = isRawSingleVideo ? "" : "none";
- ge("#voiceoverblock").style.display = value.length > 0 ? "" : "none";
+ final url = mediaUrl.value;
+ final playerType = main.getLinkPlayerType(url);
+ final isSingle = main.isSingleVideoUrl(url);
+ final isSingleRawVideo = url != "" && playerType == RawType && isSingle;
+ ge("#mediatitleblock").style.display = isSingleRawVideo ? "" : "none";
+ ge("#subsurlblock").style.display = isSingleRawVideo ? "" : "none";
+ ge("#voiceoverblock").style.display = (url.length > 0 && isSingle) ? "" : "none";
+ final showCache = isSingle && main.playersCacheSupport.contains(playerType);
+ ge("#cache-on-server").parentElement.style.display = showCache ? "" : "none";
final panel = ge("#addfromurl");
final oldH = panel.style.height; // save for animation
panel.style.height = ""; // to calculate height from content
@@ -482,6 +491,19 @@ class Buttons {
if (Utils.isTouch()) chatline.blur();
return true;
});
+ final checkboxes:Array<InputElement> = [
+ ge("#add-temp"),
+ ge("#cache-on-server"),
+ ];
+ for (checkbox in checkboxes) {
+ checkbox.addEventListener("change", () -> {
+ final checked = checkbox.checked;
+ final item = settings.checkboxes.find(item -> item.id == checkbox.id);
+ settings.checkboxes.remove(item);
+ settings.checkboxes.push({id: checkbox.id, checked: checked});
+ Settings.write(settings);
+ });
+ }
}
static inline function getVisualViewport():Null<VisualViewport> {
diff --git a/src/client/ClientSettings.hx b/src/client/ClientSettings.hx
index a004213..cb5f99f 100644
--- a/src/client/ClientSettings.hx
+++ b/src/client/ClientSettings.hx
@@ -14,5 +14,6 @@ typedef ClientSettings = {
latestLinks:Array<String>,
latestSubs:Array<String>,
hotkeysEnabled:Bool,
- showHintList:Bool
+ showHintList:Bool,
+ checkboxes:Array<{id:String, checked:Null<Bool>}>,
}
diff --git a/src/client/IPlayer.hx b/src/client/IPlayer.hx
index 032ac97..e8835d1 100644
--- a/src/client/IPlayer.hx
+++ b/src/client/IPlayer.hx
@@ -1,10 +1,12 @@
package client;
+import Types.PlayerType;
import Types.VideoData;
import Types.VideoDataRequest;
import Types.VideoItem;
interface IPlayer {
+ function getPlayerType():PlayerType;
function isSupportedLink(url:String):Bool;
function getVideoData(data:VideoDataRequest, callback:(data:VideoData) -> Void):Void;
function loadVideo(item:VideoItem):Void;
diff --git a/src/client/JsApi.hx b/src/client/JsApi.hx
index 1532231..abf9a6a 100644
--- a/src/client/JsApi.hx
+++ b/src/client/JsApi.hx
@@ -72,8 +72,9 @@ class JsApi {
}
@:expose
- static function addVideoItem(url:String, atEnd:Bool, isTemp:Bool, ?callback:() -> Void):Void {
- main.addVideo(url, atEnd, isTemp, callback);
+ static function addVideoItem(url:String, atEnd:Bool, isTemp:Bool, ?callback:() ->
+ Void, doCache = false):Void {
+ main.addVideo(url, atEnd, isTemp, doCache, callback);
}
@:expose
diff --git a/src/client/Main.hx b/src/client/Main.hx
index 248e205..72dbf8e 100644
--- a/src/client/Main.hx
+++ b/src/client/Main.hx
@@ -3,6 +3,7 @@ package client;
import Client.ClientData;
import Types.Config;
import Types.Permission;
+import Types.PlayerType;
import Types.VideoData;
import Types.VideoDataRequest;
import Types.WsEvent;
@@ -25,15 +26,16 @@ import js.html.WebSocket;
using ClientTools;
class Main {
- static inline var SETTINGS_VERSION = 4;
+ static inline var SETTINGS_VERSION = 5;
public final settings:ClientSettings;
public var isSyncActive = true;
public var forceSyncNextTick = false;
- public var isVideoEnabled = true;
+ public var isVideoEnabled(default, null) = true;
public final host:String;
public var globalIp(default, null) = "";
- public var isPlaylistOpen = true;
+ public var isPlaylistOpen(default, null) = true;
+ public var playersCacheSupport(default, null):Array<PlayerType> = [];
final clients:Array<Client> = [];
var pageTitle = document.title;
@@ -74,7 +76,8 @@ class Main {
latestLinks: [],
latestSubs: [],
hotkeysEnabled: true,
- showHintList: true
+ showHintList: true,
+ checkboxes: [],
}
Settings.init(defaults, settingsPatcher);
settings = Settings.read();
@@ -104,7 +107,7 @@ class Main {
if (!player.isVideoLoaded()) return;
gotFirstPageInteraction = true;
player.unmute();
- player.play();
+ if (!hasLeader()) player.play();
document.removeEventListener("click", onFirstInteraction);
}
@@ -119,6 +122,9 @@ class Main {
case 3:
final data:ClientSettings = data;
data.showHintList = true;
+ case 4:
+ final data:ClientSettings = data;
+ data.checkboxes = [];
case SETTINGS_VERSION, _:
throw 'skipped version $version';
}
@@ -236,19 +242,19 @@ class Main {
return personal.hasPermission(permission, config.permissions);
}
- final mask = ~/\${([0-9]+)-([0-9]+)}/g;
+ public final urlMask = ~/\${([0-9]+)-([0-9]+)}/g;
function handleUrlMasks(links:Array<String>):Void {
for (link in links) {
- if (!mask.match(link)) continue;
- final start = Std.parseInt(mask.matched(1));
- var end = Std.parseInt(mask.matched(2));
+ if (!urlMask.match(link)) continue;
+ final start = Std.parseInt(urlMask.matched(1));
+ var end = Std.parseInt(urlMask.matched(2));
if (Math.abs(start - end) > 100) continue;
final step = end > start ? -1 : 1;
final i = links.indexOf(link);
links.remove(link);
while (end != start + step) {
- links.insert(i, mask.replace(link, '$end'));
+ links.insert(i, urlMask.replace(link, '$end'));
end += step;
}
}
@@ -257,8 +263,11 @@ class Main {
function addVideoUrl(atEnd:Bool):Void {
final mediaUrl:InputElement = cast ge("#mediaurl");
final subsUrl:InputElement = cast ge("#subsurl");
- final checkbox:InputElement = cast ge("#addfromurl").querySelector(".add-temp");
- final isTemp = checkbox.checked;
+ final checkboxTemp:InputElement = cast ge("#addfromurl .add-temp");
+ final isTemp = checkboxTemp.checked;
+ final checkboxCache:InputElement = cast ge("#cache-on-server");
+ final doCache = checkboxCache.checked
+ && checkboxCache.parentElement.style.display != "none";
final url = mediaUrl.value;
final subs = subsUrl.value;
if (url.length == 0) return;
@@ -273,17 +282,15 @@ class Main {
handleUrlMasks(links);
// if videos added as next, we need to load them in reverse order
if (!atEnd) sortItemsForQueueNext(links);
- addVideoArray(links, atEnd, isTemp);
+ addVideoArray(links, atEnd, isTemp, doCache);
}
- public function isRawPlayerLink(url:String):Bool {
- return player.isRawPlayerLink(url);
+ public function getLinkPlayerType(url:String):PlayerType {
+ return player.getLinkPlayerType(url);
}
- public function isSingleVideoLink(url:String):Bool {
- if (~/, ?(https?)/g.match(url)) return false;
- if (mask.match(url)) return false;
- return true;
+ public function isSingleVideoUrl(url:String):Bool {
+ return player.isSingleVideoUrl(url);
}
public function sortItemsForQueueNext<T>(items:Array<T>):Void {
@@ -295,13 +302,14 @@ class Main {
if (first != null) items.unshift(first);
}
- function addVideoArray(links:Array<String>, atEnd:Bool, isTemp:Bool):Void {
+ function addVideoArray(links:Array<String>, atEnd:Bool, isTemp:Bool, doCache:Bool):Void {
if (links.length == 0) return;
final link = links.shift();
- addVideo(link, atEnd, isTemp, () -> addVideoArray(links, atEnd, isTemp));
+ addVideo(link, atEnd, isTemp, doCache, () ->
+ addVideoArray(links, atEnd, isTemp, doCache));
}
- public function addVideo(url:String, atEnd:Bool, isTemp:Bool, ?callback:() -> Void):Void {
+ public function addVideo(url:String, atEnd:Bool, isTemp:Bool, doCache:Bool, ?callback:() -> Void):Void {
final protocol = Browser.location.protocol;
if (url.startsWith("/")) {
final host = Browser.location.hostname;
@@ -330,9 +338,10 @@ class Main {
author: personal.name,
duration: data.duration,
isTemp: isTemp,
+ doCache: doCache,
subs: data.subs,
voiceOverTrack: data.voiceOverTrack,
- isIframe: data.isIframe == true
+ playerType: data.playerType
},
atEnd: atEnd
}
@@ -349,7 +358,7 @@ class Main {
final mediaTitle:InputElement = cast ge("#customembed-title");
final title = mediaTitle.value;
mediaTitle.value = "";
- final checkbox:InputElement = cast ge("#customembed").querySelector(".add-temp");
+ final checkbox:InputElement = cast ge("#customembed .add-temp");
final isTemp = checkbox.checked;
final obj:VideoDataRequest = {
url: iframe,
@@ -372,7 +381,8 @@ class Main {
author: personal.name,
duration: data.duration,
isTemp: isTemp,
- isIframe: true
+ doCache: false,
+ playerType: IframeType
},
atEnd: atEnd
}
@@ -504,12 +514,14 @@ class Main {
case Pause:
player.setPauseIndicator(false);
+ updateUserList();
if (isLeader()) return;
player.pause();
player.setTime(data.pause.time);
case Play:
player.setPauseIndicator(true);
+ updateUserList();
if (isLeader()) return;
final synchThreshold = settings.synchThreshold;
final newTime = data.play.time;
@@ -596,9 +608,6 @@ class Main {
case Dump:
Utils.saveFile("dump.json", ApplicationJson, data.dump.data);
-
- case GetYoutubeVideoInfo:
- // handled by event listeners like `JsApi.once`
}
}
@@ -609,6 +618,7 @@ class Main {
Settings.write(settings);
globalIp = connected.globalIp;
+ playersCacheSupport = connected.playersCacheSupport;
setConfig(connected.config);
if (connected.isUnknownClient) {
updateClients(connected.clients);
@@ -906,7 +916,8 @@ class Main {
final list = new StringBuf();
for (client in clients) {
list.add('<div class="userlist_item">');
- if (client.isLeader) list.add('<ion-icon name="play"></ion-icon>');
+ final iconName = player.isPaused() ? "pause" : "play";
+ if (client.isLeader) list.add('<ion-icon name="$iconName"></ion-icon>');
var klass = client.isBanned ? "userlist_banned" : "";
if (client.isAdmin) klass += " userlist_owner";
list.add('<span class="$klass">${client.name}</span></div>');
@@ -1232,7 +1243,8 @@ class Main {
return ~/([.*+?^${}()|[\]\\])/g.replace(regex, "\\$1");
}
- public static inline function ge(id:String):Element {
- return document.querySelector(id);
+ @:generic
+ public static inline function ge<T:Element>(id:String):T {
+ return cast document.querySelector(id);
}
}
diff --git a/src/client/Player.hx b/src/client/Player.hx
index f40c34c..70efcf9 100644
--- a/src/client/Player.hx
+++ b/src/client/Player.hx
@@ -1,5 +1,6 @@
package client;
+import Types.PlayerType;
import Types.VideoData;
import Types.VideoDataRequest;
import Types.VideoItem;
@@ -41,7 +42,7 @@ class Player {
streamable = new Streamable(main, this);
players = [
youtube,
- streamable
+ streamable,
];
iframePlayer = new Iframe(main, this);
rawPlayer = new Raw(main, this);
@@ -118,26 +119,40 @@ class Player {
var player = players.find(player -> player.isSupportedLink(req.url));
player ??= rawPlayer;
player.getVideoData(req, data -> {
+ data.playerType ??= player.getPlayerType();
final voiceOverTrack = voiceOverInput.value.trim();
- data.voiceOverTrack = voiceOverTrack;
voiceOverInput.value = "";
+ data.voiceOverTrack ??= voiceOverTrack;
callback(data);
});
}
- public function isRawPlayerLink(url:String):Bool {
- if (streamable.isSupportedLink(url)) return true;
- return !players.exists(player -> player.isSupportedLink(url));
+ public function getLinkPlayerType(url:String):PlayerType {
+ final player = players.find(player -> player.isSupportedLink(url));
+ if (player == null) return rawPlayer.getPlayerType();
+ return player.getPlayerType();
+ }
+
+ public function isSingleVideoUrl(url:String):Bool {
+ if (youtube.isSupportedLink(url)) {
+ if (youtube.isPlaylistUrl(url)) return false;
+ }
+ if (~/, ?(https?)/g.match(url)) return false;
+ if (main.urlMask.match(url)) return false;
+ return true;
}
public function getIframeData(data:VideoDataRequest, callback:(data:VideoData) -> Void):Void {
- iframePlayer.getVideoData(data, callback);
+ iframePlayer.getVideoData(data, data -> {
+ data.playerType = IframeType;
+ callback(data);
+ });
}
public function setVideo(i:Int):Void {
if (!main.isSyncActive) return;
final item = videoList.getItem(i);
- setSupportedPlayer(item.url, item.isIframe);
+ setSupportedPlayer(item.url, item.playerType);
removeActiveLabel(videoList.pos);
videoList.setPos(i);
@@ -190,17 +205,17 @@ class Player {
needsVolumeReset = true;
}
- function setSupportedPlayer(url:String, isIframe:Bool):Void {
+ function setSupportedPlayer(url:String, playerType:PlayerType):Void {
final currentPlayer = players.find(p -> p.isSupportedLink(url));
if (currentPlayer != null) setPlayer(currentPlayer);
- else if (isIframe) setPlayer(iframePlayer);
+ else if (playerType == IframeType) setPlayer(iframePlayer);
else setPlayer(rawPlayer);
}
public function changeVideoSrc(url:String):Void {
if (!main.isVideoEnabled) return;
final item:VideoItem = videoList.currentItem ?? return;
- setSupportedPlayer(url, item.isIframe);
+ setSupportedPlayer(url, item.playerType);
player.loadVideo(item.withUrl(url));
}
@@ -247,10 +262,6 @@ class Player {
final item = videoList.currentItem ?? return;
// do not send pause if video is ended
if (getTime() >= item.duration - 0.01) return;
- // youtube raw fallback has around one second difference from rounded youtube duration
- if (player == rawPlayer && youtube.isSupportedLink(item.url)) {
- if (getTime() >= item.duration - 1) return;
- }
final hasAutoPause = main.hasLeaderOnPauseRequest()
&& videoList.length > 0
&& getTime() > 1
@@ -316,7 +327,7 @@ class Player {
public function addVideoItem(item:VideoItem, atEnd:Bool):Void {
final url = item.url.htmlEscape(true);
- final duration = item.isIframe ? "" : duration(item.duration);
+ final duration = item.playerType == IframeType ? "" : duration(item.duration);
final itemEl = Utils.nodeFromString(
'<li class="queue_entry info" title="${Lang.get("addedBy")}: ${item.author}">
<header>
@@ -449,7 +460,7 @@ class Player {
function totalDuration():String {
var time = 0.0;
for (item in videoList.getItems()) {
- if (item.isIframe) continue;
+ if (item.playerType == IframeType) continue;
time += item.duration;
}
return duration(time);
diff --git a/src/client/players/Iframe.hx b/src/client/players/Iframe.hx
index 56cf319..8a2e06e 100644
--- a/src/client/players/Iframe.hx
+++ b/src/client/players/Iframe.hx
@@ -1,5 +1,6 @@
package client.players;
+import Types.PlayerType;
import Types.VideoData;
import Types.VideoDataRequest;
import Types.VideoItem;
@@ -18,13 +19,17 @@ class Iframe implements IPlayer {
this.player = player;
}
+ public function getPlayerType():PlayerType {
+ return IframeType;
+ }
+
public function isSupportedLink(url:String):Bool {
return true;
}
public function getVideoData(data:VideoDataRequest, callback:(data:VideoData) -> Void):Void {
final iframe = document.createDivElement();
- iframe.innerHTML = data.url;
+ iframe.innerHTML = data.url.trim();
if (isValidIframe(iframe)) {
callback({duration: 99 * 60 * 60});
} else {
diff --git a/src/client/players/Raw.hx b/src/client/players/Raw.hx
index 5c5b7a4..7e5e50c 100644
--- a/src/client/players/Raw.hx
+++ b/src/client/players/Raw.hx
@@ -1,5 +1,6 @@
package client.players;
+import Types.PlayerType;
import Types.VideoData;
import Types.VideoDataRequest;
import Types.VideoItem;
@@ -30,6 +31,10 @@ class Raw implements IPlayer {
this.player = player;
}
+ public function getPlayerType():PlayerType {
+ return RawType;
+ }
+
public function isSupportedLink(url:String):Bool {
return true;
}
diff --git a/src/client/players/Youtube.hx b/src/client/players/Youtube.hx
index 3ad9030..5a8921a 100644
--- a/src/client/players/Youtube.hx
+++ b/src/client/players/Youtube.hx
@@ -1,5 +1,6 @@
package client.players;
+import Types.PlayerType;
import Types.VideoData;
import Types.VideoDataRequest;
import Types.VideoItem;
@@ -23,7 +24,6 @@ class Youtube implements IPlayer {
var apiKey:String;
var video:Element;
var youtube:YoutubePlayer;
- var tempYoutube:YoutubePlayer;
var isLoaded = false;
public function new(main:Main, player:Player) {
@@ -31,6 +31,10 @@ class Youtube implements IPlayer {
this.player = player;
}
+ public function getPlayerType():PlayerType {
+ return YoutubeType;
+ }
+
public function isSupportedLink(url:String):Bool {
return extractVideoId(url) != "" || extractPlaylistId(url) != "";
}
@@ -43,6 +47,10 @@ class Youtube implements IPlayer {
return YoutubeUtils.extractPlaylistId(url);
}
+ public function isPlaylistUrl(url:String):Bool {
+ return extractVideoId(url) == "" && extractPlaylistId(url) != "";
+ }
+
final matchHours = ~/([0-9]+)H/;
final matchMinutes = ~/([0-9]+)M/;
final matchSeconds = ~/([0-9]+)S/;
@@ -93,7 +101,7 @@ class Youtube implements IPlayer {
url: '<iframe src="https://www.youtube.com/embed/$id?autoplay=1$mute" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>',
- isIframe: true
+ playerType: IframeType
});
continue;
}
@@ -168,6 +176,7 @@ class Youtube implements IPlayer {
final video = document.createDivElement();
video.id = "temp-videoplayer";
Utils.prepend(playerEl, video);
+ var tempYoutube:YoutubePlayer = null;
tempYoutube = new YoutubePlayer(video.id, {
videoId: extractVideoId(url),
playerVars: {
@@ -181,12 +190,14 @@ class Youtube implements IPlayer {
callback({
duration: tempYoutube.getDuration()
});
+ tempYoutube.destroy();
},
onError: e -> {
// TODO message error codes
trace('Error ${e.data}');
if (playerEl.contains(video)) playerEl.removeChild(video);
callback({duration: 0});
+ tempYoutube.destroy();
}
}
});
@@ -243,49 +254,13 @@ class Youtube implements IPlayer {
onError: e -> {
// TODO message error codes
trace('Error ${e.data}');
- final item = player.getCurrentItem() ?? return;
- rawSourceFallback(item.url);
+ // final item = player.getCurrentItem() ?? return;
+ // rawSourceFallback(item.url);
}
}
});
}
- function rawSourceFallback(url:String):Void {
- JsApi.once(GetYoutubeVideoInfo, event -> {
- final data = event.getYoutubeVideoInfo;
- final info = data.response;
- final format = getBestStreamFormat(info) ?? {
- trace("format not found in response info:");
- trace(info);
- return;
- };
- player.changeVideoSrc(format.url);
- });
- main.send({
- type: GetYoutubeVideoInfo,
- getYoutubeVideoInfo: {
- url: url
- }
- });
- }
-
- function getBestStreamFormat(info:YouTubeVideoInfo):Null<YoutubeVideoFormat> {
- info.formats ??= [];
- info.adaptiveFormats ??= [];
- final formats = info.adaptiveFormats.concat(info.formats);
- trace(formats);
- final qPriority = [1080, 720, 480, 360, 240];
- for (q in qPriority) {
- final quality = '${q}p';
- for (format in formats) {
- if (format.audioQuality == null) continue; // no sound
- if (format.width == null) continue; // no video
- if (format.qualityLabel == quality) return format;
- }
- }
- return null;
- }
-
public function removeVideo():Void {
if (video == null) return;
isLoaded = false;
diff --git a/src/server/Cache.hx b/src/server/Cache.hx
new file mode 100644
index 0000000..8348476
--- /dev/null
+++ b/src/server/Cache.hx
@@ -0,0 +1,165 @@
+package server;
+
+import js.lib.Promise;
+import js.node.ChildProcess;
+import js.node.Fs.Fs;
+import js.node.stream.Readable;
+import sys.FileSystem;
+import utils.YoutubeUtils;
+
+class Cache {
+ final main:Main;
+ final cacheDir:String;
+
+ public final cachedFiles:Array<String> = [];
+
+ public final isYtReady = false;
+
+ /** In bytes **/
+ public var storageLimit = 3 * 1024 * 1024 * 1024;
+
+ public function new(main:Main, cacheDir:String) {
+ this.main = main;
+ this.cacheDir = cacheDir;
+ Utils.ensureDir(cacheDir);
+ isYtReady = checkYtDeps();
+ }
+
+ function checkYtDeps():Bool {
+ final ytdl = try {
+ untyped require("@distube/ytdl-core");
+ } catch (e) {
+ return false;
+ }
+ try {
+ ChildProcess.execSync("ffmpeg -version", {stdio: "ignore", timeout: 3000});
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function log(client:Client, msg:String):Void {
+ main.serverMessage(client, msg);
+ trace(msg);
+ }
+
+ public function cacheYoutubeVideo(client:Client, url:String, callback:(name:String) -> Void) {
+ if (!isYtReady) {
+ trace("Do `npm i @distube/ytdl-core@latest` to use cache feature (you also need to install `ffmpeg` to build mp4 from downloaded audio/video tracks).");
+ return;
+ }
+ final videoId = YoutubeUtils.extractVideoId(url);
+ if (videoId == "") {
+ log(client, 'Error: youtube video id not found in url: $url');
+ return;
+ }
+ final outName = videoId + ".mp4";
+ if (cachedFiles.contains(outName)) {
+ callback(outName);
+ return;
+ }
+ final ytdl:Dynamic = untyped require("@distube/ytdl-core");
+ log(client, 'Caching $url to $outName...');
+ final opts = {playerClients: ["IOS", "WEB_CREATOR"]};
+ final promise:Promise<YouTubeVideoInfo> = ytdl.getInfo(url, opts);
+ promise.then(info -> {
+ // trace(info.formats.filter(item -> item.audioCodec != null));
+ trace('Get info with ${info.formats.length} formats');
+ final audioFormat:YoutubeVideoFormat = try {
+ ytdl.chooseFormat(info.formats.filter(item -> {
+ return item.audioCodec?.startsWith("mp4a");
+ }), {quality: "highestaudio"});
+ } catch (e) {
+ log(client, "Error: audio format not found");
+ trace(e);
+ trace(info.formats);
+ return;
+ }
+ final videoFormat = getBestYoutubeVideoFormat(info.formats) ?? {
+ log(client, "Error: video format not found");
+ trace(info.formats);
+ return;
+ }
+ trace("Picked audio and video formats");
+
+ final dlVideo:Readable<Dynamic> = ytdl(url, {
+ format: videoFormat,
+ playerClients: opts.playerClients
+ });
+ dlVideo.pipe(Fs.createWriteStream('$cacheDir/input-video'));
+ dlVideo.on("error", err -> log(client, "Error during video download: " + err));
+
+ final dlAudio:Readable<Dynamic> = ytdl(url, {
+ format: audioFormat,
+ playerClients: opts.playerClients
+ });
+ dlAudio.pipe(Fs.createWriteStream('$cacheDir/input-audio'));
+ dlAudio.on("error", err -> log(client, "Error during audio download: " + err));
+
+ var count = 0;
+ function onComplete(type:String):Void {
+ count++;
+ log(client, '$type track downloaded ($count/2)');
+ if (count < 2) return;
+ final args = '-y -i input-video -i input-audio -c copy -map 0:v -map 1:a $outName'.split(" ");
+ final process = ChildProcess.spawn("ffmpeg", args, {
+ cwd: cacheDir,
+ stdio: "ignore"
+ });
+ process.on("close", (code:Int) -> {
+ if (code != 0) {
+ log(client, 'Error: ffmpeg closed with code $code');
+ return;
+ }
+ final inVideo = '$cacheDir/input-video';
+ final inAudio = '$cacheDir/input-audio';
+ if (FileSystem.exists(inVideo)) FileSystem.deleteFile(inVideo);
+ if (FileSystem.exists(inAudio)) FileSystem.deleteFile(inAudio);
+
+ cachedFiles.push(outName);
+ removeOlderCache();
+
+ callback(outName);
+ });
+ }
+ dlVideo.on("finish", () -> onComplete("Video"));
+ dlAudio.on("finish", () -> onComplete("Audio"));
+ // dlVideo.on('progress', (c, d, t) -> {
+ // final progress = Std.int((d / t * 100) * 10) / 10;
+ // trace(progress);
+ // });
+ }).catchError(err -> {
+ log(client, "" + err);
+ });
+ }
+
+ function removeOlderCache():Void {
+ while (getUsedSpace() > storageLimit) {
+ final name = cachedFiles.shift();
+ final path = '$cacheDir/$name';
+ if (FileSystem.exists(path)) FileSystem.deleteFile(path);
+ }
+ }
+
+ function getUsedSpace():Int {
+ var total = 0;
+ for (name in cachedFiles) {
+ final path = '$cacheDir/$name';
+ total += FileSystem.stat(path).size;
+ }
+ return total;
+ }
+
+ function getBestYoutubeVideoFormat(formats:Array<YoutubeVideoFormat>):Null<YoutubeVideoFormat> {
+ final qPriority = [1080, 720, 480, 360, 240];
+ for (q in qPriority) {
+ final quality = '${q}p';
+ for (format in formats) {
+ if (format.videoCodec == null) continue;
+ if (format.qualityLabel == quality) return format;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/server/Main.hx b/src/server/Main.hx
index 9c2f6e2..b257e91 100644
--- a/src/server/Main.hx
+++ b/src/server/Main.hx
@@ -5,6 +5,7 @@ import Types.Config;
import Types.FlashbackItem;
import Types.Message;
import Types.Permission;
+import Types.PlayerType;
import Types.UserList;
import Types.VideoItem;
import Types.WsEvent;
@@ -48,12 +49,14 @@ class Main {
var wss:WSServer;
final localIp:String;
var globalIp:String;
+ final playersCacheSupport:Array<PlayerType> = [];
var port:Int;
final userList:UserList;
final clients:Array<Client> = [];
final freeIds:Array<Int> = [];
final wsEventParser = new JsonParser<WsEvent>();
final consoleInput:ConsoleInput;
+ final cache:Cache;
final videoList = new VideoList();
final videoTimer = new VideoTimer();
final messages:Array<Message> = [];
@@ -99,9 +102,12 @@ class Main {
logger = new Logger(logsDir, 10, verbose);
consoleInput = new ConsoleInput(this);
consoleInput.initConsoleInput();
+ cache = new Cache(this, '$rootDir/user/res/cache');
+ if (cache.isYtReady) playersCacheSupport.push(YoutubeType);
initIntergationHandlers();
loadState();
config = loadUserConfig();
+ cache.storageLimit = cast config.cacheStorageLimitGiB * 1024 * 1024 * 1024;
userList = loadUsers();
config.isVerbose = verbose;
config.salt = generateConfigSalt();
@@ -276,7 +282,8 @@ class Main {
time: videoTimer.getTime(),
paused: videoTimer.isPaused()
},
- flashbacks: flashbacks
+ flashbacks: flashbacks,
+ cachedFiles: cache.cachedFiles
}
}
@@ -285,6 +292,9 @@ class Main {
if (!FileSystem.exists(statePath)) return;
trace("Loading state...");
final state:ServerState = Json.parse(File.getContent(statePath));
+ state.flashbacks ??= [];
+ state.cachedFiles ??= [];
+
videoList.setItems(state.videoList);
videoList.isOpen = state.isPlaylistOpen;
videoList.setPos(state.itemPos);
@@ -293,7 +303,10 @@ class Main {
for (message in state.messages) messages.push(message);
flashbacks.resize(0);
- for (flashback in state.flashbacks ?? []) flashbacks.push(flashback);
+ for (flashback in state.flashbacks) flashbacks.push(flashback);
+
+ cache.cachedFiles.resize(0);
+ for (name in state.cachedFiles) cache.cachedFiles.push(name);
videoTimer.start();
videoTimer.setTime(state.timer.time);
@@ -479,7 +492,8 @@ class Main {
videoList: videoList.getItems(),
isPlaylistOpen: videoList.isOpen,
itemPos: videoList.pos,
- globalIp: globalIp
+ globalIp: globalIp,
+ playersCacheSupport: playersCacheSupport,
}
});
sendClientListExcept(client);
@@ -623,17 +637,6 @@ class Main {
broadcast(data);
case ServerMessage:
- case GetYoutubeVideoInfo:
- final url = data.getYoutubeVideoInfo.url;
- YoutubeFallback.getInfo(url, info -> {
- send(client, {
- type: data.type,
- getYoutubeVideoInfo: {
- url: url,
- response: info
- }
- });
- });
case AddVideo:
if (isPlaylistLockedFor(client)) return;
if (!checkPermission(client, AddVideoPerm)) return;
@@ -660,11 +663,23 @@ class Main {
serverMessage(client, "videoAlreadyExistsError");
return;
}
- data.addVideo.item = item;
- videoList.addItem(item, data.addVideo.atEnd);
- broadcast(data);
- // Initial timer start if VideoLoaded is not happen
- if (videoList.length == 1) restartWaitTimer();
+
+ inline function addVideo():Void {
+ data.addVideo.item = item;
+ videoList.addItem(item, data.addVideo.atEnd);
+ broadcast(data);
+ // Initial timer start if VideoLoaded is not happen
+ if (videoList.length == 1) restartWaitTimer();
+ }
+ if (!item.doCache) {
+ addVideo();
+ } else {
+ cache.cacheYoutubeVideo(client, item.url, (name) -> {
+ item = item.withUrl('/cache/$name');
+ if (item.duration > 1) item.duration -= 1;
+ addVideo();
+ });
+ }
case VideoLoaded:
// Called if client loads next video and can play it
@@ -939,7 +954,7 @@ class Main {
});
}
- function serverMessage(client:Client, textId:String):Void {
+ public function serverMessage(client:Client, textId:String):Void {
send(client, {
type: ServerMessage,
serverMessage: {
diff --git a/src/server/ServerState.hx b/src/server/ServerState.hx
index 0369353..41f86ba 100644
--- a/src/server/ServerState.hx
+++ b/src/server/ServerState.hx
@@ -12,5 +12,6 @@ typedef ServerState = {
timer:{
time:Float, paused:Bool
},
- ?flashbacks:Array<FlashbackItem>
+ ?flashbacks:Array<FlashbackItem>,
+ ?cachedFiles:Array<String>,
}
diff --git a/src/server/YoutubeFallback.hx b/src/server/YoutubeFallback.hx
deleted file mode 100644
index 5360283..0000000
--- a/src/server/YoutubeFallback.hx
+++ /dev/null
@@ -1,142 +0,0 @@
-package server;
-
-import haxe.Json;
-import js.lib.Function;
-import js.lib.Object;
-import js.lib.Promise;
-import js.node.Https.Https;
-import js.node.Https.HttpsRequestOptions;
-import js.node.url.URLSearchParams;
-import utils.YoutubeUtils;
-
-class YoutubeFallback {
- static function httpsGet(
- url:String,
- ?options:HttpsRequestOptions,
- ?callback:(status:Int, data:String) -> Void
- ):Void {
- final request = Https.get(url, options, res -> {
- var data = "";
- res.on("data", chunk -> data += chunk.toString());
- res.on("end", () -> callback(res.statusCode, data));
- });
- request.on("error", err -> {
- trace(url);
- trace("request error: ", err);
- });
- }
-
- public static function resolvePlayerResponse(watchHtml:String):String {
- if (watchHtml == null) return "";
- final resReg = ~/ytInitialPlayerResponse = (.*)}}};/;
- var matches = resReg.match(watchHtml);
- return matches ? resReg.matched(1) + "}}}" : "";
- }
-
- public static function resoleM3U8Link(watchHtml:String):Null<String> {
- if (watchHtml == null) return null;
- final hlsReg = ~/hlsManifestUrl":"(.*\/file\/index\.m3u8)/;
- return hlsReg.match(watchHtml) ? hlsReg.matched(1) : null;
- }
-
- public static function buildDecoder(watchHtml:String, callback:(decoder:(cipher:String) -> String) -> Void):Void {
- if (watchHtml == null) return callback(null);
-
- final jsFileUrlReg = ~/\/s\/player\/[A-Za-z0-9]+\/[A-Za-z0-9_.]+\/[A-Za-z0-9_]+\/base\.js/;
- if (!jsFileUrlReg.match(watchHtml)) return callback(null);
-
- final url = "https://www.youtube.com" + jsFileUrlReg.matched(0);
- httpsGet(url, {}, (status, jsFileContent) -> {
- final funcReg = ~/function.*\.split\(""\).*\.join\(""\)}/;
- if (!funcReg.match(jsFileContent)) return callback(null);
-
- final decodeFunction = funcReg.matched(0);
- final varNameReg = ~/\.split\(""\);([a-zA-Z0-9]+)\./i;
- if (!varNameReg.match(decodeFunction)) return callback(null);
-
- final varStartIndex = jsFileContent.indexOf("var " + varNameReg.matched(1) + "={");
- if (varStartIndex < 0) return callback(null);
-
- final varEndIndex = jsFileContent.indexOf("}};", varStartIndex);
- if (varEndIndex < 0) return callback(null);
-
- final varDeclares = jsFileContent.substring(varStartIndex, varEndIndex + 3);
- if (varDeclares.length == 0) return callback(null);
-
- callback(signatureCipher -> {
- final params = new URLSearchParams(signatureCipher);
- final obj = Object.fromEntries(params);
- final signature = obj.s;
- final signatureParam = obj.sp ?? "signature";
- final url = obj.url;
- final decodedSignature = new Function('
- "use strict";
- $varDeclares
- return ($decodeFunction)("$signature");
- ').call(null);
- return '$url&$signatureParam=${untyped encodeURIComponent(decodedSignature)}';
- });
- });
- }
-
- public static function getInfo(url:String, callback:(info:Null<YouTubeVideoInfo>) -> Void):Void {
- final videoId = YoutubeUtils.extractVideoId(url);
- if (videoId.length == 0) {
- trace("youtube videoId is not found");
- return callback(null);
- }
-
- final url = 'https://www.youtube.com/watch?v=$videoId';
- httpsGet(url, {}, (status, data) -> {
- if (status != 200 || data.length == 0) {
- trace("Cannot get youtube video response");
- return callback(null);
- }
-
- final ytInitialPlayerResponse = resolvePlayerResponse(data);
- final parsedResponse = Json.parse(ytInitialPlayerResponse);
- final streamingData:YouTubeVideoInfo = parsedResponse.streamingData ?? cast {};
- streamingData.formats ??= [];
- streamingData.adaptiveFormats ??= [];
- var formats:Array<YoutubeVideoFormat> = streamingData.formats.concat(streamingData.adaptiveFormats);
-
- final promises:Array<Promise<Any>> = [];
-
- final isEncryptedVideo = formats.exists(it -> it.signatureCipher != null);
- if (isEncryptedVideo) {
- final promise = new Promise((resolve, reject) -> {
- buildDecoder(data, decoder -> {
- if (decoder != null) {
- formats = formats.map(item -> {
- if (item.url != null || item.signatureCipher == null) return item;
-
- item.url = decoder(item.signatureCipher);
- item.signatureCipher = null;
- return item;
- });
- }
- resolve(null);
- });
- });
- promises.push(promise);
- }
-
- Promise.all(promises).then(_ -> {
- final result:YouTubeVideoInfo = {
- videoDetails: parsedResponse.videoDetails ?? cast {},
- formats: formats.filter(format -> format.url != null)
- };
- if (result.videoDetails.isLiveContent) {
- final m3u8Link = resoleM3U8Link(data);
- try {
- result.liveData = {
- manifestUrl: m3u8Link,
- // data: m3u8Parser.getResult()
- };
- }
- }
- callback(result);
- });
- });
- }
-}
diff --git a/src/utils/YoutubeUtils.hx b/src/utils/YoutubeUtils.hx
index 7429565..0bb8016 100644
--- a/src/utils/YoutubeUtils.hx
+++ b/src/utils/YoutubeUtils.hx
@@ -41,7 +41,11 @@ typedef YoutubeVideoFormat = {
?indexRange:{start:Int, end:Int},
?audioQuality:String, // AUDIO_QUALITY_LOW
?audioSampleRate:Int,
- ?audioChannels:Int
+ ?audioChannels:Int,
+
+ ?container:String,
+ ?videoCodec:String,
+ ?audioCodec:String,
}
typedef YouTubeVideoInfo = {
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage