Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disable http disk cache and implempent in-memory image cache #2498

Merged
merged 12 commits into from
Oct 25, 2022
Merged
8 changes: 5 additions & 3 deletions _scripts/dev-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ async function restartElectron() {

electronProcess = spawn(electron, [
path.join(__dirname, '../dist/main.js'),
// '--enable-logging', Enable to show logs from all electron processes
// '--enable-logging', // Enable to show logs from all electron processes
remoteDebugging ? '--inspect=9222' : '',
remoteDebugging ? '--remote-debugging-port=9223' : '',
])
remoteDebugging ? '--remote-debugging-port=9223' : ''
],
// { stdio: 'inherit' } // required for logs to actually appear in the stdout
)

electronProcess.on('exit', (code, _) => {
if (code === relaunchExitCode) {
Expand Down
73 changes: 73 additions & 0 deletions src/main/ImageCache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// cleanup expired images once every 5 mins
const CLEANUP_INTERVAL = 300_000

// images expire after 2 hours if no expiry information is found in the http headers
const FALLBACK_MAX_AGE = 7200

export class ImageCache {
constructor() {
this._cache = new Map()

setInterval(this._cleanup.bind(this), CLEANUP_INTERVAL)
}

add(url, mimeType, data, expiry) {
this._cache.set(url, { mimeType, data, expiry })
}

has(url) {
return this._cache.has(url)
}

get(url) {
const entry = this._cache.get(url)

if (!entry) {
// this should never happen as the `has` method should be used to check for the existence first
throw new Error(`No image cache entry for ${url}`)
}

return {
data: entry.data,
mimeType: entry.mimeType
}
}

_cleanup() {
// seconds since 1970-01-01 00:00:00
const now = Math.trunc(Date.now() / 1000)

for (const [key, entry] of this._cache.entries()) {
if (entry.expiry <= now) {
this._cache.delete(key)
}
}
}
}

/**
* Extracts the cache expiry timestamp of image from HTTP headers
* @param {Record<string, string>} headers
* @returns a timestamp in seconds
*/
export function extractExpiryTimestamp(headers) {
const maxAgeRegex = /max-age=([0-9]+)/

const cacheControl = headers['cache-control']
if (cacheControl && maxAgeRegex.test(cacheControl)) {
let maxAge = parseInt(cacheControl.match(maxAgeRegex)[1])

if (headers.age) {
maxAge -= parseInt(headers.age)
}

// we don't need millisecond precision, so we can store it as seconds to use less memory
return Math.trunc(Date.now() / 1000) + maxAge
} else if (headers.expires) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires

return Math.trunc(Date.parse(headers.expires) / 1000)
} else {
return Math.trunc(Date.now() / 1000) + FALLBACK_MAX_AGE
}
}
122 changes: 121 additions & 1 deletion src/main/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
app, BrowserWindow, dialog, Menu, ipcMain,
powerSaveBlocker, screen, session, shell, nativeTheme
powerSaveBlocker, screen, session, shell, nativeTheme, net, protocol
} from 'electron'
import path from 'path'
import cp from 'child_process'

import { IpcChannels, DBActions, SyncEvents } from '../constants'
import baseHandlers from '../datastores/handlers/base'
import { extractExpiryTimestamp, ImageCache } from './ImageCache'
import { existsSync } from 'fs'

if (process.argv.includes('--version')) {
app.exit()
Expand Down Expand Up @@ -49,6 +51,17 @@ function runApp() {
app.commandLine.appendSwitch('enable-file-cookies')
app.commandLine.appendSwitch('ignore-gpu-blacklist')

// command line switches need to be added before the app ready event first
// that means we can't use the normal settings system as that is asynchonous,
// doing it synchronously ensures that we add it before the event fires
const replaceHttpCache = existsSync(`${app.getPath('userData')}/experiment-replace-http-cache`)
if (replaceHttpCache) {
// the http cache causes excessive disk usage during video playback
// we've got a custom image cache to make up for disabling the http cache
// experimental as it increases RAM use in favour of reduced disk use
app.commandLine.appendSwitch('disable-http-cache')
}

// See: https://stackoverflow.com/questions/45570589/electron-protocol-handler-not-working-on-windows
// remove so we can register each time as we run the app.
app.removeAsDefaultProtocolClient('freetube')
Expand Down Expand Up @@ -149,6 +162,113 @@ function runApp() {
})
})

