diff --git a/api/video.js b/api/video.js index 9d63276..1e78a8e 100644 --- a/api/video.js +++ b/api/video.js @@ -37,6 +37,53 @@ function formatOrder(format) { return -total } +function sortFormats(video, preference) { + const standard = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height) + const adaptive = video.adaptiveFormats.filter(f => f.type.startsWith("video") && f.qualityLabel).sort((a, b) => a.second__order - b.second__order) + let formats = standard.concat(adaptive) + + for (const format of formats) { + if (!format.second__height && format.resolution) format.second__height = +format.resolution.slice(0, -1) + if (!format.second__order) format.second__order = formatOrder(format) + format.cloudtube__label = `${format.qualityLabel} ${format.container}` + } + for (const format of adaptive) { + format.cloudtube__label += " *" + } + + if (preference === 1) { // best dash + formats.sort((a, b) => (b.second__height - a.second__height)) + } else if (preference === 2) { // best <=1080p + formats.sort((a, b) => { + if (b.second__height > 1080) { + if (a.second__height > 1080) return b.second__height - a.second__height + return -1 + } + if (a.second__height > 1080) return 1 + return b.second__height - a.second__height + }) + } else if (preference === 3) { // best low-fps + formats.sort((a, b) => { + if (b.fps > 30) { + if (a.fps < 30) return b.second__height - a.second__height + return -1 + } + if (a.fps > 30) return 1 + return b.second__height - a.second__height + }) + } else if (preference === 4) { // 360p only + formats.sort((a, b) => { + if (a.itag == 18) return -1 + if (b.itag == 18) return 1 + return 0 + }) + } else { // preference === 0, best combined + // should already be correct + } + + return formats +} + function rewriteVideoDescription(descriptionHtml, id) { // replace timestamps to clickable links and rewrite youtube links to stay on the instance instead of pointing to YouTube // test cases @@ -71,23 +118,24 @@ function rewriteVideoDescription(descriptionHtml, id) { return descriptionHtml } -async function renderVideo(videoPromise, {user, id, instanceOrigin}, locals = {}) { +async function renderVideo(videoPromise, {user, settings, id, instanceOrigin}, locals = {}) { try { // resolve video const video = await videoPromise if (!video) throw new Error("The instance returned null.") if (video.error) throw new InstanceError(video.error, video.identifier) + // process stream list ordering - for (const format of video.formatStreams.concat(video.adaptiveFormats)) { - if (!format.second__height && format.resolution) format.second__height = +format.resolution.slice(0, -1) - if (!format.second__order) format.second__order = formatOrder(format) - } + const formats = sortFormats(video, settings.quality) + // process length text and view count for (const rec of video.recommendedVideos) { converters.normaliseVideoInfo(rec) } + // get subscription data const subscribed = user.isSubscribed(video.authorId) + // process watched videos user.addWatchedVideoMaybe(video.videoId) const watchedVideos = user.getWatchedVideos() @@ -96,12 +144,16 @@ async function renderVideo(videoPromise, {user, id, instanceOrigin}, locals = {} rec.watched = watchedVideos.includes(rec.videoId) } } + // normalise view count if (!video.second__viewCountText && video.viewCount) { video.second__viewCountText = converters.viewCountToText(video.viewCount) } + + // rewrite description video.descriptionHtml = rewriteVideoDescription(video.descriptionHtml, id) - return render(200, "pug/video.pug", Object.assign(locals, {video, subscribed, instanceOrigin})) + + return render(200, "pug/video.pug", Object.assign(locals, {video, formats, subscribed, instanceOrigin})) } catch (e) { // show an appropriate error message // these should probably be split out to their own files @@ -168,7 +220,7 @@ module.exports = [ const instanceOrigin = settings.instance const outURL = `${instanceOrigin}/api/v1/videos/${id}` const videoPromise = request(outURL).then(res => res.json()) - return renderVideo(videoPromise, {user, id, instanceOrigin}, {mediaFragment}) + return renderVideo(videoPromise, {user, settings, id, instanceOrigin}, {mediaFragment}) } else { return render(200, "pug/local-video.pug", {id}) } @@ -176,7 +228,7 @@ module.exports = [ const video = JSON.parse(new URLSearchParams(body.toString()).get("video")) const videoPromise = Promise.resolve(video) const instanceOrigin = "http://localhost:3000" - return renderVideo(videoPromise, {user, id, instanceOrigin}, {mediaFragment}) + return renderVideo(videoPromise, {user, settings, id, instanceOrigin}, {mediaFragment}) } } } diff --git a/html/static/js/player.js b/html/static/js/player.js index dc4a3b3..1826053 100644 --- a/html/static/js/player.js +++ b/html/static/js/player.js @@ -87,10 +87,11 @@ const playManagers = { class QualitySelect extends ElemJS { constructor() { super(q("#quality-select")) - this.on("input", this.onInput.bind(this)) + this.on("input", this.setFormat.bind(this)) + this.setFormat() } - onInput() { + setFormat() { const itag = this.element.value formatLoader.play(itag) video.focus() diff --git a/pug/settings.pug b/pug/settings.pug index d70c3e4..208f0bc 100644 --- a/pug/settings.pug +++ b/pug/settings.pug @@ -32,6 +32,14 @@ block content +input("instance", "Instance", "url", constants.user_settings.instance.default, false, instances) + +select("quality", "Preferred qualities", false, [ + {value: "0", text: "Best combined"}, + {value: "1", text: "Best DASH"}, + {value: "2", text: "Best <=1080p"}, + {value: "3", text: "Best low-fps"}, + {value: "4", text: "360p"} + ]) + +select("save_history", "Watched videos history", false, [ {value: "0", text: "Don't store"}, {value: "1", text: "Store on server"} diff --git a/pug/video.pug b/pug/video.pug index 2b09e10..7eb8ed0 100644 --- a/pug/video.pug +++ b/pug/video.pug @@ -14,12 +14,9 @@ block head block content unless error main.video-page - - const sortedFormatStreams = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height) - - const sortedVideoAdaptiveFormats = video.adaptiveFormats.filter(f => f.type.startsWith("video") && f.qualityLabel).sort((a, b) => a.second__order - b.second__order) - .main-video-section .video-container - - const format = sortedFormatStreams[0] + - const format = formats[0] if format video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag)#video.video source(src=format.url+mediaFragment type=format.type) @@ -49,10 +46,8 @@ block content +subscribe_button(video.authorId, subscribed, `/watch?v=${video.videoId}`).border-look //- button.border-look#theatre Theatre select(autocomplete="off").border-look#quality-select - each f in sortedFormatStreams - option(value=f.itag)= `${f.qualityLabel} ${f.container}` - each f in sortedVideoAdaptiveFormats - option(value=f.itag)= `${f.qualityLabel} ${f.container} *` + each f in formats + option(value=f.itag)= f.cloudtube__label //- a(href="/subscriptions").border-look img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon diff --git a/utils/constants.js b/utils/constants.js index 9f51604..6d9a92c 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -16,6 +16,10 @@ let constants = { local: { type: "boolean", default: false + }, + quality: { + type: "integer", + default: 0 } }, @@ -56,7 +60,8 @@ try { const overrides = require("../config/config.js") constants = mixin(constants, overrides) } catch (e) { - console.log("Note: overrides file `config/config.js` ignored, file not found.") + console.error("Missing config file /config/config.js\nDocumentation: https://git.sr.ht/~cadence/tube-docs/tree/main/item/docs") + process.exit(1) } module.exports = constants diff --git a/utils/upgradedb.js b/utils/upgradedb.js index 40d5e4c..9e71646 100644 --- a/utils/upgradedb.js +++ b/utils/upgradedb.js @@ -38,6 +38,11 @@ const deltas = [ function() { db.prepare("UPDATE Settings SET instance = REPLACE(REPLACE(instance, '/', ''), ':', '://') WHERE instance LIKE '%/'") .run() + }, + // 5: Settings +quality + function() { + db.prepare("ALTER TABLE Settings ADD COLUMN quality INTEGER DEFAULT 0") + .run() } ]