~cadence/cloudtube

ac3de4b4e6b38e3f209d22f85f319bdf4468aa63 — Cadence Ember 2 months ago fd854ec
Implement preferred quality selection

The list is subject to change as I collect more feedback. I just want
to get this initial change out for everyone to use and appreciate.
6 files changed, 85 insertions(+), 19 deletions(-)

M api/video.js
M html/static/js/player.js
M pug/settings.pug
M pug/video.pug
M utils/constants.js
M utils/upgradedb.js
M api/video.js => api/video.js +60 -8
@@ 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})
			}
		}
	}

M html/static/js/player.js => html/static/js/player.js +3 -2
@@ 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()

M pug/settings.pug => pug/settings.pug +8 -0
@@ 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"}

M pug/video.pug => pug/video.pug +3 -8
@@ 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

M utils/constants.js => utils/constants.js +6 -1
@@ 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

M utils/upgradedb.js => utils/upgradedb.js +5 -0
@@ 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()
	}
]