if (replaceHttpCache) {
// in-memory image cache

const imageCache = new ImageCache()

protocol.registerBufferProtocol('imagecache', (request, callback) => {
// Remove `imagecache://` prefix
const url = decodeURIComponent(request.url.substring(13))
if (imageCache.has(url)) {
const cached = imageCache.get(url)

// eslint-disable-next-line node/no-callback-literal
callback({
mimeType: cached.mimeType,
data: cached.data
})
return
}

const newRequest = net.request({
method: request.method,
url
})

// Electron doesn't allow certain headers to be set:
// https://www.electronjs.org/docs/latest/api/client-request#requestsetheadername-value
// also blacklist Origin and Referrer as we don't want to let YouTube know about them
const blacklistedHeaders = ['content-length', 'host', 'trailer', 'te', 'upgrade', 'cookie2', 'keep-alive', 'transfer-encoding', 'origin', 'referrer']

for (const header of Object.keys(request.headers)) {
if (!blacklistedHeaders.includes(header.toLowerCase())) {
newRequest.setHeader(header, request.headers[header])
}
}

newRequest.on('response', (response) => {
const chunks = []
response.on('data', (chunk) => {
chunks.push(chunk)
})

response.on('end', () => {
const data = Buffer.concat(chunks)

const expiryTimestamp = extractExpiryTimestamp(response.headers)
const mimeType = response.headers['content-type']

imageCache.add(url, mimeType, data, expiryTimestamp)

// eslint-disable-next-line node/no-callback-literal
callback({
mimeType,
data: data
})
})

response.on('error', (error) => {
console.error('image cache error', error)

// error objects don't get serialised properly
// https://stackoverflow.com/a/53624454

const errorJson = JSON.stringify(error, (key, value) => {
if (value instanceof Error) {
return {
// Pull all enumerable properties, supporting properties on custom Errors
...value,
// Explicitly pull Error's non-enumerable properties
name: value.name,
message: value.message,
stack: value.stack
}
}

return value
})

// eslint-disable-next-line node/no-callback-literal
callback({
statusCode: response.statusCode ?? 400,
mimeType: 'application/json',
data: Buffer.from(errorJson)
})
})
})

newRequest.end()
})

const imageRequestFilter = { urls: ['https://*/*', 'http://*/*'] }
session.defaultSession.webRequest.onBeforeRequest(imageRequestFilter, (details, callback) => {
// the requests made by the imagecache:// handler to fetch the image,
// are allowed through, as their resourceType is 'other'
if (details.resourceType === 'image') {
// eslint-disable-next-line node/no-callback-literal
callback({
redirectURL: `imagecache://${encodeURIComponent(details.url)}`
})
} else {
// eslint-disable-next-line node/no-callback-literal
callback({})
}
})

// --- end of `if experimentsDisableDiskCache` ---
}

await createWindow()

