feat: move WT to worker, UI fixes, UI improvements

This commit is contained in:
ThaUnknown 2022-04-24 02:03:28 +02:00
parent ec3dd4924f
commit a11516e088
12 changed files with 291 additions and 332 deletions

View file

@ -1,6 +1,6 @@
{
"name": "Miru",
"version": "1.9.1",
"version": "2.0.0",
"author": "ThaUnknown_ <ThaUnknown@users.noreply.github.com>",
"main": "src/index.js",
"homepage": "https://github.com/ThaUnknown/miru#readme",

View file

@ -2,7 +2,6 @@ const { app, BrowserWindow, protocol, shell, ipcMain } = require('electron')
const path = require('path')
const log = require('electron-log')
const { autoUpdater } = require('electron-updater')
require('./main/torrent.js')
require('./main/misc.js')
if (process.defaultApp) {
@ -81,6 +80,7 @@ function createWindow () {
webPreferences: {
enableBlinkFeatures: 'AudioVideoTracks',
backgroundThrottling: false,
nodeIntegrationInWorker: true,
preload: path.join(__dirname, '/preload.js')
},
icon: path.join(__dirname, '/renderer/public/logo.ico'),

View file

@ -1,204 +0,0 @@
const { app, ipcMain } = require('electron')
const WebTorrent = require('webtorrent')
const http = require('http')
const pump = require('pump')
const rangeParser = require('range-parser')
const mime = require('mime')
const { SubtitleParser, SubtitleStream } = require('matroska-subtitles')
let window = null
app.on('browser-window-created', (event, data) => {
window = data
window.on('closed', () => {
window = null
})
})
let client = null
let settings = {}
const server = http.createServer((request, response) => {
if (!request.url) return null
let [infoHash, ...filePath] = request.url.slice(request.url.indexOf('/webtorrent/') + 12).split('/')
filePath = decodeURI(filePath.join('/'))
if (!infoHash || !filePath) return null
const file = client?.get(infoHash)?.files.find(file => file.path === filePath)
if (!file) return null
response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Content-Type', mime.getType(file.name) || 'application/octet-stream')
response.setHeader('Accept-Ranges', 'bytes')
let range = rangeParser(file.length, request.headers.range || '')
if (Array.isArray(range)) {
response.statusCode = 206
range = range[0]
response.setHeader(
'Content-Range',
`bytes ${range.start}-${range.end}/${file.length}`
)
response.setHeader('Content-Length', range.end - range.start + 1)
} else {
response.statusCode = 200
range = null
response.setHeader('Content-Length', file.length)
}
if (response.method === 'HEAD') {
return response.end()
}
let stream = file.createReadStream(range)
if (stream && !parsed) {
if (file.name.endsWith('.mkv')) {
parserInstance = new SubtitleStream(parserInstance)
handleSubtitleParser(parserInstance, true)
stream = pump(stream, parserInstance)
}
}
pump(stream, response)
})
server.on('error', console.log)
server.listen(41785)
let current = null
let parsed = false
let parserInstance = null
function parseSubtitles () {
if (current.name.endsWith('.mkv')) {
const parser = new SubtitleParser()
handleSubtitleParser(parser, true)
const finish = () => {
console.log('Sub parsing finished')
parsed = true
fileStream?.destroy()
stream?.destroy()
stream = undefined
}
parser.once('tracks', tracks => {
if (!tracks.length) finish()
})
parser.once('finish', finish)
console.log('Sub parsing started')
const fileStream = current.createReadStream()
let stream = fileStream.pipe(parser)
}
}
function parseFonts (file) {
const stream = new SubtitleParser()
handleSubtitleParser(stream)
stream.once('tracks', tracks => {
if (!tracks.length) {
parsed = true
stream.destroy()
fileStreamStream.destroy()
}
})
stream.once('subtitle', () => {
fileStreamStream.destroy()
stream.destroy()
window?.webContents.send('fonts')
})
const fileStreamStream = file.createReadStream()
fileStreamStream.pipe(stream)
}
function handleSubtitleParser (parser, skipFile) {
parser.once('tracks', tracks => {
if (!tracks.length) {
parsed = true
parser?.destroy()
} else {
window?.webContents.send('tracks', tracks)
}
})
parser.on('subtitle', (subtitle, trackNumber) => {
window?.webContents.send('subtitle', { subtitle, trackNumber })
})
if (!skipFile) {
parser.on('file', file => {
if (file.mimetype === 'application/x-truetype-font' || file.mimetype === 'application/font-woff' || file.mimetype === 'application/vnd.ms-opentype') {
window?.webContents.send('file', { mimetype: file.mimetype, data: Array.from(file.data) })
}
})
}
}
ipcMain.on('current', (event, data) => {
current?.removeListener('done', parseSubtitles)
parserInstance?.destroy()
parserInstance = null
current = null
parsed = false
if (data) {
current = client?.get(data.infoHash)?.files.find(file => file.path === data.path)
if (current.name.endsWith('.mkv')) {
if (current.done) parseSubtitles()
current.on('done', parseSubtitles)
parseFonts(current)
}
// findSubtitleFiles(current) TODO:
}
})
ipcMain.on('settings', (event, data) => {
if (!client) {
settings = data
client = new WebTorrent({
dht: !settings.torrentDHT,
downloadLimit: settings.torrentSpeed * 1048576 || 0,
uploadLimit: settings.torrentSpeed * 1572864 || 0 // :trolled:
})
setInterval(() => {
window?.webContents?.send('stats', {
numPeers: (client?.torrents.length && client?.torrents[0].numPeers) || 0,
uploadSpeed: (client?.torrents.length && client?.torrents[0].uploadSpeed) || 0,
downloadSpeed: (client?.torrents.length && client?.torrents[0].downloadSpeed) || 0
})
}, 200)
setInterval(() => {
if (client?.torrents[0]?.pieces) window?.webContents?.send('pieces', [...client?.torrents[0]?.pieces.map(piece => piece === null ? 77 : 33)])
}, 2000)
client.on('torrent', torrent => {
const files = torrent.files.map(file => {
return {
infoHash: torrent.infoHash,
name: file.name,
type: file._getMimeType(),
size: file.size,
path: file.path,
url: encodeURI('http://localhost:41785/webtorrent/' + torrent.infoHash + '/' + file.path)
}
})
window?.webContents.send('files', files)
window?.webContents.send('pieces', torrent.pieces.length)
window?.webContents.send('torrent', Array.from(torrent.torrentFile))
})
}
})
ipcMain.on('torrent', (event, data) => {
if (client.torrents.length) client.remove(client.torrents[0].infoHash)
if (typeof data !== 'string') data = Buffer.from(data)
client.add(data, {
private: settings.torrentPeX,
path: settings.torrentPath,
destroyStoreOnDestroy: !settings.torrentPersist,
announce: [
'wss://tracker.openwebtorrent.com',
'wss://spacetradersapi-chatbox.herokuapp.com:443/announce',
'wss://peertube.cpy.re:443/tracker/socket'
]
})
})

View file

@ -1,82 +0,0 @@
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('fetch', event => {
const res = proxyResponse(event)
if (res) event.respondWith(res)
})
self.addEventListener('activate', evt => {
evt.waitUntil(self.clients.claim())
})
const portTimeoutDuration = 5000
function proxyResponse (event) {
const { url } = event.request
if (!(url.includes(self.registration.scope) && url.includes('/webtorrent/')) || url.includes('?')) return null
if (url.includes(self.registration.scope) && url.includes('/webtorrent/keepalive/')) return new Response()
return serve(event)
}
async function serve ({ request }) {
const { url, method, headers, destination } = request
const clientlist = await clients.matchAll({ type: 'window', includeUncontrolled: true })
const [data, port] = await new Promise(resolve => {
// Use race condition for whoever controls the response stream
for (const client of clientlist) {
const messageChannel = new MessageChannel()
const { port1, port2 } = messageChannel
port1.onmessage = ({ data }) => {
resolve([data, port1])
}
client.postMessage({
url,
method,
headers: Object.fromEntries(headers.entries()),
scope: self.registration.scope,
destination,
type: 'webtorrent'
}, [port2])
}
})
if (data.body !== 'STREAM' && data.body !== 'DOWNLOAD') return new Response(data.body, data)
let timeOut = null
return new Response(new ReadableStream({
pull (controller) {
return new Promise(resolve => {
port.onmessage = ({ data }) => {
if (data) {
controller.enqueue(data) // event.data is Uint8Array
} else {
clearTimeout(timeOut)
controller.close() // event.data is null, means the stream ended
port.onmessage = null
}
resolve()
}
// 'media player' does NOT signal a close on the stream and we cannot close it because it's locked to the reader,
// so we just empty it after 5s of inactivity, the browser will request another port anyways
clearTimeout(timeOut)
if (data.body === 'STREAM') {
timeOut = setTimeout(() => {
controller.close()
port.postMessage(false) // send timeout
port.onmessage = null
resolve()
}, portTimeoutDuration)
}
port.postMessage(true) // send a pull request
})
},
cancel () {
// This event is never executed
port.postMessage(false) // send a cancel request
}
}), data)
}

View file

@ -256,9 +256,6 @@
</div>
<style>
.trailer {
padding-bottom: 56.25%;
}
.close {
top: 1rem !important;
left: unset;

View file

@ -64,7 +64,7 @@
window.IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=4254&response_type=token')
if (platformMap[window.version.platform] === 'Linux') {
addToast({
text: 'If your linux distribution doesn\'t support custom protocol handlers, you can simply paste the full URL into the app.',
text: "If your linux distribution doesn't support custom protocol handlers, you can simply paste the full URL into the app.",
title: 'Support Notification',
type: 'secondary',
duration: '300000'
@ -78,8 +78,10 @@
]
if (alID) {
alID.then(result => {
links[links.length - 1].image = result.data.Viewer.avatar.medium
links[links.length - 1].text = result.data.Viewer.name
if (result?.data?.Viewer) {
links[links.length - 1].image = result.data.Viewer.avatar.medium
links[links.length - 1].text = result.data.Viewer.name + '\nLogout'
}
})
}
</script>
@ -159,6 +161,9 @@
padding: 0.75rem 1.5rem;
height: 5.5rem;
}
.sidebar-link::after {
white-space: pre !important;
}
.material-icons {
font-size: 2.2rem;

View file

@ -3,6 +3,7 @@
import { playAnime } from '../RSSView.svelte'
import { title } from '../Menubar.svelte'
import { onMount } from 'svelte'
import { client } from '@/modules/torrent.js'
export let media = null
let fileMedia = null
let hadImage = false
@ -222,7 +223,7 @@
current = file
initSubs()
src = file.url
window.IPC.emit('current', file)
client.send('current', file)
video?.load()
checkAvail(current)
clearCanvas()
@ -739,11 +740,11 @@
}
}
const torrent = {}
window.IPC.on('stats', updateStats)
function updateStats(stats) {
torrent.peers = stats.numPeers || 0
torrent.up = stats.uploadSpeed || 0
torrent.down = stats.downloadSpeed || 0
client.on('stats', updateStats)
function updateStats({ detail }) {
torrent.peers = detail.numPeers || 0
torrent.up = detail.uploadSpeed || 0
torrent.down = detail.downloadSpeed || 0
}
let bufferCanvas = null
let ctx = null
@ -757,23 +758,23 @@
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'
}
let imageData = null
function handlePieces(data) {
if (data.constructor === Array) {
function handlePieces({ detail }) {
if (detail.constructor === Array) {
if (imageData) {
for (let i = 0; i < data.length; ++i) {
imageData.data[i * 4 + 3] = data[i]
for (let i = 0; i < detail.length; ++i) {
imageData.data[i * 4 + 3] = detail[i]
}
ctx.putImageData(imageData, 0, 0)
}
} else {
const uint32 = new Uint32Array(data)
const uint32 = new Uint32Array(detail)
uint32.fill(872415231) // rgba(255, 255, 255, 0.2) to HEX to DEC
imageData = new ImageData(new Uint8ClampedArray(uint32.buffer), data, 1)
bufferCanvas.width = data
imageData = new ImageData(new Uint8ClampedArray(uint32.buffer), detail, 1)
bufferCanvas.width = detail
clearCanvas()
}
}
window.IPC.on('pieces', handlePieces)
client.on('pieces', handlePieces)
</script>
<svelte:window on:keydown={handleKeydown} bind:innerWidth bind:innerHeight />

View file

@ -7,6 +7,8 @@
import { addToast } from '@/lib/Toasts.svelte'
import 'browser-event-target-emitter'
import { client } from '@/modules/torrent.js'
export const w2gEmitter = new EventTarget()
const state = writable(null)
@ -61,11 +63,13 @@
if (setPlayerState(detail)) emit('player', detail)
})
window.IPC.on('torrent', file => {
if (!file.every((v, i) => v === playerState.file[i])) {
playerState.file = file
emit('torrent', { file })
}
queueMicrotask(() => {
client.on('torrent', ({ detail }) => {
if (!detail.every((v, i) => v === playerState.file[i])) {
playerState.file = detail
emit('torrent', { file: detail })
}
})
})
function emit(type, data) {

View file

@ -264,7 +264,7 @@ query {
}`
break
} case 'UserLists': {
variables.id = (await alID).data.Viewer.id
variables.id = (await alID)?.data?.Viewer?.id
query = `
query ($page: Int, $perPage: Int, $id: Int, $type: MediaType, $status_in: [MediaListStatus]){
Page (page: $page, perPage: $perPage) {
@ -280,7 +280,7 @@ query ($page: Int, $perPage: Int, $id: Int, $type: MediaType, $status_in: [Media
}`
break
} case 'SearchIDStatus': {
variables.id = (await alID).data.Viewer.id
variables.id = (await alID)?.data?.Viewer?.id
variables.mediaId = opts.id
query = `
query ($id: Int, $mediaId: Int){

View file

@ -2,6 +2,8 @@
import SubtitlesOctopus from './subtitles-octopus.js'
import { toTS, videoRx, subRx } from './util.js'
import { client } from '@/modules/torrent.js'
const defaultHeader = `[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default, Roboto Medium,26,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,0,0,0,0,100,100,0,0,1,1.3,0,2,20,20,23,1
@ -25,15 +27,15 @@ export default class Subtitles {
this.videoFiles = files.filter(file => videoRx.test(file.name))
this.subtitleFiles = []
this.timeout = null
window.IPC.on('tracks', (...args) => this.handleTracks(...args))
window.IPC.on('subtitle', (...args) => this.handleSubtitle(...args))
window.IPC.on('fonts', (...args) => this.handleFonts(...args))
window.IPC.on('file', (...args) => this.handleFile(...args))
client.on('tracks', this.handleTracks.bind(this))
client.on('subtitle', this.handleSubtitle.bind(this))
client.on('fonts', this.handleFonts.bind(this))
client.on('file', this.handleFile.bind(this))
}
handleFile (file) {
handleFile ({ detail }) {
if (this.selected) {
this.fonts.push(URL.createObjectURL(new Blob([file.data], { type: file.mimetype })))
this.fonts.push(URL.createObjectURL(new Blob([detail.data], { type: detail.mimetype })))
}
}
@ -46,7 +48,8 @@ export default class Subtitles {
}
}
handleSubtitle ({ subtitle, trackNumber }) {
handleSubtitle ({ detail }) {
const { subtitle, trackNumber } = detail
if (this.selected) {
if (!this.renderer) this.initSubtitleRenderer()
this.tracks[trackNumber].add(this.constructor.constructSub(subtitle, this.headers[trackNumber].type !== 'ass'))
@ -54,9 +57,9 @@ export default class Subtitles {
}
}
handleTracks (tracks) {
handleTracks ({ detail }) {
if (this.selected) {
for (const track of tracks) {
for (const track of detail) {
if (!this.tracks[track.number]) {
// overwrite webvtt or other header with custom one
if (track.type !== 'ass') track.header = defaultHeader
@ -242,6 +245,10 @@ export default class Subtitles {
}
destroy () {
client.removeListener('tracks', this.handleTracks.bind(this))
client.removeListener('subtitle', this.handleSubtitle.bind(this))
client.removeListener('fonts', this.handleFonts.bind(this))
client.removeListener('file', this.handleFile.bind(this))
this.stream?.destroy()
this.parser?.destroy()
this.renderer?.destroy()

View file

@ -3,12 +3,27 @@ import { set } from '@/lib/pages/Settings.svelte'
import { files } from '@/lib/Router.svelte'
import { page } from '@/App.svelte'
export const client = null
class TorrentWorker extends Worker {
constructor (opts) {
super(opts)
this.onmessage = this.handleMessage.bind(this)
}
window.IPC.emit('settings', { ...set })
handleMessage ({ data }) {
this.emit(data.type, data.data)
}
window.IPC.on('files', arr => {
files.set(arr)
send (type, data) {
this.postMessage({ type, data })
}
}
export const client = new TorrentWorker(new URL('./torrentworker.js', import.meta.url))
client.send('settings', { ...set })
client.on('files', ({ detail }) => {
files.set(detail)
})
export async function add (torrentID, hide) {
@ -20,17 +35,17 @@ export async function add (torrentID, hide) {
const res = await fetch(torrentID)
torrentID = Array.from(new Uint8Array(await res.arrayBuffer()))
}
window.IPC.emit('torrent', torrentID)
client.send('torrent', torrentID)
}
}
window.IPC.on('torrent', file => {
localStorage.setItem('torrent', JSON.stringify(file))
client.on('torrent', ({ detail }) => {
localStorage.setItem('torrent', JSON.stringify(detail))
})
// load last used torrent
queueMicrotask(() => {
if (localStorage.getItem('torrent')) {
window.IPC.emit('torrent', JSON.parse(localStorage.getItem('torrent')))
client.send('torrent', JSON.parse(localStorage.getItem('torrent')))
}
})

View file

@ -0,0 +1,216 @@
const WebTorrent = require('webtorrent')
const http = require('http')
const pump = require('pump')
const rangeParser = require('range-parser')
const mime = require('mime')
const { SubtitleParser, SubtitleStream } = require('matroska-subtitles')
class TorrentClient extends WebTorrent {
constructor (settings) {
super({
dht: !settings.torrentDHT,
downloadLimit: settings.torrentSpeed * 1048576 || 0,
uploadLimit: settings.torrentSpeed * 1572864 || 0 // :trolled:
})
this.settings = settings
this.current = null
this.parsed = false
this.parserInstance = null
setInterval(() => {
this.dispatch('stats', {
numPeers: (this.torrents.length && this.torrents[0].numPeers) || 0,
uploadSpeed: (this.torrents.length && this.torrents[0].uploadSpeed) || 0,
downloadSpeed: (this.torrents.length && this.torrents[0].downloadSpeed) || 0
})
}, 200)
setInterval(() => {
if (this.torrents[0]?.pieces) this.dispatch('pieces', [...this.torrents[0]?.pieces.map(piece => piece === null ? 77 : 33)])
}, 2000)
this.on('torrent', torrent => {
const files = torrent.files.map(file => {
return {
infoHash: torrent.infoHash,
name: file.name,
type: file._getMimeType(),
size: file.size,
path: file.path,
url: encodeURI('http://localhost:41785/webtorrent/' + torrent.infoHash + '/' + file.path)
}
})
this.dispatch('files', files)
this.dispatch('pieces', torrent.pieces.length)
this.dispatch('torrent', Array.from(torrent.torrentFile))
})
this.server = http.createServer((request, response) => {
if (!request.url) return null
let [infoHash, ...filePath] = request.url.slice(request.url.indexOf('/webtorrent/') + 12).split('/')
filePath = decodeURI(filePath.join('/'))
if (!infoHash || !filePath) return null
const file = this.get(infoHash)?.files.find(file => file.path === filePath)
if (!file) return null
response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Content-Type', mime.getType(file.name) || 'application/octet-stream')
response.setHeader('Accept-Ranges', 'bytes')
let range = rangeParser(file.length, request.headers.range || '')
if (Array.isArray(range)) {
response.statusCode = 206
range = range[0]
response.setHeader(
'Content-Range',
`bytes ${range.start}-${range.end}/${file.length}`
)
response.setHeader('Content-Length', range.end - range.start + 1)
} else {
response.statusCode = 200
range = null
response.setHeader('Content-Length', file.length)
}
if (response.method === 'HEAD') {
return response.end()
}
let stream = file.createReadStream(range)
if (stream && !this.parsed) {
if (file.name.endsWith('.mkv')) {
this.parserInstance = new SubtitleStream(this.parserInstance)
this.handleSubtitleParser(this.parserInstance, true)
stream = pump(stream, this.parserInstance)
}
}
pump(stream, response)
})
this.server.on('error', console.log)
this.server.listen(41785)
onmessage = this.handleMessage.bind(this)
}
handleMessage ({ data }) {
switch (data.type) {
case 'current': {
this.current?.removeListener('done', this.parseSubtitles.bind(this))
this.parserInstance?.destroy()
this.parserInstance = null
this.current = null
this.parsed = false
if (data) {
this.current = this?.get(data.data.infoHash)?.files.find(file => file.path === data.data.path)
if (this.current.name.endsWith('.mkv')) {
if (this.current.done) this.parseSubtitles()
this.current.on('done', this.parseSubtitles.bind(this))
this.parseFonts(this.current)
}
// findSubtitleFiles(current) TODO:
}
break
}
case 'torrent': {
if (this.torrents.length) this.remove(client.torrents[0].infoHash)
const id = typeof data.data !== 'string' ? Buffer.from(data.data) : data.data
this.add(id, {
private: this.settings.torrentPeX,
path: this.settings.torrentPath,
destroyStoreOnDestroy: !this.settings.torrentPersist,
announce: [
'wss://tracker.openwebtorrent.com',
'wss://spacetradersapi-chatbox.herokuapp.com:443/announce',
'wss://peertube.cpy.re:443/tracker/socket'
]
})
break
}
}
}
dispatch (type, data) {
postMessage({ type, data })
}
parseSubtitles () {
if (this.current.name.endsWith('.mkv')) {
const parser = new SubtitleParser()
this.handleSubtitleParser(parser, true)
const finish = () => {
console.log('Sub parsing finished')
this.parsed = true
fileStream?.destroy()
stream?.destroy()
stream = undefined
}
parser.once('tracks', tracks => {
if (!tracks.length) finish()
})
parser.once('finish', finish)
console.log('Sub parsing started')
const fileStream = this.current.createReadStream()
let stream = fileStream.pipe(parser)
}
}
parseFonts (file) {
const stream = new SubtitleParser()
this.handleSubtitleParser(stream)
stream.once('tracks', tracks => {
if (!tracks.length) {
this.parsed = true
stream.destroy()
fileStreamStream.destroy()
}
})
stream.once('subtitle', () => {
fileStreamStream.destroy()
stream.destroy()
this.dispatch('fonts')
})
const fileStreamStream = file.createReadStream()
fileStreamStream.pipe(stream)
}
handleSubtitleParser (parser, skipFile) {
parser.once('tracks', tracks => {
if (!tracks.length) {
this.parsed = true
parser?.destroy()
} else {
this.dispatch('tracks', tracks)
}
})
parser.on('subtitle', (subtitle, trackNumber) => {
this.dispatch('subtitle', { subtitle, trackNumber })
})
if (!skipFile) {
parser.on('file', file => {
if (file.mimetype === 'application/x-truetype-font' || file.mimetype === 'application/font-woff' || file.mimetype === 'application/vnd.ms-opentype') {
this.dispatch('file', { mimetype: file.mimetype, data: Array.from(file.data) })
}
})
}
}
predestroy () {
this.destroy()
this.server.close()
}
}
let client = null
onmessage = ({ data }) => {
if (!client && data.type === 'settings') client = new TorrentClient(data.data)
if (data.type === 'destroy') client?.predestroy()
}