if (isDev) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.experimental-warning {
text-align: center;
font-weight: bold;
padding-left: 4%;
padding-right: 4%
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { closeSync, existsSync, openSync, rmSync } from 'fs'
import Vue from 'vue'
import { mapActions } from 'vuex'
import FtSettingsSection from '../ft-settings-section/ft-settings-section.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
import FtPrompt from '../ft-prompt/ft-prompt.vue'

export default Vue.extend({
name: 'ExperimentalSettings',
components: {
'ft-settings-section': FtSettingsSection,
'ft-flex-box': FtFlexBox,
'ft-toggle-switch': FtToggleSwitch,
'ft-prompt': FtPrompt
},
data: function () {
return {
replaceHttpCacheLoading: true,
replaceHttpCache: false,
replaceHttpCachePath: '',
showRestartPrompt: false
}
},
mounted: function () {
this.getUserDataPath().then((userData) => {
this.replaceHttpCachePath = `${userData}/experiment-replace-http-cache`

this.replaceHttpCache = existsSync(this.replaceHttpCachePath)
this.replaceHttpCacheLoading = false
})
},
methods: {
updateReplaceHttpCache: function () {
this.replaceHttpCache = !this.replaceHttpCache

if (this.replaceHttpCache) {
// create an empty file
closeSync(openSync(this.replaceHttpCachePath, 'w'))
} else {
rmSync(this.replaceHttpCachePath)
}
},

handleRestartPrompt: function (value) {
this.replaceHttpCache = value
this.showRestartPrompt = true
},

handleReplaceHttpCache: function (value) {
this.showRestartPrompt = false

if (value === null || value === 'no') {
this.replaceHttpCache = !this.replaceHttpCache
return
}

if (this.replaceHttpCache) {
// create an empty file
closeSync(openSync(this.replaceHttpCachePath, 'w'))
} else {
rmSync(this.replaceHttpCachePath)
}

const { ipcRenderer } = require('electron')
ipcRenderer.send('relaunchRequest')
},

...mapActions([
'getUserDataPath'
])
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<template>
<ft-settings-section
:title="$t('Settings.Experimental Settings.Experimental Settings')"
>
<p class="experimental-warning">
{{ $t('Settings.Experimental Settings.Warning') }}
</p>
<ft-flex-box>
<ft-toggle-switch
tooltip-position="top"
:label="$t('Settings.Experimental Settings.Replace HTTP Cache')"
:compact="true"
:default-value="replaceHttpCache"
:disabled="replaceHttpCacheLoading"
:tooltip="$t('Tooltips.Experimental Settings.Replace HTTP Cache')"
@change="handleRestartPrompt"
/>
</ft-flex-box>
<ft-prompt
v-if="showRestartPrompt"
:label="$t('Settings[\'The app needs to restart for changes to take effect. Restart and apply change?\']')"
:option-names="[$t('Yes'), $t('No')]"
:option-values="['yes', 'no']"
@click="handleReplaceHttpCache"
/>
</ft-settings-section>
</template>

<script src="./experimental-settings.js" />
<style scoped src="./experimental-settings.css" />
4 changes: 4 additions & 0 deletions src/renderer/components/ft-toggle-switch/ft-toggle-switch.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default Vue.extend({
tooltip: {
type: String,
default: ''
},
tooltipPosition: {
type: String,
default: 'bottom-left'
}
},
data: function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<ft-tooltip
v-if="tooltip !== ''"
class="selectTooltip"
position="bottom-left"
:position="tooltipPosition"
:tooltip="tooltip"
/>
</label>
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/views/Settings/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import DistractionSettings from '../../components/distraction-settings/distracti
import ProxySettings from '../../components/proxy-settings/proxy-settings.vue'
import SponsorBlockSettings from '../../components/sponsor-block-settings/sponsor-block-settings.vue'
import ParentControlSettings from '../../components/parental-control-settings/parental-control-settings.vue'
import ExperimentalSettings from '../../components/experimental-settings/experimental-settings.vue'

export default Vue.extend({
name: 'Settings',
Expand All @@ -30,7 +31,8 @@ export default Vue.extend({
'proxy-settings': ProxySettings,
'sponsor-block-settings': SponsorBlockSettings,
'download-settings': DownloadSettings,
'parental-control-settings': ParentControlSettings
'parental-control-settings': ParentControlSettings,
'experimental-settings': ExperimentalSettings
},
computed: {
usingElectron: function () {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/views/Settings/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
<parental-control-settings />
<hr>
<sponsor-block-settings />
<hr v-if="usingElectron">
<experimental-settings v-if="usingElectron" />
</div>
</template>

Expand Down
Loading