├── .npmignore ├── .git-blame-ignore-revs ├── .gitignore ├── client ├── plotly.d.ts ├── tsconfig.json ├── common-client-plugin.ts ├── video-edit-client-plugin.ts └── video-watch-client-plugin.ts ├── assets └── style.css ├── server ├── tsconfig.json └── main.ts ├── languages ├── de.json └── ru.json ├── public └── images │ ├── webmon_icon.svg │ ├── wm-icon.svg │ └── wm-icon-grey.svg ├── webpack.config.js ├── LICENSE-MIT ├── shared ├── api.ts ├── common.ts └── paid.ts ├── CHANGELOG.md ├── README.md ├── package.json └── LICENSE-APACHE /.npmignore: -------------------------------------------------------------------------------- 1 | # Emacs files 2 | *~ 3 | \#*# 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # format 2 | 444e2a09068dc0d1696cceb2df83576a63c4e7a2 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | 5 | # Emacs backup 6 | *~ 7 | # Emacs autosave 8 | \#*# -------------------------------------------------------------------------------- /client/plotly.d.ts: -------------------------------------------------------------------------------- 1 | declare module "plotly.js-finance-dist-min" { 2 | export * from 'plotly.js' 3 | } 4 | -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | .video-miniature-name { 2 | /* price labels may wrap to second line 3 | 3em is good for 2 lines, 4em for 3 lines 4 | */ 5 | max-height: 4em !important 6 | } 7 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["dom"], 5 | "outDir": "../dist/", 6 | "target": "es6" // same as PeerTube 7 | }, 8 | "include": [ 9 | "./**/*", 10 | "../shared/**/*" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /languages/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "Web Monetization payment pointer": "Web Monetization Payment Pointer", 3 | "Interledger payment pointer for Web Monetization. In the form of $example.org/account.": "Interledger Payment Pointer für Web Monetization. In Form von $example.org/account.", 4 | "Invalid payment pointer format.": "Ungültiges Payment Pointer-Format." 5 | } -------------------------------------------------------------------------------- /languages/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "Web Monetization payment pointer": "Указатель платежей Web Monetization", 3 | "Interledger payment pointer for Web Monetization. In the form of $example.org/account.": "Значение указателя платежей для Web Monetization. В формате $example.org/account.", 4 | "Invalid payment pointer format.": "Неверный формат указателя платежа." 5 | } -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["dom"], 5 | "module": "es6", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "outDir": "../dist/", 9 | "moduleResolution": "node", 10 | "target": "es6", // same as PeerTube 11 | "paths": { 12 | "shared/*": ["../shared/*"] 13 | } 14 | }, 15 | "include": [ 16 | "./**/*", 17 | "../shared/**/*" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /public/images/webmon_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/wm-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/wm-icon-grey.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const EsmWebpackPlugin = require('@purtuga/esm-webpack-plugin') 4 | 5 | const clientFiles = [ 6 | 'video-edit-client-plugin', 7 | 'video-watch-client-plugin', 8 | 'common-client-plugin' 9 | ] 10 | 11 | const config = clientFiles.map(f => ({ 12 | // mode: 'production', 13 | devtool: process.env.NODE_ENV === 'dev' ? 'eval-source-map' : false, 14 | entry: './client/' + f + '.ts', 15 | output: { 16 | path: path.resolve(__dirname, './dist/client'), 17 | filename: './' + f + '.js', 18 | library: 'script', 19 | libraryTarget: 'var' 20 | }, 21 | plugins: [new EsmWebpackPlugin()], 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts$/, 26 | use: 'ts-loader' 27 | } 28 | ] 29 | }, 30 | resolve: { 31 | alias: { 32 | shared: path.resolve(__dirname, 'shared/') 33 | }, 34 | extensions: ['.ts'] 35 | } 36 | })) 37 | 38 | module.exports = config 39 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /shared/api.ts: -------------------------------------------------------------------------------- 1 | import { SerializedReceipts, SerializedHistogramBinUncommitted, SerializedChanges, SerializedAmount } from '../shared/paid' 2 | 3 | export type Histogram = { 4 | parts: number[], 5 | history: Record 6 | } 7 | 8 | export type HistogramUpdate = { 9 | receipts: any, 10 | histogram: any, 11 | subscribed: boolean, 12 | } 13 | 14 | export type StatsHistogramGet = { 15 | histogram: Histogram, 16 | } 17 | 18 | export type StatsHistogramUpdatePost = { 19 | receipts: SerializedReceipts, 20 | histogram: SerializedHistogramBinUncommitted[], 21 | subscribed: boolean, 22 | } 23 | 24 | export type StatsViewPost = { 25 | receipts: SerializedReceipts, 26 | changes: SerializedChanges, 27 | subscribed: boolean, 28 | } 29 | 30 | export type MonetizationStatusBulkPost = { 31 | videos: string[], 32 | } 33 | 34 | export type MonetizationStatusBulkStatus = { 35 | monetization: 'unmonetized' | 'monetized' | 'ad-skip' | 'pay-wall' | 'unknown', 36 | currency?: string, 37 | viewCost?: number, 38 | duration?: number, 39 | paid?: SerializedAmount | null, 40 | } 41 | export type MonetizationStatusBulkPostRes = { 42 | statuses: Record, 43 | } 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.1 --- Patch 2 | 3 | - Display per-channel contributions pie chart 4 | - Histograms initialized when not logged in are done properly now 5 | - Video currency defaults to USD if not set 6 | 7 | # 1.0.0 --- Stable release 8 | 9 | - Add receipt verification to check payments server-side 10 | - Add stats tracking and viewing: payments made during each 15 seconds of the video are shown as a 11 | histogram, revenue per day is tracked by subscriber/non-subscriber. User payments to channels 12 | is tracked, but not currently shown as we cannot retrieve the channel name. 13 | - Currency conversion: Costs now specified in any currency, and stats are shown in any currency. 14 | It is assumed that all payments are converted to the currency specified for the video upon receipt for 15 | purposes of stats tracking. 16 | - Opt-out for stats tracking (still records payments made to prevent double payments, although maybe 17 | this could also be disabled with a more fine-grained option) 18 | - Monetization status is shown when video title/thumbnail is visible. Basic icon for monetized, 19 | icon with circle for ad skips, and icon with circle followed by cost for mandatory payment. 20 | 21 | 22 | # 0.0.2 --- Initial release 23 | 24 | - Add payment pointer field to videos 25 | - Use payment pointer field to direction Web Monetization payments during playback 26 | -------------------------------------------------------------------------------- /shared/common.ts: -------------------------------------------------------------------------------- 1 | const version = '1.0.6' 2 | 3 | export interface StoreKey { 4 | k: string 5 | } 6 | export interface StoreObjectKey { 7 | k: string, 8 | validate: (x: object) => T | null 9 | } 10 | 11 | export function paymentPointerStore(videoId: string): StoreKey { 12 | return { k: paymentPointerField + '_v-' + videoId } 13 | } 14 | export function receiptServiceStore(videoId: string): StoreKey { 15 | return { k: receiptServiceField + '_v-' + videoId } 16 | } 17 | export function currencyStore(videoId: string): StoreKey { 18 | return { k: currencyField + '_v-' + videoId } 19 | } 20 | export function viewCostStore(videoId: string): StoreKey { 21 | return { k: viewCostField + '_v-' + videoId } 22 | } 23 | export function adSkipCostStore(videoId: string): StoreKey { 24 | return { k: adSkipCostField + '_v-' + videoId } 25 | } 26 | 27 | const paymentPointerField = 'web-monetization-payment-pointer' 28 | const receiptServiceField = 'web-monetization-receipt-service' 29 | const currencyField = 'web-monetization-currency' 30 | const viewCostField = 'web-monetization-view-cost' 31 | const adSkipCostField = 'web-monetization-ad-skip-cost' 32 | 33 | function hms (duration: number | null) { 34 | if (duration == null || isNaN(duration)) { 35 | return '' + duration 36 | } 37 | var s = duration % 60 38 | const m = Math.round((duration - s) / 60 % 60) 39 | const h = Math.round((duration - 60 * m - s) / 3600) 40 | // Only round if it's too long to avoid floating point precision issues 41 | if (6 < ('' + s).length) { 42 | s = Math.round(s * 1000) / 1000 43 | } 44 | 45 | if (h != 0) { 46 | return h + 'h' + m + 'm' + s + 's' 47 | } 48 | if (m != 0) { 49 | return m + 'm' + s + 's' 50 | } 51 | return s + 's' 52 | } 53 | 54 | export { 55 | version, 56 | paymentPointerField, 57 | receiptServiceField, 58 | currencyField, 59 | viewCostField, 60 | adSkipCostField, 61 | hms} 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PeerTube Web Monetization Plugin 2 | 3 | Web Monetization makes it easy for viewers to support creators through anonymous micropayments. 4 | 5 | Viewers can sign up for a service such as [Coil](https://coil.com/) and either install their extension or use their Puma Browser (Coil is currently the only provider). Then, when viewing a supported video, payments will be made while the video is playing. 6 | 7 | Creators can monetize their content by using a PeerTube instance with the Web Monetization plugin installed, and adding their Interledger payment pointer. 8 | A payment pointer provides a way for funds to be deposited, and a supported wallet can be created using [GateHub](https://gatehub.net/) or [Uphold](https://uphold.com). The payment pointer is added under the "Plugin Settings" tab in the video editing interface. You can also set a minimum pay rate to view. 9 | 10 | ![Editing plugin settings on a video](https://milesdewitt.com/peertube-web-monetization/video-edit3.png) 11 | 12 | The monetization status of videos will then show when viewing their thumbnails. 13 | 14 | ![Badges on video titles](https://milesdewitt.com/peertube-web-monetization/badges.png) 15 | 16 | Creators can specify the location of sponsors segments using the [PeerTube chapters plugin](https://milesdewitt.com/peertube-chapters), and those who pay with Web Monetization will automatically skip those sponsor segments. You can also set a minimum pay rate for ad-skipping as seen above. 17 | 18 | ![Chapter menu including sponsor segments](https://milesdewitt.com/peertube-chapters/chapters-menu.png) 19 | 20 | Segments of the video which have already been paid for are remembered and will not receive double-payment. Users can store this data when logged-in to not pay multiple times. General contribution data is made available in the stats tray below the video. However, no data sharing is necessary. 21 | 22 | ![Stats tray](https://milesdewitt.com/peertube-web-monetization/stats.png) 23 | 24 | ## Contributing 25 | 26 | Code is run through `npx standard-format `. Some of the changes it makes are wrong, but at least it's consistent. 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peertube-plugin-web-monetization", 3 | "description": "Web Monetization PeerTube plugin", 4 | "license": "(MIT OR Apache-2.0)", 5 | "version": "1.0.6", 6 | "author": "Miles DeWitt", 7 | "homepage": "https://milesdewitt.com/peertube-web-monetization", 8 | "repository": "https://github.com/samlich/peertube-plugin-web-monetization", 9 | "bugs": "https://github.com/samlich/peertube-plugin-web-monetization", 10 | "engine": { 11 | "peertube": ">=3.2.0" 12 | }, 13 | "clientScripts": [ 14 | { 15 | "script": "dist/client/video-watch-client-plugin.js", 16 | "scopes": [ 17 | "video-watch", 18 | "embed" 19 | ] 20 | }, 21 | { 22 | "script": "dist/client/video-edit-client-plugin.js", 23 | "scopes": [ 24 | "video-edit" 25 | ] 26 | }, 27 | { 28 | "script": "dist/client/common-client-plugin.js", 29 | "scopes": [ 30 | "common" 31 | ] 32 | } 33 | ], 34 | "css": [ 35 | "assets/style.css" 36 | ], 37 | "dependencies": { 38 | "interval-promise": "^1.4", 39 | "plotly.js-finance-dist-min": "^2.12", 40 | "short-uuid": "^4.2" 41 | }, 42 | "devDependencies": { 43 | "@peertube/peertube-types": "^4.2", 44 | "@types/express": "4.17", 45 | "@types/plotly.js": "^1.54", 46 | "video.js": "^7.19", 47 | "@types/video.js": "^7.3", 48 | "@webmonetization/types": "^0.0.0", 49 | "@tsconfig/node12": "^1.0", 50 | "@purtuga/esm-webpack-plugin": "^1.0", 51 | "transform-loader": "^0.2.4", 52 | "ify-loader": "^1.1", 53 | "typescript": "^4.3", 54 | "ts-loader": "^8.3", 55 | "webpack": "^4.0", 56 | "webpack-cli": "^4.0", 57 | "standard": "^14.0", 58 | "npm-run-all": "^4.1" 59 | }, 60 | "keywords": [ 61 | "peertube", 62 | "plugin" 63 | ], 64 | "library": "./dist/server/main.js", 65 | "scripts": { 66 | "clean:light": "rm -rf dist/*", 67 | "prepare": "npm run build", 68 | "build:webpack": "webpack --mode production", 69 | "build:server": "npx -p typescript tsc --build server/tsconfig.json", 70 | "build": "npm-run-all -s clean:light -p build:webpack build:server", 71 | "test": "standard" 72 | }, 73 | "staticDirs": { 74 | "images": "public/images" 75 | }, 76 | "translations": { 77 | "de-DE": "./languages/de.json" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /client/common-client-plugin.ts: -------------------------------------------------------------------------------- 1 | import { RegisterClientHelpers, RegisterClientOptions } from '@peertube/peertube-types/client' 2 | import interval from 'interval-promise' 3 | import { Amount, Exchange, quoteCurrencies } from 'shared/paid' 4 | import { MonetizationStatusBulkPost, MonetizationStatusBulkPostRes, MonetizationStatusBulkStatus } from 'shared/api' 5 | 6 | var ptHelpers: RegisterClientHelpers | null = null 7 | var exchange = new Exchange() 8 | var displayCurrency = quoteCurrencies['usd'] 9 | 10 | export function register ({ peertubeHelpers }: RegisterClientOptions) { 11 | ptHelpers = peertubeHelpers 12 | interval(async () => await populateBadges(), 2500, { stopOnError: false }) 13 | } 14 | 15 | var monetizationStatus: Record = {} 16 | 17 | async function populateBadges (recurse: boolean = false): Promise { 18 | if (ptHelpers == null) { 19 | console.error("`populateBadges` without `peertubeHelpers`") 20 | return 21 | } 22 | 23 | const names = document.getElementsByClassName('video-miniature-name') 24 | 25 | var fetchMonetizationStatus: string[] = [] 26 | 27 | for (var i = 0; i < names.length; i++) { 28 | if (names[i].classList.contains('web-monetization-badge-checked')) { 29 | continue 30 | } 31 | // Price labels may wrap to second line 32 | // names[i].setAttribute('style', 'maxHeight:3emw') 33 | var link 34 | // older versions use ``, newer versions user `` with an `` child 35 | if (names[i].tagName.toLowerCase() == 'a') { 36 | link = names[i] 37 | } else { 38 | const children = names[i].getElementsByTagName('a') 39 | if (children.length != 0) { 40 | link = children[0] 41 | } else { 42 | continue 43 | } 44 | } 45 | 46 | const dest = link.getAttribute('href') 47 | if (dest == null) { 48 | continue 49 | } 50 | const videoUuid = dest.substring(dest.lastIndexOf('/') + 1) 51 | const status = monetizationStatus[videoUuid] 52 | if (status == null) { 53 | fetchMonetizationStatus.push(videoUuid) 54 | continue 55 | } 56 | 57 | if (status.monetization == 'monetized' || status.monetization == 'ad-skip' || status.monetization == 'pay-wall') { 58 | var badge = document.createElement('img') 59 | badge.setAttribute('style', 'padding-left:0.5em;height:1.5em;') 60 | if (status.monetization == 'monetized') { 61 | badge.src = ptHelpers.getBaseStaticRoute() + '/images/wm-icon.svg' 62 | badge.title = 'Monetized' 63 | } 64 | if (status.monetization == 'ad-skip') { 65 | badge.src = ptHelpers.getBaseStaticRoute() + '/images/webmon_icon.svg' 66 | badge.title = 'Monetized (ad-skip)' 67 | } 68 | if (status.monetization == 'pay-wall') { 69 | badge.src = ptHelpers.getBaseStaticRoute() + '/images/webmon_icon.svg' 70 | badge.title = 'Pay-wall' 71 | } 72 | link.append(badge) 73 | } 74 | 75 | if (status.monetization == 'pay-wall') { 76 | var costTag = document.createElement('span') 77 | costTag.setAttribute('style', 'padding-left:0.5em;height:1.5em;font-size:0.95em;') 78 | 79 | if (status.viewCost != null && status.duration != null && status.currency != null ) { 80 | var costAmount = new Amount(true) 81 | // 600 to convert from per 10 min to per second 82 | var significand = status.viewCost / 600 * status.duration 83 | var exponent = 0 84 | while (significand * 0.001 < Math.abs(significand - (significand >> 0))) { 85 | significand *= 10 86 | exponent -= 1 87 | } 88 | significand >>= 0 89 | costAmount.depositUnchecked(significand, exponent, status.currency, true, null) 90 | 91 | var paidConverted = null 92 | if (status.paid != null) { 93 | try { 94 | const paid = Amount.deserialize(status.paid) 95 | paidConverted = await paid.inCurrency(exchange, quoteCurrencies[status.currency.toLowerCase()]) 96 | } catch (e) { 97 | console.error(e) 98 | } 99 | } 100 | costTag.innerText = '' 101 | if (paidConverted != null && !paidConverted.isEmpty()) { 102 | costTag.innerText = paidConverted.display() + '/' 103 | } 104 | costTag.innerText += costAmount.display() 105 | 106 | if (displayCurrency.code.toLowerCase() != status.currency.toLowerCase()) { 107 | var exchanged = null 108 | try { 109 | exchanged = await costAmount.inCurrency(exchange, displayCurrency) 110 | } catch (e) { 111 | console.error(e) 112 | } 113 | if (exchanged != null) { 114 | costTag.innerText += ' (' + exchanged.display() + ')' 115 | } 116 | } 117 | } 118 | 119 | link.append(costTag) 120 | } 121 | names[i].classList.add('web-monetization-badge-checked') 122 | } 123 | 124 | if (0 < fetchMonetizationStatus.length) { 125 | var route = ptHelpers.getBaseStaticRoute() 126 | route = route.slice(0, route.lastIndexOf('/') + 1) + 'router/monetization_status_bulk' 127 | 128 | var headers: Record = {} 129 | // needed for checking if video is already paid for 130 | var tryHeaders = ptHelpers.getAuthHeader() 131 | if (tryHeaders != null) { 132 | headers = tryHeaders 133 | } 134 | headers['content-type'] = 'application/json; charset=utf-8' 135 | 136 | fetch(route, { 137 | method: 'POST', 138 | headers, 139 | body: JSON.stringify({ videos: fetchMonetizationStatus }) 140 | }).then(res => res.json()) 141 | .then((data: MonetizationStatusBulkPostRes) => { 142 | for (const key in data.statuses) { 143 | monetizationStatus[key] = data.statuses[key] 144 | } 145 | if (!recurse) { 146 | populateBadges(true) 147 | } 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /client/video-edit-client-plugin.ts: -------------------------------------------------------------------------------- 1 | import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@peertube/peertube-types' 2 | import type { RegisterClientOptions } from '@peertube/peertube-types/client' 3 | import { adSkipCostField, currencyField, paymentPointerField, receiptServiceField, viewCostField } from '../shared/common' 4 | import { quoteCurrencies } from '../shared/paid' 5 | 6 | var invalidPaymentPointerFormatMsg = 'Invalid payment pointer format.' 7 | 8 | export async function register ({ registerVideoField, peertubeHelpers }: RegisterClientOptions): Promise { 9 | // Payment pointer 10 | { 11 | const commonOptions: RegisterClientFormFieldOptions = { 12 | name: paymentPointerField, 13 | label: await peertubeHelpers.translate('Web Monetization payment pointer'), 14 | type: 'input', 15 | descriptionHTML: await peertubeHelpers.translate( 16 | 'Interledger payment pointer for Web Monetization. In the form of $example.org/account.' 17 | ), 18 | default: '' 19 | } 20 | const types: ('upload' | 'import-url' | 'import-torrent' | 'update' | 'go-live')[] = ['upload', 'import-url', 'import-torrent', 'update'] 21 | for (const type of types) { 22 | const videoFormOptions: RegisterClientVideoFieldOptions = { type} 23 | registerVideoField(commonOptions, videoFormOptions) 24 | } 25 | invalidPaymentPointerFormatMsg = await peertubeHelpers.translate(invalidPaymentPointerFormatMsg) 26 | finishAddPaymentPointerField() 27 | } 28 | 29 | // Receipt service 30 | { 31 | const commonOptions: RegisterClientFormFieldOptions = { 32 | name: receiptServiceField, 33 | label: await peertubeHelpers.translate('Add receipt service to payment pointer (to verify payments)'), 34 | type: 'input-checkbox', 35 | descriptionHTML: '', 36 | default: true 37 | } 38 | const types: ('upload' | 'import-url' | 'import-torrent' | 'update' | 'go-live')[] = ['upload', 'import-url', 'import-torrent', 'update'] 39 | for (const type of types) { 40 | const videoFormOptions: RegisterClientVideoFieldOptions = { type} 41 | registerVideoField(commonOptions, videoFormOptions) 42 | } 43 | } 44 | 45 | // Currency 46 | { 47 | var currencies = [] 48 | const commonCurrencies = ['usd', 'eur', 'xrp'] 49 | for (var i = 0; i < commonCurrencies.length; i++) { 50 | const currency = quoteCurrencies[commonCurrencies[i]] 51 | currencies.push({ 52 | label: currency.network, 53 | value: currency.code 54 | }) 55 | } 56 | currencies.push({ 57 | label: '================', 58 | value: 'USD' 59 | }) 60 | var codes = Object.keys(quoteCurrencies) 61 | for (var i = 0; i < codes.length; i++) { 62 | const currency = quoteCurrencies[codes[i]] 63 | currencies.push({ 64 | label: currency.network, 65 | value: currency.code 66 | }) 67 | } 68 | 69 | const commonOptions: RegisterClientFormFieldOptions = { 70 | name: currencyField, 71 | label: await peertubeHelpers.translate('Currency which costs are quoted in'), 72 | type: 'select', 73 | options: currencies, 74 | descriptionHTML: '', 75 | default: 'USD' 76 | } 77 | const types: ('upload' | 'import-url' | 'import-torrent' | 'update' | 'go-live')[] = ['upload', 'import-url', 'import-torrent', 'update'] 78 | for (const type of types) { 79 | const videoFormOptions: RegisterClientVideoFieldOptions = { type} 80 | registerVideoField(commonOptions, videoFormOptions) 81 | } 82 | } 83 | 84 | // View cost 85 | { 86 | const commonOptions: RegisterClientFormFieldOptions = { 87 | name: viewCostField, 88 | label: await peertubeHelpers.translate('Minimum payment rate to view per 10 minutes'), 89 | type: 'input', 90 | descriptionHTML: await peertubeHelpers.translate(''), 91 | default: '0' 92 | } 93 | const types: ('upload' | 'import-url' | 'import-torrent' | 'update' | 'go-live')[] = ['upload', 'import-url', 'import-torrent', 'update'] 94 | for (const type of types) { 95 | const videoFormOptions: RegisterClientVideoFieldOptions = { type} 96 | registerVideoField(commonOptions, videoFormOptions) 97 | } 98 | } 99 | 100 | // Ad skip cost 101 | { 102 | const commonOptions: RegisterClientFormFieldOptions = { 103 | name: adSkipCostField, 104 | label: await peertubeHelpers.translate('Minimum payment rate to skip ads per 10 minutes'), 105 | type: 'input', 106 | descriptionHTML: await peertubeHelpers.translate('Payment rates at or above this level will skip chapters with the "Sponsor" tag, labelled using the chapters plugin.'), 107 | default: '0' 108 | } 109 | const types: ('upload' | 'import-url' | 'import-torrent' | 'update' | 'go-live')[] = ['upload', 'import-url', 'import-torrent', 'update'] 110 | for (const type of types) { 111 | const videoFormOptions: RegisterClientVideoFieldOptions = { type} 112 | registerVideoField(commonOptions, videoFormOptions) 113 | } 114 | } 115 | } 116 | 117 | function finishAddPaymentPointerField () { 118 | var paymentPointerElement = document.getElementById(paymentPointerField) 119 | // The element is not added until the user switches to the "Plugin settings" tab 120 | if (paymentPointerElement == null) { 121 | setTimeout(() => { 122 | finishAddPaymentPointerField() 123 | }, 3000) 124 | return 125 | } 126 | 127 | var paymentPointerValid = true 128 | 129 | function update () { 130 | if (paymentPointerElement == null) { 131 | throw 'typescript unreachable' 132 | } 133 | 134 | if (paymentPointerElement.getAttribute('value') == null || 135 | paymentPointerElement.getAttribute('value') === '' || 136 | validatePaymentPointer(paymentPointerElement.getAttribute('value'))) { 137 | if (!paymentPointerValid) { 138 | paymentPointerValid = true 139 | 140 | paymentPointerElement.classList.remove('ng-invalid') 141 | paymentPointerElement.classList.add('ng-valid') 142 | 143 | var errorElRemove = document.getElementById(paymentPointerField + '-error') 144 | if (errorElRemove != null) { 145 | errorElRemove.parentNode!.removeChild(errorElRemove) 146 | } 147 | } 148 | } else { 149 | if (paymentPointerValid) { 150 | paymentPointerValid = false 151 | 152 | paymentPointerElement.classList.remove('ng-valid') 153 | paymentPointerElement.classList.add('ng-invalid') 154 | 155 | var errorEl = document.createElement('div') 156 | errorEl.id = paymentPointerField + '-error' 157 | errorEl.classList.add('form-error') 158 | errorEl.innerText = invalidPaymentPointerFormatMsg 159 | paymentPointerElement.parentNode!.appendChild(errorEl) 160 | } 161 | } 162 | } 163 | 164 | paymentPointerElement.addEventListener('input', () => { 165 | update() 166 | }) 167 | update() 168 | } 169 | 170 | function validatePaymentPointer (value: string | null): boolean { 171 | if (value == null) { 172 | return false 173 | } 174 | if (!value.startsWith('$')) { 175 | return false 176 | } 177 | 178 | const unparsed = 'https://' + value.substring(1) 179 | const parsed = new URL(unparsed) 180 | 181 | return parsed.host != null && 182 | parsed.username == null && 183 | parsed.password == null && 184 | parsed.search == null && 185 | parsed.hash == null 186 | } 187 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /server/main.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express' 2 | import type { RegisterServerOptions, MVideoFullLight, MVideoThumbnail, PluginStorageManager } from '@peertube/peertube-types' 3 | import short from 'short-uuid' 4 | import { paymentPointerField, paymentPointerStore, receiptServiceField, receiptServiceStore, currencyField, currencyStore, viewCostField, viewCostStore, adSkipCostField, adSkipCostStore, StoreKey, StoreObjectKey } from '../shared/common.js' 5 | import { VideoPaidStorage, Amount, Receipts, Exchange, quoteCurrencies, SerializedState, SerializedHistogramBinUncommitted, SerializedVideoPaid } from '../shared/paid.js' 6 | import { Histogram, StatsHistogramGet, StatsViewPost, StatsHistogramUpdatePost, MonetizationStatusBulkPost, MonetizationStatusBulkPostRes, MonetizationStatusBulkStatus } from '../shared/api' 7 | 8 | const shortUuidTranslator = short() 9 | 10 | var exchange = new Exchange() 11 | 12 | class StorageManagerTypeFix { 13 | storageManager: PluginStorageManager 14 | 15 | constructor (storageManager: PluginStorageManager) { 16 | this.storageManager = storageManager 17 | } 18 | 19 | // PeerTube lies and says it will always return a string, when it actually 20 | // returns undefined when no value exists, and returns an object, number, string, boolean, or null 21 | // if it's able to parse as json 22 | async getDataUnknown (key: string): Promise { 23 | // PeerTube spec specifies: async getData (key: string): Promise { 24 | return await this.storageManager.getData(key) as any 25 | } 26 | 27 | async getDataString (key: StoreKey): Promise { 28 | const val = await this.getDataUnknown(key.k) 29 | if (val === undefined || typeof val == 'string') { 30 | return val 31 | } 32 | // backwards compatibility for when we set values to null in order to unset them 33 | if (val === null) { 34 | return undefined 35 | } 36 | return JSON.stringify(val) 37 | } 38 | 39 | async getDataObjectRaw (key: StoreKey): Promise { 40 | const val = await this.getDataUnknown(key.k) 41 | if (val === undefined || (typeof val == 'object' && val != null)) { 42 | return val 43 | } 44 | // backwards compatibility for when we set values to null in order to unset them 45 | if (val === null) { 46 | return undefined 47 | } 48 | throw new Error('expected object for stored value '+key.k+', but got '+typeof val) 49 | } 50 | 51 | async getDataObject (key: StoreObjectKey): Promise { 52 | const val = await this.getDataUnknown(key.k) 53 | if (val === undefined) { 54 | return val 55 | } 56 | if (typeof val == 'object' && val != null) { 57 | return key.validate(val) 58 | } 59 | // backwards compatibility for when we set values to null in order to unset them 60 | if (val === null) { 61 | return undefined 62 | } 63 | throw new Error('expected object for stored value '+key.k+', but got '+typeof val+' with nullness:'+(val === null)) 64 | } 65 | async getDataNumber (key: StoreKey): Promise { 66 | const val = await this.getDataUnknown(key.k) 67 | if (val === undefined || typeof val == 'number') { 68 | return val 69 | } 70 | // backwards compatibility for when we set values to null in order to unset them 71 | if (val === null) { 72 | return undefined 73 | } 74 | throw new Error('expected number for stored value '+key.k+', but got '+typeof val) 75 | } 76 | 77 | async getDataBoolean (key: StoreKey): Promise { 78 | const val = await this.getDataUnknown(key.k) 79 | if (val === undefined || typeof val == 'boolean') { 80 | return val 81 | } 82 | // backwards compatibility for when we set values to null in order to unset them 83 | if (val === null) { 84 | return undefined 85 | } 86 | throw new Error('expected boolean for stored value '+key.k+', but got '+typeof val) 87 | } 88 | 89 | /*async storeData (key: string, data: any): Promise { 90 | return await this.storageManager.storeData(key, data) 91 | }*/ 92 | 93 | async storeDataRemove (key: StoreKey): Promise { 94 | return await this.storageManager.storeData(key.k, undefined) 95 | } 96 | 97 | async storeDataString (key: StoreKey, data: string): Promise { 98 | await this.storageManager.storeData(key.k, data) 99 | } 100 | 101 | async storeDataObjectRaw (key: StoreKey, data: object): Promise { 102 | await this.storageManager.storeData(key.k, data) 103 | } 104 | 105 | async storeDataObject (key: StoreObjectKey, data: T): Promise { 106 | await this.storageManager.storeData(key.k, data) 107 | } 108 | 109 | async storeDataNumber (key: StoreKey, data: number): Promise { 110 | await this.storageManager.storeData(key.k, data) 111 | } 112 | 113 | async storeDataBoolean (key: StoreKey, data: boolean): Promise { 114 | await this.storageManager.storeData(key.k, data) 115 | } 116 | } 117 | 118 | function histogramStore (videoId: string): StoreObjectKey { 119 | return { 120 | k: 'stats_histogram_v-' + videoId, 121 | validate: (x: object): Histogram | null => { 122 | if (!x.hasOwnProperty('parts') || !x.hasOwnProperty('history')) { 123 | return null 124 | } 125 | var y = x as any 126 | return { parts: y.parts, history: y.history } 127 | } 128 | } 129 | } 130 | 131 | type UserStats = { 132 | optOut: boolean, 133 | channels: any, 134 | } 135 | function userStatsStore (userId: string): StoreObjectKey { 136 | return { 137 | k: 'stats_user-' + userId, 138 | validate: (x: object): UserStats | null => { 139 | var y = x as any 140 | var optOut 141 | var channels 142 | if (x.hasOwnProperty('optOut')) { 143 | optOut = y.optOut 144 | } else { 145 | optOut = false 146 | } 147 | if (x.hasOwnProperty('channels')) { 148 | channels = y.channels 149 | } else { 150 | channels = {} 151 | } 152 | var y = x as any 153 | return { optOut, channels } 154 | } 155 | } 156 | } 157 | 158 | function videoPaidStore (videoId: string, userId: string): StoreObjectKey { 159 | return { 160 | k: 'stats_view_v-' + videoId + '_user-' + userId, 161 | validate: (x: object): SerializedVideoPaid | null => { 162 | if (!x.hasOwnProperty('total') || !x.hasOwnProperty('spans')) { 163 | return null 164 | } 165 | var y = x as any 166 | return { total: y.total, spans: y.spans } 167 | } 168 | } 169 | } 170 | 171 | export async function register ({peertubeHelpers, getRouter, registerHook, registerSetting: _r, settingsManager: _s, storageManager: storageManager_ }: RegisterServerOptions) { 172 | const log = peertubeHelpers.logger 173 | const storageManager = new StorageManagerTypeFix(storageManager_) 174 | 175 | registerHook({ 176 | target: 'action:api.video.updated', 177 | handler: ({ video, body }: { video: MVideoFullLight, body: any }) => { 178 | if (!body.pluginData) { 179 | return 180 | } 181 | 182 | var paymentPointer = body.pluginData[paymentPointerField] 183 | if (!paymentPointer || paymentPointer.trim() === '') { 184 | storageManager.storeDataRemove(paymentPointerStore(video.id)) 185 | storageManager.storeDataRemove(receiptServiceStore(video.id)) 186 | storageManager.storeDataRemove(currencyStore(video.id)) 187 | storageManager.storeDataRemove(viewCostStore(video.id)) 188 | storageManager.storeDataRemove(adSkipCostStore(video.id)) 189 | return 190 | } 191 | 192 | storageManager.storeDataString(paymentPointerStore(video.id), paymentPointer.trim()) 193 | storageManager.storeDataBoolean(receiptServiceStore(video.id), body.pluginData[receiptServiceField]) 194 | storageManager.storeDataString(currencyStore(video.id), body.pluginData[currencyField].trim()) 195 | // Divide by 600 to convert from per 10 minutes to per second 196 | storageManager.storeDataNumber(viewCostStore(video.id), parseFloat(body.pluginData[viewCostField].trim())) 197 | storageManager.storeDataNumber(adSkipCostStore(video.id), parseFloat(body.pluginData[adSkipCostField].trim())) 198 | } 199 | }) 200 | 201 | registerHook({ 202 | target: 'filter:api.video.get.result', 203 | handler: async (video: any) => { 204 | if (!video) { 205 | return video 206 | } 207 | if (!video.pluginData) { 208 | video.pluginData = {} 209 | } 210 | 211 | var paymentPointer = await storageManager.getDataString(paymentPointerStore(video.id)) 212 | video.pluginData[receiptServiceField] = await storageManager.getDataBoolean(receiptServiceStore(video.id)) 213 | // if (receiptService) { 214 | // paymentPointer = '$webmonetization.org/api/receipts/'+encodeURIComponent(paymentPointer) 215 | // } 216 | video.pluginData[paymentPointerField] = paymentPointer 217 | video.pluginData[currencyField] = await storageManager.getDataString(currencyStore(video.id)) 218 | video.pluginData[viewCostField] = await storageManager.getDataNumber(viewCostStore(video.id)) 219 | video.pluginData[adSkipCostField] = await storageManager.getDataNumber(adSkipCostStore(video.id)) 220 | return video 221 | } 222 | }) 223 | 224 | const router = getRouter() 225 | router.get('/stats/histogram/*', async (req: Request, res: Response) => { 226 | const videoId = req.path.slice(req.path.lastIndexOf('/') + 1) 227 | 228 | var video 229 | try { 230 | video = await peertubeHelpers.videos.loadByIdOrUUID(videoId) 231 | } catch (e) { 232 | log.error('web-monetization: /stats/histogram/: Failed to video loadByIdOrUUID: ' + e) 233 | res.status(500).send('500 Internal Server Error') 234 | return 235 | } 236 | if (video == null) { 237 | res.status(404).send('404 Not Found') 238 | return 239 | } 240 | 241 | var histogramObj: Histogram | null | undefined 242 | try { 243 | histogramObj = await storageManager.getDataObject(histogramStore(video.id)) 244 | } catch (e) { 245 | log.error('web-monetization: /stats/histogram/: Failed to getData histogram: ' + e) 246 | res.status(500).send('500 Internal Server Error') 247 | return 248 | } 249 | var histogram 250 | if(histogramObj === undefined) { 251 | histogram = { parts: [], history: {} } 252 | } else if (histogramObj === null) { 253 | log.error('web-monetization: /stats/histogram/: `Histogram` in store failed validation') 254 | res.status(500).send('500 Internal Server Error') 255 | return 256 | } else { 257 | histogram = histogramObj 258 | } 259 | 260 | const body: StatsHistogramGet = { histogram } 261 | res.send(body) 262 | }) 263 | 264 | async function commitHistogramChanges (video: MVideoThumbnail, histogram: Histogram, changes: SerializedHistogramBinUncommitted[], subscribed: boolean, userStats: any = null): Promise { 265 | var histogramChanged = false 266 | const lastBin = (video.duration / 15) >> 0 267 | try { 268 | const currencyCode = await storageManager.getDataString(currencyStore(video.id)) 269 | const currency = currencyCode == null ? null : quoteCurrencies[currencyCode.toLowerCase()] 270 | if (currency != null) { 271 | for (var i = 0; i < changes.length; i++) { 272 | var bin = changes[i] 273 | if (lastBin < bin.bin) { 274 | // Certainly malicious 275 | break 276 | } 277 | while (histogram.parts.length <= bin.bin) { 278 | histogram.parts.push(0.0) 279 | } 280 | var amount = Amount.deserialize(bin.uncommitted) 281 | amount = await amount.inCurrency(exchange, currency) 282 | if (amount != null) { 283 | var sum = 0 284 | var x = amount.unverified.get(currency.code) 285 | if (x != null) { 286 | sum += x.significand * 10 ** x.exponent 287 | } 288 | var x = amount.unverified.get(currency.code) 289 | if (x != null) { 290 | sum += x.significand * 10 ** x.exponent 291 | } 292 | 293 | histogram.parts[bin.bin] += sum 294 | 295 | const day = (Date.now() / 86400000) >> 0 296 | 297 | if (histogram.history['' + day] == null) { 298 | histogram.history['' + day] = { unknown: 0, subscribed: 0 } 299 | } 300 | if (subscribed) { 301 | histogram.history['' + day].subscribed += sum 302 | } else { 303 | histogram.history['' + day].unknown += sum 304 | } 305 | 306 | if (userStats != null && userStats.optOut != null) { 307 | if (userStats.channels['' + video.channelId] == null) { 308 | userStats.channels['' + video.channelId] = 0 309 | } 310 | // this assumes all their videos are in the same currency 311 | // there will also need to be a per-channel currency, though this is not possible at the moment 312 | userStats.channels['' + video.channelId] += sum 313 | } 314 | 315 | if (sum != 0) { 316 | histogramChanged = true 317 | } 318 | } 319 | } 320 | } 321 | } catch (e) { 322 | log.error('within commmitHistogramChanges: ' + e) 323 | } 324 | return histogramChanged 325 | } 326 | 327 | router.post('/stats/histogram_update/*', async (req, res) => { 328 | var data: StatsHistogramUpdatePost = req.body 329 | if (data.histogram.length != 0) { 330 | const videoId = req.path.slice(req.path.lastIndexOf('/') + 1) 331 | 332 | var video 333 | try { 334 | video = await peertubeHelpers.videos.loadByIdOrUUID(videoId) 335 | } catch (e) { 336 | log.error('web-monetization: /stats/histogram/: Failed to video loadByIdOrUUID: ' + e) 337 | res.status(500).send('500 Internal Server Error') 338 | return 339 | } 340 | if (video == null) { 341 | res.status(404).send('404 Not Found') 342 | return 343 | } 344 | 345 | var histogramObj 346 | try { 347 | histogramObj = await storageManager.getDataObject(histogramStore(video.id)) 348 | } catch (e) { 349 | log.error('web-monetization: /stats/histogram/: Failed to getData histogram: ' + e) 350 | res.status(500).send('500 Internal Server Error') 351 | return 352 | } 353 | var histogram 354 | if(histogramObj === undefined) { 355 | histogram = { parts: [], history: {} } 356 | } else if (histogramObj === null) { 357 | log.error('web-monetization: /stats/histogram/: `Histogram` in store failed validation') 358 | res.status(500).send('500 Internal Server Error') 359 | return 360 | } else { 361 | histogram = histogramObj 362 | } 363 | 364 | 365 | var receipts = Receipts.deserialize(data.receipts) 366 | receipts.verified = [] 367 | try { 368 | await receipts.verifyReceipts() 369 | } catch(e) { 370 | log.error('Failed to verify receipts:' + e) 371 | } 372 | 373 | try { 374 | const histogramChanged = await commitHistogramChanges(video, histogram, data.histogram, data.subscribed) 375 | if (histogramChanged) { 376 | storageManager.storeDataObject(histogramStore(video.id), histogram) 377 | } 378 | } catch (e) { 379 | log.error('commitHistogramChanges: ' + e) 380 | } 381 | } 382 | 383 | const resBody: { committed: SerializedHistogramBinUncommitted[] } = { committed: data.histogram } 384 | res.send(resBody) 385 | }) 386 | 387 | router.post('/stats/opt_out', async (req, res) => { 388 | var user 389 | try { 390 | user = await peertubeHelpers.user.getAuthUser(res) 391 | } catch (e) { 392 | log.error('web-monetization: /stats/opt_out/: Failed to getAuthUser: ' + e) 393 | res.status(500).send('500 Internal Server Error') 394 | return 395 | } 396 | if (user == null) { 397 | return 398 | } 399 | if (user.id == null) { 400 | log.error('web-monetization: /stats/opt_out/: `user.id == null`') 401 | res.status(500).send('500 Internal Server Error') 402 | return 403 | } 404 | 405 | var previousUserStatsObj: UserStats | null | undefined 406 | try { 407 | previousUserStatsObj = await storageManager.getDataObject(userStatsStore(user.id)) 408 | } catch (e) { 409 | log.error('web-monetization: /stats/view/: Failed to getData user stats: ' + e) 410 | res.status(500).send('500 Internal Server Error') 411 | return 412 | } 413 | var previousUserStats 414 | if (previousUserStatsObj === undefined) { 415 | previousUserStats = { optOut: false, channels: {} } 416 | } else if (previousUserStatsObj === null) { 417 | log.error('web-monetization: /stats/view/: `UserStats` in store failed validation') 418 | res.status(500).send('500 Internal Server Error') 419 | return 420 | } else { 421 | previousUserStats = previousUserStatsObj 422 | } 423 | 424 | if (req.body.optOut == true) { 425 | previousUserStats = { optOut: true, channels: {} } 426 | } else if (req.body.optOut == false) { 427 | previousUserStats.optOut = false 428 | } 429 | 430 | storageManager.storeDataObject(userStatsStore(user.id), previousUserStats) 431 | 432 | res.send({ optOut: previousUserStats.optOut }) 433 | }) 434 | 435 | router.post('/stats/user/channels', async (_: Request, res: Response) => { 436 | var user 437 | try { 438 | user = await peertubeHelpers.user.getAuthUser(res) 439 | } catch (e) { 440 | log.error('web-monetization: /stats/user/channels: Failed to getAuthUser: ' + e) 441 | res.status(500).send('500 Internal Server Error') 442 | return 443 | } 444 | if (user == null) { 445 | return 446 | } 447 | if (user.id == null) { 448 | log.error('web-monetization: /stats/opt_out/: `user.id == null`') 449 | res.status(500).send('500 Internal Server Error') 450 | return 451 | } 452 | 453 | var userStatsObj: UserStats | null | undefined 454 | try { 455 | userStatsObj = await storageManager.getDataObject(userStatsStore(user.id)) 456 | } catch (e) { 457 | log.error('web-monetization: /stats/user/channels: Failed to getData user stats: ' + e) 458 | res.status(500).send('500 Internal Server Error') 459 | return 460 | } 461 | var userStats 462 | if (userStatsObj === undefined) { 463 | userStats = { optOut: false, channels: {} } 464 | } else if (userStatsObj === null) { 465 | log.error('web-monetization: /stats/view/: `UserStats` in store failed validation') 466 | res.status(500).send('500 Internal Server Error') 467 | return 468 | } else { 469 | userStats = userStatsObj 470 | } 471 | 472 | res.send(userStats) 473 | }) 474 | 475 | router.post('/stats/view/*', async (req, res) => { 476 | var user 477 | try { 478 | user = await peertubeHelpers.user.getAuthUser(res) 479 | } catch (e) { 480 | log.error('web-monetization: /stats/view/: Failed to getAuthUser: ' + e) 481 | res.status(500).send('500 Internal Server Error') 482 | return 483 | } 484 | if (user == null) { 485 | return 486 | } 487 | if (user.id == null) { 488 | log.error('web-monetization: /stats/opt_out/: `user.id == null`') 489 | res.status(500).send('500 Internal Server Error') 490 | return 491 | } 492 | 493 | const videoId = req.path.slice(req.path.lastIndexOf('/') + 1) 494 | 495 | var video 496 | try { 497 | video = await peertubeHelpers.videos.loadByIdOrUUID(videoId) 498 | } catch (e) { 499 | log.error('web-monetization: /stats/view/: Failed to video loadByIdOrUUID: ' + e) 500 | res.status(500).send('500 Internal Server Error') 501 | return 502 | } 503 | if (video == null) { 504 | res.status(404).send('404 Not Found') 505 | return 506 | } 507 | 508 | var data: StatsViewPost = req.body 509 | 510 | var previous: SerializedVideoPaid | null | undefined 511 | try { 512 | previous = await storageManager.getDataObject(videoPaidStore(video.id, user.id)) 513 | } catch (e) { 514 | log.error('web-monetization: /stats/view/: Failed to getData view stats: ' + e) 515 | res.status(500).send('500 Internal Server Error') 516 | return 517 | } 518 | 519 | var previousHistogramObj 520 | try { 521 | previousHistogramObj = await storageManager.getDataObject(histogramStore(video.id)) 522 | } catch (e) { 523 | log.error('web-monetization: /stats/view/: Failed to getData histogram: ' + e) 524 | res.status(500).send('500 Internal Server Error') 525 | return 526 | } 527 | var previousHistogram 528 | if(previousHistogramObj === undefined) { 529 | previousHistogram = { parts: [], history: {} } 530 | } else if (previousHistogramObj === null) { 531 | log.error('web-monetization: /stats/histogram/: `Histogram` in store failed validation') 532 | res.status(500).send('500 Internal Server Error') 533 | return 534 | } else { 535 | previousHistogram = previousHistogramObj 536 | } 537 | 538 | var previousUserStatsObj 539 | try { 540 | previousUserStatsObj = await storageManager.getDataObject(userStatsStore(user.id)) 541 | } catch (e) { 542 | log.error('web-monetization: /stats/view/: Failed to getData user stats: ' + e) 543 | res.status(500).send('500 Internal Server Error') 544 | return 545 | } 546 | var previousUserStats 547 | if (previousUserStatsObj === undefined) { 548 | previousUserStats = { optOut: false, channels: {} } 549 | } else if (previousUserStatsObj === null) { 550 | log.error('web-monetization: /stats/view/: `UserStats` in store failed validation') 551 | res.status(500).send('500 Internal Server Error') 552 | return 553 | } else { 554 | previousUserStats = previousUserStatsObj 555 | } 556 | 557 | var store 558 | if (previous == null) { 559 | store = new VideoPaidStorage() 560 | } else { 561 | store = VideoPaidStorage.deserialize(previous) 562 | } 563 | 564 | var receipts = Receipts.deserialize(data.receipts) 565 | receipts.verified = [] 566 | try { 567 | await receipts.verifyReceipts() 568 | } catch(e) { 569 | log.error('verify receipts:' + e) 570 | } 571 | 572 | // `histogram` field in `VideoPAid` here is sparse, most functions are invalid 573 | // var changes = VideoPaid.deserializeChanges(data.changes) 574 | const anyChanges = store.commitChanges(data.changes) 575 | try { 576 | store.verifyReceipts() 577 | } catch (e) { 578 | log.error('verify receipts:' + e) 579 | } 580 | const storeSerialized: SerializedVideoPaid = store.serialize() 581 | if (anyChanges) { 582 | storageManager.storeDataObject(videoPaidStore(video.id, user.id), storeSerialized) 583 | } 584 | 585 | var histogramChanged = false 586 | if (!previousUserStats.optOut) { 587 | try { 588 | histogramChanged = await commitHistogramChanges(video, previousHistogram, data.changes.histogram, data.subscribed, previousUserStats) 589 | if (histogramChanged) { 590 | storageManager.storeDataObject(histogramStore(video.id), previousHistogram) 591 | storageManager.storeDataObject(userStatsStore(user.id), previousUserStats) 592 | } 593 | } catch (e) { 594 | log.error('commitHistogramChanges: ' + e) 595 | } 596 | } 597 | 598 | var resBody: SerializedState = { 599 | currentState: storeSerialized, 600 | committedChanges: data.changes, 601 | optOut: previousUserStats.optOut 602 | } 603 | res.send(resBody) 604 | }) 605 | 606 | router.post('/monetization_status_bulk', async (req, res) => { 607 | var user 608 | try { 609 | user = await peertubeHelpers.user.getAuthUser(res) 610 | } catch (e) { 611 | user = null 612 | log.error('web-monetization: /stats/view/: Failed to getAuthUser: ' + e) 613 | } 614 | 615 | var data: MonetizationStatusBulkPost = req.body 616 | if (req.body.videos == null || req.body.videos.length == null) { 617 | res.status(400).send('400 Bad Request') 618 | return 619 | } 620 | const videos: string[] = data.videos 621 | 622 | var statuses: Record = {} 623 | for (var i = 0; i < videos.length; i++) { 624 | try { 625 | const video = await peertubeHelpers.videos.loadByIdOrUUID(shortUuidTranslator.toUUID(videos[i])) 626 | const paymentPointer = await storageManager.getDataString(paymentPointerStore(video.id)) 627 | if (paymentPointer != null) { 628 | statuses[videos[i]] = { monetization: 'monetized' } 629 | 630 | try { 631 | const currency = await storageManager.getDataString(currencyStore(video.id)) 632 | const viewCost = await storageManager.getDataNumber(viewCostStore(video.id)) 633 | const adSkipCost = await storageManager.getDataNumber(adSkipCostStore(video.id)) 634 | if (adSkipCost != undefined && !isNaN(adSkipCost) && 0 < adSkipCost) { 635 | statuses[videos[i]] = { monetization: 'ad-skip' } 636 | } 637 | if (viewCost != undefined && !isNaN(viewCost) && 0 < viewCost) { 638 | var paid = null 639 | if (user != null && user.id != null) { 640 | try { 641 | const stats = await storageManager.getDataObject(videoPaidStore(video.id, user.id)) 642 | if (stats != null) { 643 | paid = stats.total 644 | } 645 | } catch (e) { 646 | log.error('failed to try to get stats for video '+video.id+' for user '+user.id+', error:'+e) 647 | } 648 | } 649 | 650 | statuses[videos[i]] = { monetization: 'pay-wall', currency: currency, viewCost: viewCost, duration: video.duration, paid: paid } 651 | } 652 | } catch (e) { 653 | log.error('failed to get extended monetization data for video '+video.id+', error:'+e) 654 | } 655 | } 656 | } catch (e) { 657 | log.error('failed to get video ' + videos[i]) 658 | log.error('failed to get video error: ' + e) 659 | if (statuses[videos[i]] == null) { 660 | statuses[videos[i]] = { monetization: 'unknown' } 661 | } 662 | } 663 | } 664 | 665 | const body: MonetizationStatusBulkPostRes = { statuses } 666 | res.send(body) 667 | }) 668 | } 669 | 670 | export async function unregister () { 671 | } 672 | -------------------------------------------------------------------------------- /client/video-watch-client-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { VideoDetails } from '@peertube/peertube-types' 2 | import type { RegisterClientOptions, RegisterClientHelpers } from '@peertube/peertube-types/client' 3 | import { MonetizationExtendedDocument, MonetizationProgressEvent } from '@webmonetization/types' 4 | import interval from 'interval-promise' 5 | import videojs from 'video.js' 6 | import * as Plotly from 'plotly.js-finance-dist-min' 7 | import { adSkipCostField, currencyField, hms, paymentPointerField, receiptServiceField, version, viewCostField } from 'shared/common' 8 | import { Amount, Exchange, quoteCurrencies, Currency, Receipts, VideoPaid, VideoPaidStorage, SerializedState, SerializedHistogramBinUncommitted } from 'shared/paid' 9 | import { StatsViewPost, StatsHistogramUpdatePost } from 'shared/api' 10 | 11 | const tableOfContentsField = 'table-of-contents_parsed' 12 | 13 | function getDocument() { 14 | return document as unknown as MonetizationExtendedDocument 15 | } 16 | const doc = getDocument() 17 | 18 | var ptHelpers: RegisterClientHelpers | null = null 19 | var baseStaticRoute: string 20 | var paid = new VideoPaid() 21 | var receipts = new Receipts() 22 | var exchange = new Exchange() 23 | var displayCurrency = quoteCurrencies['usd'] 24 | var paymentPointer: string | null 25 | var videoQuoteCurrency: string 26 | var videoQuoteCurrencyObj: Currency 27 | var viewCost = 0 28 | var adSkipCost = 0 29 | 30 | var unpaid = true 31 | var paidEnds: number | null = null 32 | var nextPaid: number | null = null 33 | 34 | var play = false 35 | var monetized = false 36 | var seeking = false 37 | // `videoEl.currentTime` after `seeked` event is thrown 38 | var lastSeek: number | null = null 39 | 40 | type Chapters = { 41 | chapters: Chapter[], 42 | description: string | null, 43 | end: null, 44 | }; 45 | type Chapter = { 46 | start: number, 47 | end: number, 48 | name: string, 49 | tags: { 50 | sponsor?: boolean, 51 | selfPromotion?: boolean, 52 | interactionReminder?: boolean, 53 | intro?: boolean, 54 | intermission?: boolean, 55 | outro?: boolean, 56 | credits?: boolean, 57 | nonMusic?: boolean, 58 | } 59 | }; 60 | var chapters: Chapters | null = null 61 | var chaptersTrack: TextTrack | null = null 62 | var videoEl: HTMLVideoElement 63 | var videoId: number 64 | 65 | var statsTracking = true 66 | 67 | // `peertubeHelpers` is not available for `embed` 68 | export function register ({ registerHook, peertubeHelpers }: RegisterClientOptions) { 69 | ptHelpers = peertubeHelpers 70 | if (ptHelpers != null) { 71 | baseStaticRoute = ptHelpers.getBaseStaticRoute() 72 | } 73 | 74 | registerHook({ 75 | target: 'action:video-watch.player.loaded', 76 | handler: ({ player, video }: { player: videojs.Player, video: VideoDetails }) => { 77 | setup(player, video) 78 | } 79 | }) 80 | registerHook({ 81 | target: 'action:embed.player.loaded', 82 | handler: ({ player, video }: { player: videojs.Player, video: VideoDetails }) => { 83 | // `peertubeHelpers` is not available for embed, make best attempt at getting base route 84 | // `originInstanceUrl` also doesn't exist for embedded videos 85 | // baseStaticRoute = video.originInstanceUrl + '/plugins/web-monetization/' + version + '/router' 86 | baseStaticRoute = video.channel.url 87 | baseStaticRoute = baseStaticRoute!.slice(0, baseStaticRoute!.lastIndexOf('/')) 88 | baseStaticRoute = baseStaticRoute.slice(0, baseStaticRoute.lastIndexOf('/')) 89 | baseStaticRoute += '/plugins/web-monetization/' + version + '/router' 90 | setup(player, video) 91 | } 92 | }) 93 | 94 | function setup (player: videojs.Player, video: VideoDetails) { 95 | if (!video.pluginData || !video.pluginData[paymentPointerField]) { 96 | console.log('web-monetization: Not enabled for this video.') 97 | return 98 | } 99 | videoId = video.id 100 | paymentPointer = video.pluginData[paymentPointerField] 101 | if (paymentPointer != null && (video.pluginData[receiptServiceField] == true || video.pluginData[receiptServiceField] == 'true')) { 102 | paymentPointer = '$webmonetization.org/api/receipts/' + encodeURIComponent(paymentPointer) 103 | } 104 | 105 | videoQuoteCurrency = video.pluginData[currencyField] || 'USD' 106 | videoQuoteCurrencyObj = quoteCurrencies[videoQuoteCurrency!.toLowerCase()] 107 | if (videoQuoteCurrencyObj == null) { 108 | videoQuoteCurrency = 'USD' 109 | videoQuoteCurrencyObj = quoteCurrencies[videoQuoteCurrency!.toLowerCase()] 110 | } 111 | 112 | if (video.pluginData[viewCostField] != null && !isNaN(parseFloat(video.pluginData[viewCostField]))) { 113 | // 600 to convert from per 10 min to per second 114 | viewCost = parseFloat(video.pluginData[viewCostField]) / 600 115 | } 116 | if (video.pluginData[adSkipCostField] != null && !isNaN(parseFloat(video.pluginData[adSkipCostField]))) { 117 | // 600 to convert from per 10 min to per second 118 | adSkipCost = parseFloat(video.pluginData[adSkipCostField]) / 600 119 | } 120 | console.log('web-monetization: paymentPointer: ' + paymentPointer + ' viewCost: ' + viewCost + ' adSkipCost: ' + adSkipCost + ' currency: ' + videoQuoteCurrency) 121 | 122 | chapters = video.pluginData[tableOfContentsField] 123 | if (chapters == null) { 124 | console.log('web-monetization: No chapter information from peertube-plugin-chapters plugin data, sponsor skipping not possible.') 125 | } 126 | 127 | videoEl = player.el().getElementsByTagName('video')[0] 128 | if (doc.monetization === undefined) { 129 | console.log('peertube-plugin-web-monetization v', version, ' enabled on server, but Web Monetization not supported by user agent. See https://webmonetization.org.') 130 | if (0 < viewCost) { 131 | console.log('web-monetization: Web Monetization not supported by user agent, but viewCost is ' + viewCost + ' cannot view video') 132 | enforceViewCost().then(() => { 133 | }) 134 | } 135 | return 136 | } 137 | 138 | console.log('peertube-plugin-web-monetization v', version, ' detected Web Monetization support. Setting up...') 139 | 140 | // Indicates that Web Monetization is enabled 141 | doc.monetization.addEventListener( 142 | 'monetizationpending', 143 | () => { 144 | // const { paymentPointer, requestId } = event.detail 145 | } 146 | ) 147 | 148 | // First non-zero payment has been sent 149 | doc.monetization.addEventListener( 150 | 'monetizationstart', 151 | () => { 152 | // const { paymentPointer, requestId } = event.detail 153 | monetized = true 154 | // If start occures mid-segment 155 | cueChange() 156 | } 157 | ) 158 | 159 | // Monetization end 160 | doc.monetization.addEventListener( 161 | 'monetizationstop', 162 | () => { 163 | /* 164 | const { 165 | paymentPointer, 166 | requestId, 167 | finalized // if `requestId` will not be used again 168 | } = event.detail 169 | */ 170 | monetized = false 171 | } 172 | ) 173 | 174 | // A payment (including first payment) has been made 175 | doc.monetization.addEventListener( 176 | 'monetizationprogress', 177 | (event: MonetizationProgressEvent) => { 178 | const { 179 | // paymentPointer, 180 | // requestId, 181 | amount, assetCode, assetScale, receipt } = event.detail 182 | 183 | var instant: number | null = videoEl.currentTime 184 | if (seeking) { 185 | // If we are seeking, there is no guarantee whether the time reported is before or after the seek operation 186 | instant = null 187 | } 188 | var receiptNumber = null 189 | if (receipt != null) { 190 | receiptNumber = receipts.toCheck(receipt) 191 | } 192 | paid.deposit(instant, parseInt(amount), -assetScale, assetCode, receiptNumber) 193 | } 194 | ) 195 | 196 | // Normal state changes 197 | videoEl.addEventListener('play', () => { 198 | play = true 199 | // Update timer 200 | updateSpan() 201 | enableMonetization() 202 | }) 203 | videoEl.addEventListener('pause', () => { 204 | play = false 205 | disableMonetization() 206 | }) 207 | videoEl.addEventListener('ended', () => { 208 | play = false 209 | disableMonetization() 210 | }) 211 | 212 | videoEl.addEventListener('ratechange', () => { 213 | // Update timer 214 | updateSpan() 215 | }) 216 | var preSeekTime: number | null = null 217 | videoEl.addEventListener('timeupdate', () => { 218 | if (!seeking) { 219 | preSeekTime = videoEl.currentTime 220 | } 221 | }) 222 | videoEl.addEventListener('seeking', () => { 223 | seeking = true 224 | if (preSeekTime != null && paid.currentSpan != null) { 225 | // Seems to give time after seeking finished sometimes 226 | // paid.endSpan(videoEl.currentTime) 227 | // `seeking` event is triggered when skipping a segment, in which case the 228 | // span will have already been ended, and a new one started after the `preSeekTime` 229 | if (paid.currentSpan.start <= preSeekTime) { 230 | paid.endSpan(preSeekTime) 231 | } 232 | preSeekTime = null 233 | } 234 | paidEnds = null 235 | nextPaid = null 236 | }) 237 | videoEl.addEventListener('seeked', () => { 238 | lastSeek = videoEl.currentTime 239 | seeking = false 240 | cueChange() 241 | updateSpan() 242 | }) 243 | 244 | // State changes due to loading 245 | videoEl.addEventListener('playing', () => { 246 | if (play) { 247 | // Update timer 248 | updateSpan() 249 | enableMonetization() 250 | } 251 | }) 252 | videoEl.addEventListener('waiting', () => { 253 | disableMonetization() 254 | }) 255 | 256 | function textTracksUpdate () { 257 | const tracks = player.remoteTextTracks() 258 | for (var i = 0; i < tracks.length; i++) { 259 | var track = tracks[i] 260 | if (track.kind == 'chapters') { 261 | chaptersTrack = track 262 | track.addEventListener('cuechange', () => { 263 | if (videoEl != null && videoEl.seeking) { 264 | // Will be called by `seeked` event, otherwise we can miss the change in positon 265 | // and skip a segment that the user clicked on 266 | return 267 | } else { 268 | cueChange() 269 | } 270 | }) 271 | console.log('web-monetization: Chapter cue track appears. Plugin data also available: ' + (chapters != null)) 272 | return 273 | } 274 | } 275 | 276 | if (chaptersTrack != null) { 277 | chaptersTrack.removeEventListener('cuechange', cueChange) 278 | } 279 | chaptersTrack = null 280 | } 281 | 282 | player.remoteTextTracks().addEventListener('addtrack', textTracksUpdate) 283 | player.remoteTextTracks().addEventListener('removetrack', textTracksUpdate) 284 | textTracksUpdate() 285 | 286 | if (player.hasStarted()) { 287 | updateSpan() 288 | } 289 | 290 | enforceViewCost().then(() => { 291 | }) 292 | 293 | window.setInterval(pushViewedSegments, 10 * 1000) 294 | 295 | var videoActionsMatches = doc.getElementsByClassName('video-actions') 296 | var videoDescriptionMatches = doc.getElementsByClassName('video-info-description') 297 | if (videoActionsMatches.length < 1 || videoDescriptionMatches.length < 1) { 298 | console.error('web-monetization: Failed to add stats panel') 299 | } else { 300 | var actions = videoActionsMatches[0] 301 | var description = videoDescriptionMatches[0] 302 | 303 | var statsPanel = doc.createElement('div') 304 | statsPanel.style.display = 'none' 305 | 306 | var currencySelect = doc.createElement('select') 307 | currencySelect.classList.add('peertube-button') 308 | currencySelect.classList.add('grey-button') 309 | currencySelect.setAttribute('style', 'margin-top:0.5em;margin-bottom:0.5em;margin-right:0.5em;') 310 | 311 | var foundDisplayCurrency = false 312 | var commonCurrencies = ['usd', 'eur', 'xrp'] 313 | for (var i = 0; i < commonCurrencies.length; i++) { 314 | const currency = quoteCurrencies[commonCurrencies[i]] 315 | var option = doc.createElement('option') 316 | option.innerText = currency.code + ' ' + currency.network 317 | option.value = currency.code 318 | currencySelect.appendChild(option) 319 | if (currency.code == displayCurrency.code) { 320 | currencySelect.selectedIndex = i 321 | foundDisplayCurrency = true 322 | } 323 | } 324 | 325 | var option = doc.createElement('option') 326 | option.innerText = '================' 327 | option.value = 'USD' 328 | currencySelect.appendChild(option) 329 | 330 | var codes = Object.keys(quoteCurrencies) 331 | for (var i = 0; i < codes.length; i++) { 332 | const currency = quoteCurrencies[codes[i]] 333 | var option = doc.createElement('option') 334 | option.innerText = currency.code + ' ' + currency.network 335 | option.value = currency.code 336 | currencySelect.appendChild(option) 337 | if (currency.code == displayCurrency.code && !foundDisplayCurrency) { 338 | currencySelect.selectedIndex = i 339 | } 340 | } 341 | currencySelect.addEventListener('change', function () { 342 | var assetCode = this.value 343 | var currency = quoteCurrencies[assetCode.toLowerCase()] 344 | if (currency != null) { 345 | displayCurrency = currency 346 | } 347 | }) 348 | statsPanel.appendChild(currencySelect) 349 | 350 | var optOut = doc.createElement('button') 351 | optOut.classList.add('peertube-button') 352 | optOut.classList.add('grey-button') 353 | optOut.textContent = 'Opt-out and delete data' 354 | optOut.addEventListener('click', function () { 355 | var headers: Record | null = null 356 | if (ptHelpers != null) { 357 | headers = ptHelpers.getAuthHeader() || null 358 | } 359 | if (headers == null) { 360 | if (statsTracking) { 361 | statsTracking = false 362 | alert('You are not logged in. Stats tracking disabled for this page. (No individualized data is stored)') 363 | optOut.textContent = 'Opt-in to stats tracking' 364 | } else { 365 | statsTracking = true 366 | optOut.textContent = 'Opt-out and delete data' 367 | } 368 | } else { 369 | headers['content-type'] = 'application/json; charset=utf-8' 370 | fetch(baseStaticRoute.slice(0, baseStaticRoute.lastIndexOf('/') + 1) + 'router/stats/opt_out', { 371 | method: 'POST', 372 | headers: headers, 373 | body: JSON.stringify({ optOut: statsTracking }) 374 | }).then(res => res.json()) 375 | .then(data => { 376 | statsTracking = !data.optOut 377 | if (statsTracking) { 378 | optOut.textContent = 'Opt-out and delete data' 379 | } else { 380 | optOut.textContent = 'Opt-in to stats tracking' 381 | } 382 | }) 383 | if (statsTracking) { 384 | alert('Sent request.') 385 | } 386 | } 387 | }) 388 | statsPanel.appendChild(optOut) 389 | 390 | statsPanel.appendChild(doc.createElement('br')) 391 | 392 | var summary = doc.createElement('h4') 393 | statsPanel.appendChild(summary) 394 | 395 | var histogram = doc.createElement('div') 396 | histogram.id = 'web-monetization-histogram' 397 | statsPanel.appendChild(histogram) 398 | 399 | var perDayPlot = doc.createElement('div') 400 | perDayPlot.id = 'web-monetization-by-day-plot' 401 | statsPanel.appendChild(perDayPlot) 402 | 403 | var channelPlot = doc.createElement('div') 404 | channelPlot.id = 'web-monetization-channel-plot' 405 | statsPanel.appendChild(channelPlot) 406 | 407 | var allUserSummary = doc.createElement('h5') 408 | statsPanel.appendChild(allUserSummary) 409 | 410 | description.parentNode!.insertBefore(statsPanel, description) 411 | 412 | var statsButton = doc.createElement('button') 413 | statsButton.classList.add('action-button') 414 | statsButton.setAttribute('placement', 'bottom auto') 415 | statsButton.setAttribute('ngbTooltip', 'View Monetization Stats') 416 | statsButton.title = 'View Monetization Stats' 417 | var icon = doc.createElement('img') 418 | icon.src = baseStaticRoute + '/images/wm-icon-grey.svg' 419 | icon.setAttribute('height', '24') 420 | 421 | statsButton.appendChild(icon) 422 | statsButton.addEventListener('click', function () { 423 | if (statsPanel.style.display == 'block') { 424 | statsPanel.style.display = 'none' 425 | } else { 426 | statsPanel.style.display = 'block' 427 | } 428 | }) 429 | actions.prepend(statsButton) 430 | 431 | // Video parts histogram 432 | var allHistogramX: number[] | null = null 433 | var allHistogramY: number[] | null = null 434 | var totalRevenue = null 435 | // Per-day data 436 | var perDayX: string[] | null = null 437 | var perDayUnknown: number[] | null = null 438 | var perDaySubscribed: number[] | null = null 439 | 440 | var channelData: Partial | null = null 441 | var channelNames: Record = {} 442 | 443 | var lastHistogramFetch: number | null = null 444 | var histogramFetchTries = 0 445 | var updateStatsClosure = async () => { 446 | if (statsPanel.style.display == 'none') { 447 | return 448 | } 449 | var display = null 450 | try { 451 | display = await paid.total.inCurrency(exchange, displayCurrency) 452 | } catch (e) { 453 | console.error(e) 454 | } 455 | if (display == null) { 456 | display = paid.total 457 | } 458 | summary.textContent = 'Paid ' + display.display() + ' for ' + hms(paid.totalTime(videoEl.currentTime) >> 0) + ' (' + display.display(paid.totalTime(videoEl.currentTime)) + ')' 459 | 460 | { 461 | try { 462 | // Refresh data every 6 minutes 463 | // If fetch fails, try up to 5 times every 15 seconds, then every 6 minutes 464 | if (lastHistogramFetch == null || (6 * 60 * 1000 < Date.now() - lastHistogramFetch || (0 < histogramFetchTries && histogramFetchTries < 5 && 15 * 1000 < Date.now() - lastHistogramFetch)) 465 | || ((allHistogramX == null && histogramFetchTries == 0) || (histogramFetchTries < 5 && 15 * 1000 < Date.now() - lastHistogramFetch))) { 466 | lastHistogramFetch = Date.now() 467 | histogramFetchTries += 1 468 | 469 | var res = await fetch(baseStaticRoute.slice(0, baseStaticRoute.lastIndexOf('/') + 1) + 'router/stats/histogram/' + videoId, { 470 | method: 'GET' 471 | }) 472 | var resData = await res.json() 473 | 474 | try { 475 | var headers = null 476 | if (ptHelpers != null) { 477 | headers = ptHelpers.getAuthHeader() 478 | } 479 | if (headers == null) { 480 | channelData = null 481 | channelPlot.style.display = 'none' 482 | } else { 483 | var userStatsRes = await fetch(baseStaticRoute.slice(0, baseStaticRoute.lastIndexOf('/') + 1) + 'router/stats/user/channels', { 484 | method: 'POST', 485 | headers: headers 486 | }) 487 | var userStatsData = await userStatsRes.json() 488 | if (userStatsData.optOut) { 489 | channelData = null 490 | channelPlot.style.display = 'none' 491 | } else { 492 | channelData = { values: [], labels: [], type: 'pie', textinfo: 'label+percent' } 493 | channelPlot.style.display = 'block' 494 | 495 | var channelList = null 496 | for (const channelId in userStatsData.channels) { 497 | if (channelNames[channelId] == null) { 498 | if (channelList == null) { 499 | var api = baseStaticRoute 500 | api = api.slice(0, api.lastIndexOf('/')) 501 | api = api.slice(0, api.lastIndexOf('/')) 502 | api = api.slice(0, api.lastIndexOf('/')) 503 | api = api.slice(0, api.lastIndexOf('/')) 504 | api = api + '/api/v1' 505 | var channelListRes = await fetch(api + '/video-channels', { 506 | method: 'GET' 507 | }) 508 | var channelListData = await channelListRes.json() 509 | channelList = channelListData.data 510 | } 511 | const channelIdInt = Number.parseInt(channelId) 512 | for (var i = 0; i < channelList.length; i++) { 513 | if (channelList[i].id == channelIdInt) { 514 | channelNames[channelId] = channelList[i].displayName 515 | break 516 | } 517 | } 518 | } 519 | channelData.values!.push(userStatsData.channels[channelId]) 520 | if (channelNames[channelId] != null) { 521 | channelData.labels!.push(channelNames[channelId]) 522 | } else { 523 | channelData.labels!.push(channelId) 524 | } 525 | } 526 | } 527 | } 528 | } catch (e) { 529 | console.error(e) 530 | } 531 | 532 | histogramFetchTries = 0 533 | 534 | allHistogramY = resData.histogram.parts 535 | allHistogramX = [] 536 | perDayX = [] 537 | perDayUnknown = [] 538 | perDaySubscribed = [] 539 | for (const day in resData.histogram.history) { 540 | perDayX.push(new Date(Number.parseInt(day) * 86400000).toISOString()) 541 | perDayUnknown.push(resData.histogram.history[day].unknown) 542 | perDaySubscribed.push(resData.histogram.history[day].subscribed) 543 | } 544 | 545 | totalRevenue = new Amount(true) 546 | var significand = 0 547 | for (var i = 0; i < resData.histogram.parts.length; i++) { 548 | allHistogramX.push(i * 15000) 549 | // `allHistogramY` set non-null a few lines above, `allHistogramY = resData.histogram.parts` 550 | significand += allHistogramY![i] 551 | } 552 | 553 | var exponent = 0 554 | while (significand * 0.001 < Math.abs(significand - (significand >> 0))) { 555 | significand *= 10 556 | exponent -= 1 557 | } 558 | significand >>= 0 559 | 560 | totalRevenue.depositUnchecked(significand, exponent, videoQuoteCurrency, true, null) 561 | try { 562 | var converted = await totalRevenue.inCurrency(exchange, displayCurrency) 563 | if (converted != null) { 564 | totalRevenue = converted 565 | } 566 | } catch (e) { 567 | console.error(e) 568 | } 569 | allUserSummary.textContent = 'The video has received ' + totalRevenue.display() + ' overall.' 570 | } 571 | } catch (e) { 572 | console.error(e) 573 | } 574 | 575 | var histogramX = [] 576 | var histogramData = [] 577 | try { 578 | const currency = videoQuoteCurrencyObj 579 | for (var i = 0; i < paid.histogram.length; i++) { 580 | var a = await paid.histogram[i].uncommitted.inCurrency(exchange, currency) 581 | var b = await paid.histogram[i].committed.inCurrency(exchange, currency) 582 | a.addFrom(b) 583 | var sum = 0 584 | var x = a.unverified.get(currency.code) 585 | if (x != null) { 586 | sum += x.significand * 10 ** x.exponent 587 | } 588 | var x = a.verified.get(currency.code) 589 | if (x != null) { 590 | sum += x.significand * 10 ** x.exponent 591 | } 592 | histogramX.push(i * 15000) 593 | histogramData.push(sum) 594 | } 595 | } catch (e) { 596 | console.error(e) 597 | } 598 | 599 | var histogramUser: Partial = { 600 | name: 'This session (histogram is not stored per-user)', 601 | x: histogramX, 602 | y: histogramData, 603 | autobinx: false, 604 | histfunc: 'sum', 605 | xbins: { 606 | start: 0, 607 | end: 15000*Math.ceil(videoEl.duration/15), 608 | size: 15000, 609 | }, 610 | type: 'histogram', 611 | yaxis: 'y', 612 | opacity: 1.0, 613 | marker: { 614 | color: 'orange' 615 | } 616 | } 617 | var histogramAll: Partial = { 618 | name: 'All users', 619 | x: allHistogramX!, 620 | y: allHistogramY!, 621 | autobinx: false, 622 | histfunc: 'sum', 623 | xbins: { 624 | start: 0, 625 | end: 15000*Math.ceil(videoEl.duration/15), 626 | size: 15000, 627 | }, 628 | type: 'histogram', 629 | yaxis: 'y2', 630 | opacity: 0.5, 631 | marker: { 632 | color: 'grey' 633 | } 634 | } 635 | var data: Plotly.Data[] 636 | if (allHistogramX != null) { 637 | data = [histogramUser, histogramAll] 638 | } else { 639 | data = [histogramUser] 640 | } 641 | var tickformat 642 | var yUnit 643 | if (3600 <= videoEl.duration) { 644 | tickformat = '%H:%M:%S' 645 | yUnit = 'hh:mm:ss' 646 | } else { 647 | tickformat = '%M:%S' 648 | yUnit = 'mm:ss' 649 | } 650 | var layout: any = { 651 | title: 'Contributions to Video at 15 Second Intervals', 652 | xaxis: { title: 'Position in video ('+yUnit+')', type: 'date', tickformat, range: [0, videoEl.duration * 1000] }, 653 | yaxis: { title: 'Session contributions (' + videoQuoteCurrency + ')', rangemode: 'nonnegative', tickformat: '.1e' }, 654 | yaxis2: { title: 'All contributions (' + videoQuoteCurrency + ')', rangemode: 'nonnegative', tickformat: '.1e', side: 'right', overlaying: 'y' }, 655 | legend: { orientation: 'h', xanchor: 'right', yanchor: 'top', x: 0.99, y: 0.99 }, 656 | showlegend: true, 657 | barmode: 'overlay' 658 | } 659 | if (histogramData.length != 0 || allHistogramX != null) { 660 | histogram.setAttribute('style', 'width:50em;height:30em;') 661 | Plotly.newPlot(histogram, data, layout) 662 | } 663 | 664 | // Per-day 665 | { 666 | var unknown: Plotly.Data = { 667 | name: 'Unsubscribed users', 668 | x: perDayX!, 669 | y: perDayUnknown!, 670 | type: 'scatter', 671 | marker: { 672 | color: 'grey' 673 | } 674 | } 675 | var subscribed: Plotly.Data = { 676 | name: 'Subscribed users', 677 | x: perDayX!, 678 | y: perDaySubscribed!, 679 | type: 'scatter', 680 | marker: { 681 | color: 'orange' 682 | } 683 | } 684 | var data: Plotly.Data[] = [unknown, subscribed] 685 | var layout: any = { 686 | title: 'Contributions to Video by Day', 687 | xaxis: { title: 'Day', type: 'date', 'dtick': 86400000 }, 688 | yaxis: { title: 'Contributions (' + videoQuoteCurrency + ')', rangemode: 'nonnegative', tickformat: '.1e' }, 689 | legend: { orientation: 'h', xanchor: 'right', yanchor: 'top', x: 0.99, y: 0.99 } 690 | } 691 | if (perDayX != null && perDayX.length != 0) { 692 | perDayPlot.setAttribute('style', 'width:50em;height:30em;') 693 | Plotly.newPlot(perDayPlot, data, layout) 694 | } 695 | } 696 | 697 | // Channel pie 698 | if (channelData != null) { 699 | channelPlot.setAttribute('style', 'width:50em;height:30em;') 700 | Plotly.newPlot(channelPlot, [channelData], { 701 | title: 'Per-channel Contributions' 702 | }) 703 | } 704 | } 705 | } 706 | 707 | interval(updateStatsClosure, 2500, { stopOnError: false }) 708 | 709 | console.log('web-monetization: Added stats panel') 710 | } 711 | 712 | console.log('web-monetization: Set up. Now waiting on user agent and video to start playing.') 713 | } 714 | } 715 | 716 | const metaId = 'peertube-plugin-web-monetization-meta' 717 | var enabled = false 718 | function enableMonetization () { 719 | if (enabled) { 720 | return 721 | } 722 | if (unpaid) { 723 | var meta = doc.createElement('meta') 724 | meta.name = 'monetization' 725 | meta.content = paymentPointer! 726 | meta.id = metaId 727 | doc.getElementsByTagName('head')[0].appendChild(meta) 728 | enabled = true 729 | } 730 | } 731 | 732 | function disableMonetization () { 733 | enabled = false 734 | const meta = doc.getElementById(metaId) 735 | if (meta != null) { 736 | meta.parentNode!.removeChild(meta) 737 | console.log('web-monetization: Paid ' + paid.displayTotal() + ' for this video so far') 738 | } 739 | } 740 | 741 | function cueChange () { 742 | if (chapters == null || chaptersTrack == null || (!monetized && unpaid)) { 743 | return 744 | } 745 | const xrpPaid = paid.total.xrp() 746 | const xrpRequired = adSkipCost * paid.totalTime(videoEl.currentTime) 747 | if (xrpPaid < xrpRequired) { 748 | // Set some sort of notice 749 | return 750 | } 751 | for (var i = 0; i < (chaptersTrack.activeCues || []).length; i++) { 752 | const cue = chaptersTrack.activeCues![i] 753 | var idxMatch = cue.id.match(/Chapter (.+)/) 754 | if (idxMatch == null) { 755 | console.log('web-monetization: Failed to parse cue id "' + cue.id + '" expected something like "Chapter 3"') 756 | return 757 | } 758 | var idx = parseInt(idxMatch[1]) - 1 759 | if (window.isNaN(idx)) { 760 | console.log('web-monetization: Failed to parse cue id "' + cue.id + '" could not parse integer from "' + idxMatch[1] + '", expected something like "Chapter 3"') 761 | return 762 | } 763 | if (chapters.chapters[idx] == null) { 764 | console.log('web-monetization: Failed to use cue id "' + cue.id + '" as chapter number ' + (idx + 1) + ' was not found in plugin data, there are only ' + chapters.chapters.length + 'chapters') 765 | return 766 | } 767 | const chapter = chapters.chapters[idx] 768 | if (chapter.tags.sponsor) { 769 | if (videoEl == null) { 770 | console.log('web-monetization: Failed to skip sponsor, video element is not stored') 771 | return 772 | } 773 | if (lastSeek != null && cue.startTime <= lastSeek && lastSeek <= cue.endTime) { 774 | console.log('web-monetization: Will not skip sponsor "' + chapter.name + '" (' + hms(cue.startTime) + '–' + hms(cue.endTime) + ') ' + hms(videoEl.currentTime) + ' -> ' + hms(cue.endTime) + ' as last seek was to ' + hms(lastSeek)) 775 | return 776 | } 777 | if (videoEl.currentTime < cue.endTime) { 778 | console.log('web-monetization: Skipping sponsor "' + chapter.name + '" (' + hms(cue.startTime) + '–' + hms(cue.endTime) + '), ' + hms(videoEl.currentTime) + ' -> ' + hms(cue.endTime) + ' (last seek ' + hms(lastSeek) + ')') 779 | if (paid.currentSpan != null) { 780 | paid.endSpan(videoEl.currentTime) 781 | } 782 | paidEnds = null 783 | nextPaid = null 784 | videoEl.currentTime = cue.endTime 785 | updateSpan() 786 | } 787 | } 788 | } 789 | } 790 | 791 | function updateSpan (recurse = 0) { 792 | if (videoEl == null) { return } 793 | if (20 < recurse) { 794 | console.log('web-monetization: Too much recursion in updateSpan pos:' + hms(videoEl.currentTime) + ' paidEnd:' + hms(paidEnds) + ' nextPaid:' + hms(nextPaid)) 795 | console.log(paid.display()) 796 | return 797 | } 798 | 799 | var next = paidEnds 800 | if (next == null) { 801 | next = nextPaid 802 | } 803 | if (next != null && next <= videoEl.currentTime) { 804 | paid.endSpan(videoEl.currentTime) 805 | runStartSpan(recurse + 1) 806 | // runStartSpan recurses 807 | return 808 | } 809 | if (paid.currentSpan == null) { 810 | runStartSpan(recurse + 1) 811 | // runStartSpan recurses 812 | return 813 | } 814 | 815 | if (next != null) { 816 | window.setTimeout(updateSpan, (next - videoEl.currentTime) / videoEl.playbackRate * 1000) 817 | } 818 | } 819 | 820 | function runStartSpan (recurse = 0) { 821 | const startSpan = paid.startSpan(videoEl.currentTime) 822 | nextPaid = startSpan.nextPaid || null 823 | paidEnds = startSpan.paidEnds || null 824 | unpaid = startSpan.unpaid 825 | if (unpaid) { 826 | enableMonetization() 827 | } else { 828 | disableMonetization() 829 | } 830 | updateSpan(recurse + 1) 831 | } 832 | 833 | var lastEnforcement: number | null = null 834 | async function enforceViewCost () { 835 | var currentTime = null 836 | if (videoEl != null) { 837 | currentTime = videoEl.currentTime 838 | } 839 | 840 | const totalTime = paid.totalTime(currentTime) 841 | const sessionTime = paid.getSessionTime(currentTime) 842 | 843 | var xrpPaid: number 844 | try { 845 | const amount = await paid.total.inCurrency(exchange, videoQuoteCurrencyObj) 846 | xrpPaid = 0 847 | if (amount.unverified.has(videoQuoteCurrencyObj.code)) { 848 | var x = amount.unverified.get(videoQuoteCurrencyObj.code)! 849 | xrpPaid += x.significand * 10 ** x.exponent 850 | } 851 | if (amount.verified.has(videoQuoteCurrencyObj.code)) { 852 | var x = amount.unverified.get(videoQuoteCurrencyObj.code)! 853 | xrpPaid += x.significand * 10 ** x.exponent 854 | } 855 | } catch (e) { 856 | console.error(e) 857 | xrpPaid = paid.total.xrp() 858 | } 859 | const xrpRequired = viewCost * totalTime 860 | var paidSessionAmount: Amount 861 | var xrpPaidSession: number 862 | try { 863 | const amount = await paid.sessionTotal.inCurrency(exchange, videoQuoteCurrencyObj) 864 | paidSessionAmount = amount 865 | xrpPaidSession = 0 866 | if (amount.unverified.has(videoQuoteCurrencyObj.code)) { 867 | var x = amount.unverified.get(videoQuoteCurrencyObj.code)! 868 | xrpPaidSession += x.significand * 10 ** x.exponent 869 | } 870 | if (amount.verified.has(videoQuoteCurrencyObj.code)) { 871 | var x = amount.unverified.get(videoQuoteCurrencyObj.code)! 872 | xrpPaidSession += x.significand * 10 ** x.exponent 873 | } 874 | } catch (e) { 875 | console.error(e) 876 | xrpPaidSession = paid.sessionTotal.xrp() 877 | paidSessionAmount = paid.sessionTotal 878 | } 879 | const xrpRequiredSession = viewCost * sessionTime 880 | 881 | // Allow time for Web Monetization to begin 882 | if (doc.monetization != null && 883 | (sessionTime < 6 || 884 | (sessionTime < 12 && (0.85 * xrpRequired < xrpPaid || 0.85 * xrpRequiredSession < xrpPaidSession)) 885 | )) { 886 | // 887 | } else { 888 | // Don't repeatedly show the modal if the video is paused 889 | if ((xrpPaid < xrpRequired && xrpPaidSession < xrpRequiredSession || doc.monetization == null) && lastEnforcement != currentTime) { 890 | videoEl.pause() 891 | lastEnforcement = currentTime 892 | if (ptHelpers == null) { 893 | } else { 894 | var costAmount = new Amount(true) 895 | var significand = viewCost 896 | var exponent = 0 897 | while (significand * 0.001 < Math.abs(significand - (significand >> 0))) { 898 | significand *= 10 899 | exponent -= 1 900 | } 901 | significand >>= 0 902 | costAmount.depositUnchecked(significand, exponent, videoQuoteCurrency, true, null) 903 | 904 | if (doc.monetization == null) { 905 | ptHelpers.showModal({ 906 | title: await ptHelpers.translate('Viewing this video requires payment through Web Monetization'), 907 | content: await ptHelpers.translate('See https://webmonetization.org for more information.') + 908 | ' ' + costAmount.display(1) + ' ' + await ptHelpers.translate('is required.'), 909 | close: true 910 | }) 911 | } else { 912 | ptHelpers.showModal({ 913 | title: await ptHelpers.translate('Viewing this video requires a higher pay rate'), 914 | content: await ptHelpers.translate('You have paid ') + paidSessionAmount.display(sessionTime) + '. ' + 915 | await ptHelpers.translate('This video requires ') + costAmount.display(1) + '.', 916 | close: true 917 | }) 918 | } 919 | } 920 | } 921 | } 922 | 923 | setTimeout(() => { 924 | enforceViewCost().then(() => { 925 | }) 926 | }, 3000) 927 | } 928 | 929 | var pushViewedSegmentsPendingNonce: string | null = null 930 | var pushViewedSegmentsPendingSince: number | null = null 931 | var totalPaidWhenSubmitted: Amount | null = null 932 | function pushViewedSegments () { 933 | if (pushViewedSegmentsPendingSince != null && Date.now() - pushViewedSegmentsPendingSince < 60 * 1000) { 934 | return 935 | } 936 | const instant = videoEl.currentTime 937 | const changes = paid.serializeChanges(instant) 938 | 939 | var subscribed = false 940 | if (doc.getElementsByClassName('subscribe-button').length == 0 && doc.getElementsByClassName('unsubscribe-button').length == 1) { 941 | subscribed = true 942 | } 943 | 944 | var headers: Record | null = null 945 | if (ptHelpers != null) { 946 | headers = ptHelpers.getAuthHeader() || null 947 | } 948 | if (headers == null) { 949 | if (!statsTracking) { 950 | return 951 | } 952 | // Not logged in. Still submit to histogram 953 | pushViewedSegmentsPendingNonce = paid.nonce 954 | pushViewedSegmentsPendingSince = Date.now() 955 | 956 | const body: StatsHistogramUpdatePost = { receipts: receipts.serialize(), histogram: changes.histogram, subscribed: false } 957 | fetch(baseStaticRoute.slice(0, baseStaticRoute.lastIndexOf('/') + 1) + 'router/stats/histogram_update/' + videoId, { 958 | method: 'POST', 959 | headers: { 'content-type': 'application/json; charset=utf-8' }, 960 | body: JSON.stringify(body) 961 | }).then(res => res.json()) 962 | .then(dataRaw => { 963 | if (dataRaw.committed == null) { 964 | throw 'web-monetization: /stats/histogram_update gave no `committed`' 965 | } 966 | const data: { committed: SerializedHistogramBinUncommitted[] } = dataRaw 967 | paid.removeCommittedChanges(VideoPaid.deserializeHistogramChanges(data.committed)) 968 | pushViewedSegmentsPendingNonce = null 969 | pushViewedSegmentsPendingSince = null 970 | }) 971 | return 972 | } 973 | headers['content-type'] = 'application/json; charset=utf-8' 974 | pushViewedSegmentsPendingNonce = paid.nonce 975 | pushViewedSegmentsPendingSince = Date.now() 976 | totalPaidWhenSubmitted = paid.total 977 | 978 | const reqBody: StatsViewPost = { receipts: receipts.serialize(), changes: changes, subscribed: subscribed } 979 | fetch(baseStaticRoute.slice(0, baseStaticRoute.lastIndexOf('/') + 1) + 'router/stats/view/' + videoId, { 980 | method: 'POST', 981 | headers: headers, 982 | body: JSON.stringify(reqBody) 983 | }).then(res => res.json()) 984 | .then((data: SerializedState) => { 985 | if (data.currentState == null) { 986 | throw 'web-monetization: /stats/view gave no `currentState`' 987 | } 988 | if (data.committedChanges == null) { 989 | throw 'web-monetization: /stats/view gave no `committedChanges`' 990 | } 991 | 992 | if (data.optOut) { 993 | statsTracking = false 994 | } 995 | 996 | const recvdState = VideoPaidStorage.deserialize(data.currentState) 997 | if (totalPaidWhenSubmitted != null) { 998 | paid.total.subtract(totalPaidWhenSubmitted) 999 | totalPaidWhenSubmitted = null 1000 | paid.total.addFrom(recvdState.total) 1001 | } else { 1002 | console.error('totalPaidWhenSubmitted is null') 1003 | } 1004 | 1005 | paid.removeCommittedChanges(VideoPaid.deserializeChanges(data.committedChanges)) 1006 | paid.updateState(recvdState) 1007 | pushViewedSegmentsPendingNonce = null 1008 | pushViewedSegmentsPendingSince = null 1009 | }) 1010 | } 1011 | -------------------------------------------------------------------------------- /shared/paid.ts: -------------------------------------------------------------------------------- 1 | import { hms } from './common' 2 | 3 | type UnverifiedReceipt = { 4 | significand: number, 5 | exponent: number, 6 | assetCode: string, 7 | receipt: number, 8 | } 9 | export type SerializedAmount = { 10 | unverified: [string, SingleAmount][], 11 | unverifiedReceipts: UnverifiedReceipt[], 12 | verified: [string, SingleAmount][], 13 | isReference: boolean, 14 | } 15 | 16 | type SingleAmount = { significand: number, exponent: number } 17 | // An amount of money in various assets 18 | // Either an actual account or reference value 19 | // Money cannot be created, other than by `deposit`ing 20 | // It can be added to reference values, but only moved among actual accounts 21 | // export 22 | export class Amount { 23 | unverified: Map; 24 | unverifiedReceipts: UnverifiedReceipt[]; 25 | verified: Map; 26 | isReference: boolean; 27 | 28 | constructor (isReference = false) { 29 | this.unverified = new Map() 30 | this.unverifiedReceipts = [] 31 | this.verified = new Map() 32 | // If this is only a reference amount, e.g. a sum of other values 33 | if (isReference == true) { 34 | this.isReference = true 35 | } else { 36 | this.isReference = false 37 | } 38 | } 39 | 40 | serialize (): SerializedAmount { 41 | var ret: SerializedAmount = { 42 | isReference: this.isReference, 43 | unverifiedReceipts: this.unverifiedReceipts, 44 | unverified: [...this.unverified], 45 | verified: [...this.verified], 46 | } 47 | return ret 48 | } 49 | 50 | static deserialize (obj: SerializedAmount): Amount { 51 | if (obj.isReference != true && obj.isReference != false) { 52 | throw 'Cannot deserialize `Amount`, `isReference` not `true` or `false`' 53 | } 54 | var ret = new Amount(obj.isReference) 55 | if (obj.unverifiedReceipts == null) { 56 | throw 'Cannot deserialize `Amount`, missing field `unverifiedReceipts`' 57 | } 58 | ret.unverifiedReceipts = obj.unverifiedReceipts 59 | if (obj.unverified == null) { 60 | throw 'Cannot deserialize `Amount`, missing field `unverified`' 61 | } 62 | ret.unverified = new Map(obj.unverified) 63 | if (obj.verified == null) { 64 | throw 'Cannot deserialize `Amount`, missing field `verified`' 65 | } 66 | ret.verified = new Map(obj.verified) 67 | return ret 68 | } 69 | 70 | deposit (significand: number, exponent: number, assetCode: string, verified: boolean, receipt: number | null) { 71 | if (this.isReference) { 72 | throw 'Cannot deposit to reference `Amount`' 73 | } 74 | this.depositUnchecked(significand, exponent, assetCode, verified, receipt) 75 | } 76 | 77 | depositReference (significand: number, exponent: number, assetCode: string, verified: boolean) { 78 | if (!this.isReference) { 79 | throw 'Cannot `depositReference` to non-reference `Amount`' 80 | } 81 | this.depositUnchecked(significand, exponent, assetCode, verified, null) 82 | } 83 | 84 | depositUnchecked (significand: number, exponent: number, assetCode: string, verified: boolean, receipt: number | null) { 85 | if (assetCode == null) { 86 | throw 'web-monetization: paid.js: Amount.depositUnchecked: `assetCode` cannot be null' 87 | } 88 | var destMap: Map 89 | if (verified) { 90 | destMap = this.verified 91 | } else { 92 | if (receipt != null) { 93 | this.unverifiedReceipts.push({ significand: significand, exponent: exponent, assetCode: assetCode, receipt: receipt }) 94 | } 95 | destMap = this.unverified 96 | } 97 | 98 | if (!destMap.has(assetCode)) { 99 | destMap.set(assetCode, { significand: 0, exponent: exponent }) 100 | } 101 | var dest = destMap.get(assetCode) 102 | if (exponent < dest.exponent) { 103 | dest.amount *= 10 ** (dest.exponent - exponent) 104 | dest.exponent = exponent 105 | } 106 | dest.significand += significand * 10 ** (exponent - dest.exponent) 107 | } 108 | 109 | subtractUnchecked (significand: number, exponent: number, assetCode: string, verified: boolean, allowOverdraft: boolean) { 110 | if (allowOverdraft) { 111 | throw 'Negative balance in `Amount` not supported' 112 | } 113 | 114 | if (significand == 0) { 115 | return 116 | } 117 | 118 | var destMap: Map 119 | if (verified) { 120 | destMap = this.verified 121 | } else { 122 | destMap = this.unverified 123 | } 124 | 125 | if (!destMap.has(assetCode)) { 126 | if (allowOverdraft) { 127 | destMap.set(assetCode, { significand: 0, exponent: exponent }) 128 | } else { 129 | throw 'Attempt overdraft `Amount`' 130 | } 131 | } 132 | 133 | var dest = destMap.get(assetCode) 134 | if (exponent < dest.exponent) { 135 | dest.amount *= 10 ** (dest.exponent - exponent) 136 | dest.exponent = exponent 137 | } 138 | const subtract = significand * 10 ** (exponent - dest.exponent) 139 | if (dest.significand < subtract * 0.999 && !allowOverdraft) { 140 | throw 'Attempt overdraft `Amount`' 141 | } 142 | dest.significand -= subtract 143 | } 144 | 145 | async verifyReceipts () {} 146 | 147 | acceptReceipts () { 148 | /*while (this.verifiedReceipts.length != 0) { 149 | var res = receipts.retrieve(this.verifiedReceipts[0].receipt) 150 | if (res != null) { 151 | this.unverifiedReceipts.shift() 152 | console.log('TODO: verify') 153 | } 154 | }*/ 155 | } 156 | 157 | moveFrom (other: Amount) { 158 | if (this.isReference) { 159 | throw 'Attempt to move money into reference value.' 160 | } 161 | if (other.isReference) { 162 | throw 'Attempt to move money from reference value.' 163 | } 164 | for (const [assetCode, { significand, exponent }] of other.verified) { 165 | this.deposit(significand, exponent, assetCode, true, null) 166 | } 167 | other.verified = new Map() 168 | for (const [assetCode, { significand, exponent }] of other.unverified) { 169 | this.deposit(significand, exponent, assetCode, false, null) 170 | } 171 | other.unverified = new Map() 172 | for (var i = other.unverifiedReceipts.pop(); i != null; i = other.unverifiedReceipts.pop()) { 173 | this.unverifiedReceipts.push(i) 174 | } 175 | } 176 | 177 | moveFromMakeReference (other: Amount) { 178 | if (this.isReference) { 179 | throw 'Attempt to move money into reference value.' 180 | } 181 | if (other.isReference) { 182 | throw 'Attempt to move money from reference value.' 183 | } 184 | other.isReference = true 185 | for (const [assetCode, { significand, exponent }] of other.verified) { 186 | this.deposit(significand, exponent, assetCode, true, null) 187 | } 188 | for (const [assetCode, { significand, exponent }] of other.unverified) { 189 | this.deposit(significand, exponent, assetCode, false, null) 190 | } 191 | for (var i = 0; i < other.unverifiedReceipts.length; i++) { 192 | this.unverifiedReceipts.push(other.unverifiedReceipts[i]) 193 | } 194 | } 195 | 196 | addFrom (other: Amount) { 197 | if (!this.isReference) { 198 | throw 'Attempt to add to non-reference money amount.' 199 | } 200 | for (const [assetCode, { significand, exponent }] of other.verified) { 201 | this.depositUnchecked(significand, exponent, assetCode, true, null) 202 | } 203 | for (const [assetCode, { significand, exponent }] of other.unverified) { 204 | this.depositUnchecked(significand, exponent, assetCode, false, null) 205 | } 206 | if (other.unverifiedReceipts.length < 1000) { 207 | for (var i = 0; i < other.unverifiedReceipts.length; i++) { 208 | this.unverifiedReceipts.push(other.unverifiedReceipts[i]) 209 | } 210 | } 211 | } 212 | 213 | subtract (other: Amount, allowOverdraft: boolean = false) { 214 | for (const [assetCode, { significand, exponent }] of other.verified) { 215 | this.subtractUnchecked(significand, exponent, assetCode, true, allowOverdraft) 216 | } 217 | for (const [assetCode, { significand, exponent }] of other.unverified) { 218 | this.subtractUnchecked(significand, exponent, assetCode, false, allowOverdraft) 219 | } 220 | for (var i = 0; i < other.unverifiedReceipts.length; i++) { 221 | var removed = false 222 | for (var j = 0; j < this.unverifiedReceipts.length; j++) { 223 | if (this.unverifiedReceipts[j].receipt == other.unverifiedReceipts[i].receipt) { 224 | this.unverifiedReceipts.splice(j, 1) 225 | removed = true 226 | break 227 | } 228 | } 229 | if (!removed) { 230 | throw 'Failed to subtract receipt from `Amount`' 231 | } 232 | } 233 | } 234 | 235 | xrp () { 236 | var unverified = 0 237 | { 238 | const xrp = this.unverified.get('XRP') 239 | if (xrp != null) { 240 | unverified = xrp.significand * 10 ** xrp.exponent 241 | } 242 | } 243 | 244 | var verified = 0 245 | { 246 | const xrp = this.verified.get('XRP') 247 | if (xrp != null) { 248 | verified = xrp.significand * 10 ** xrp.exponent 249 | } 250 | } 251 | 252 | return unverified + verified 253 | } 254 | 255 | isEmpty () { 256 | for (const [_assetCode, { significand, exponent: _ }] of this.unverified) { 257 | if (significand != 0) { 258 | return false 259 | } 260 | } 261 | for (const [_assetCode, { significand, exponent: _ }] of this.verified) { 262 | if (significand != 0) { 263 | return false 264 | } 265 | } 266 | for (var i = 0; i < this.unverifiedReceipts.length; i++) { 267 | if (this.unverifiedReceipts[i].significand != 0) { 268 | return false 269 | } 270 | } 271 | return true 272 | } 273 | 274 | // Converts as much as possible to desired currency 275 | async inCurrency (exchange: Exchange, currency: Currency): Promise { 276 | var converted = new Amount(true) 277 | const maps: [Map, boolean][] = [[this.unverified, false], [this.verified, true]] 278 | for (var i = 0; i < maps.length; i++) { 279 | for (const [assetCode, { significand, exponent }] of maps[i][0]) { 280 | var base = Exchange.currencyFromInterledgerCode(assetCode) 281 | var newAssetCode: string 282 | var newAmount: number 283 | if (base == null) { 284 | console.log("Couldn't convert from " + assetCode + '. Not found in list.') 285 | newAssetCode = assetCode 286 | newAmount = significand * 10 ** exponent 287 | } else { 288 | try { 289 | const price = await exchange.getPrice(base, currency) 290 | newAssetCode = currency.code 291 | newAmount = price * (significand * 10 ** exponent) 292 | } catch (e) { 293 | console.error(e) 294 | newAssetCode = assetCode 295 | newAmount = significand * 10 ** exponent 296 | } 297 | } 298 | var newSignificand = newAmount 299 | var newExponent = 0 300 | while (newSignificand * 0.001 < Math.abs(newSignificand - (newSignificand >> 0))) { 301 | newSignificand *= 10 302 | newExponent -= 1 303 | } 304 | newSignificand >>= 0 305 | converted.depositUnchecked(newSignificand, newExponent, newAssetCode, maps[i][1], null) 306 | } 307 | } 308 | return converted 309 | } 310 | 311 | displayInDuration (duration: number): string { 312 | return this.display(duration) 313 | } 314 | 315 | display (duration: number | null = null): string { 316 | var display = '' 317 | var first = true 318 | 319 | var phony = new Amount(true) 320 | for (const [assetCode, { significand, exponent }] of this.unverified) { 321 | phony.depositUnchecked(significand, exponent, assetCode, false, null) 322 | } 323 | for (const [assetCode, { significand, exponent }] of this.verified) { 324 | phony.depositUnchecked(significand, exponent, assetCode, false, null) 325 | } 326 | 327 | for (const [assetCode, { significand, exponent }] of phony.unverified) { 328 | var currency = quoteCurrencies[assetCode.toLowerCase()] 329 | if (!first) { 330 | display += ', ' 331 | } 332 | first = false 333 | if (duration != null) { 334 | // 600 to convert from per second to per 10 minutes 335 | const rate = 600 * significand * 10 ** exponent / duration 336 | if (0.01 < rate) { 337 | if (currency != null && currency.symbol != null) { 338 | display += currency.symbol + roundTo(rate, 8) + '/10m' 339 | } else { 340 | display += roundTo(rate, 8) + ' ' + assetCode + '/10m' 341 | } 342 | } else { 343 | const rounded = roundTo(rate, 3 + -exponent) 344 | if (currency != null && currency.symbol != null) { 345 | display += currency.symbol + rounded.toExponential() + '/10m' 346 | } else { 347 | display += rounded.toExponential() + ' ' + assetCode + '/10m' 348 | } 349 | } 350 | } else { 351 | const amount = significand * 10 ** exponent 352 | if (0.01 < amount) { 353 | if (currency != null && currency.symbol != null) { 354 | display += currency.symbol + roundTo(amount, 8) 355 | } else { 356 | display += roundTo(amount, 8) + ' ' + assetCode 357 | } 358 | } else { 359 | const rounded = roundTo(amount, 3 + -exponent) 360 | if (currency != null && currency.symbol != null) { 361 | display += currency.symbol + rounded.toExponential() 362 | } else { 363 | display += rounded.toExponential() + ' ' + assetCode 364 | } 365 | } 366 | } 367 | } 368 | 369 | if (display.length == 0) { 370 | return '0' 371 | } else { 372 | return display 373 | } 374 | } 375 | } 376 | 377 | export type SerializedReceipts = { 378 | seq: number, 379 | unverified: ReceiptUnverified[], 380 | verified: ReceiptVerified[], 381 | } 382 | 383 | type ReceiptUnverified = { receipt: string, seq: number } 384 | type ReceiptVerified = { 385 | receipt: string, 386 | seq: number, 387 | verified?: boolean, 388 | amount?: Amount, 389 | spspEndpoint?: string, 390 | } 391 | export class Receipts { 392 | seq: number; 393 | unverified: ReceiptUnverified[]; 394 | verified: ReceiptVerified[]; 395 | 396 | constructor () { 397 | this.seq = 100 398 | this.unverified = [] 399 | this.verified = [] 400 | } 401 | 402 | toCheck (receipt: string): number { 403 | this.unverified.push({ receipt: receipt, seq: this.seq }) 404 | return this.seq++ 405 | } 406 | 407 | retrieve (seq: number): null | ReceiptVerified { 408 | if (this.verified.length == 0) { 409 | return null 410 | } 411 | if (seq != seq >> 0) { 412 | throw 'Receipt `retrieve` passed non-integer' 413 | } 414 | const off = seq - this.verified[0].seq 415 | if (0 <= off && off < this.verified.length) { 416 | if (this.verified[off].seq != seq) { 417 | throw 'Reqceipt `seq` error' 418 | } else { 419 | return this.verified[off] 420 | } 421 | } else { 422 | if (seq < this.verified[0].seq) { 423 | throw 'Asked for receipt ' + seq + ', but those before ' + this.verified[0] + ' were discarded' 424 | } 425 | if (this.seq < seq) { 426 | throw 'Asked for receipt ' + seq + ', but the next one is ' + this.seq 427 | } 428 | return null 429 | } 430 | } 431 | 432 | async verifyReceipts () { 433 | if (this.unverified.length != 0) { 434 | console.log('RECEIPTS FROM ' + this.unverified[0].seq + ' -> ' + this.unverified[this.unverified.length - 1].seq) 435 | } 436 | if (1000 < this.unverified.length) { 437 | console.error('TOO MANY RECEIPTS') 438 | this.unverified = [] 439 | } 440 | for (var receiptData = this.unverified.shift(); receiptData != null; receiptData = this.unverified.shift()) { 441 | // Receipts must be verified in order, or they expire 442 | console.log(receiptData.seq + ' ' + receiptData.receipt) 443 | try { 444 | const res = await window.fetch('https://webmonetization.org/api/receipts/verify', 445 | { 446 | method: 'POST', 447 | body: receiptData.receipt 448 | }) 449 | if (res.status != 200) { 450 | if (res.statusText == 'expired receipt') { 451 | // Funds cannot be verified 452 | this.verified.push({ receipt: receiptData.receipt, seq: receiptData.seq, verified: false }) 453 | continue 454 | } else if (res.status == 429) { 455 | // Too many requests 456 | console.error('429 Too many requests when verifying receipts') 457 | this.unverified.unshift(receiptData) 458 | break 459 | } else if (res.status == 400) { 460 | // Client error, skip 461 | this.verified.push({ receipt: receiptData.receipt, seq: receiptData.seq, verified: false }) 462 | console.error('400 Bad request when verifying receipts') 463 | continue 464 | } else { 465 | // this.unverified.unshift(receiptData) 466 | this.verified.push({ receipt: receiptData.receipt, seq: receiptData.seq, verified: false }) 467 | console.error('When verifying receipt: ' + res.status + ' ' + res.statusText) 468 | break 469 | } 470 | } 471 | const resObj = await res.json() 472 | console.log('receipt validate data') 473 | console.log(resObj) 474 | if (resObj.amount == null) { 475 | console.error("web-monetization: Receipt validator didn't include `amount`...") 476 | this.verified.push({ receipt: receiptData.receipt, seq: receiptData.seq, verified: false }) 477 | // This should never happen, so... assume we did something wrong and skip that receipt 478 | continue 479 | } 480 | this.verified.push({ receipt: receiptData.receipt, seq: receiptData.seq, amount: resObj.amount, spspEndpoint: resObj.spspEndpoint }) 481 | } catch (e) { 482 | console.error(e) 483 | this.unverified.unshift(receiptData) 484 | break 485 | } 486 | } 487 | } 488 | 489 | serialize (): SerializedReceipts { 490 | return { seq: this.seq, unverified: this.unverified, verified: this.verified} 491 | } 492 | 493 | static deserialize (obj: any): Receipts { 494 | var ret = new Receipts() 495 | ret.seq = obj.seq 496 | ret.unverified = obj.unverified 497 | ret.verified = obj.verified 498 | return ret 499 | } 500 | 501 | } 502 | 503 | // 15 seconds per bin 504 | const histogramBinSize = 15 505 | 506 | type Span = { 507 | // Changes not committed to database 508 | change: boolean, 509 | // Timestamp of start 510 | start: number, 511 | // Timestamp of end 512 | end: number, 513 | // Payments during this span, both committed and uncommitted 514 | paid: Amount, 515 | // Payments during this span not committed 516 | paidUncommitted: Amount, 517 | } 518 | 519 | // `bin` is only allowed when used in `VideoPaid.removeCommittedChanges` 520 | // only created by `VideoPaid.deserializeChanges` and `VideoPaid.deserializeHistogramChanges` 521 | type HistogramBin = { bin?: number, committed: Amount, uncommitted: Amount } 522 | export type SerializedHistogramBinUncommitted = { bin: number, uncommitted: SerializedAmount } 523 | 524 | type SerializedSpan = { start: number, end: number, paidUncommitted: SerializedAmount } 525 | export type SerializedChanges = { 526 | nonce: string, 527 | spans: SerializedSpan[], 528 | histogram: SerializedHistogramBinUncommitted[], 529 | } 530 | 531 | 532 | export type SerializedVideoPaid = { 533 | total: SerializedAmount, 534 | spans: { start: number, end: number, paid: SerializedAmount }[] 535 | } 536 | 537 | export type SerializedState = { 538 | currentState: SerializedVideoPaid, 539 | committedChanges: SerializedChanges, 540 | optOut: boolean, 541 | } 542 | 543 | 544 | // nonce: String; Changed after committed changes are acknowledged 545 | // total: Amount; Sum of `paid` of each span in `spans` 546 | // currentSpan: &Span; Reference to current span 547 | // currentSpanIdx: int; Index of `currentSpan` in `spans` 548 | // spans: [Span]; List of spans not in `base`, strictly increasing by `start` and not overlapping when having the same `change` value 549 | export class VideoPaid { 550 | nonce: string | null; 551 | total: Amount; 552 | sessionTime: number; 553 | sessionTotal: Amount; 554 | currentSpan: Span | null; 555 | currentSpanIdx: number | null; 556 | spans: Span[]; 557 | histogram: HistogramBin[]; 558 | 559 | constructor () { 560 | this.nonce = VideoPaid.generateNonce() 561 | this.total = new Amount(true) 562 | this.sessionTime = 0 563 | this.sessionTotal = new Amount(true) 564 | this.currentSpan = null 565 | this.currentSpanIdx = null 566 | this.spans = [] 567 | this.histogram = [] 568 | } 569 | 570 | changesEmpty (instant: number): boolean { 571 | for (var i = 0; i < this.spans.length; i++) { 572 | if (this.spans[i].change) { 573 | if (this.spans[i].start != this.spans[i].end) { 574 | if (this.spans[i].end != null || this.spans[i].start != instant) { 575 | return false 576 | } 577 | } 578 | } 579 | } 580 | return true 581 | } 582 | 583 | startSpan (instant: number): { unpaid: boolean, nextPaid?: number | null, paidEnds?: number } { 584 | if (this.currentSpan != null) { 585 | console.log('web-monetization: VideoPaid.startSpan called before endSpan, data is lost') 586 | this.currentSpan = null 587 | } 588 | 589 | var next = null 590 | for (var i = 0; i < this.spans.length; i++) { 591 | if (instant < this.spans[i].start) { 592 | this.spans.splice(i, 0, { change: true, start: instant, end: instant, paid: new Amount(false), paidUncommitted: new Amount(false) }) 593 | this.currentSpan = this.spans[i] 594 | this.currentSpanIdx = i 595 | next = this.spans[i + 1] 596 | break 597 | } else if (this.spans[i].end == null || instant <= this.spans[i].end!) { 598 | this.currentSpan = this.spans[i] 599 | this.currentSpanIdx = i 600 | if (i < this.spans.length) { 601 | next = this.spans[i + 1] 602 | } 603 | break 604 | } 605 | } 606 | 607 | if (this.currentSpan == null) { 608 | this.spans.splice(this.spans.length, 0, { change: true, start: instant, end: instant, paid: new Amount(false), paidUncommitted: new Amount(false) }) 609 | this.currentSpanIdx = this.spans.length - 1 610 | this.currentSpan = this.spans[this.currentSpanIdx] 611 | } 612 | 613 | const unpaid = this.currentSpan.end == instant 614 | if (unpaid) { 615 | if (next == null) { 616 | return { unpaid: true, nextPaid: null } 617 | } else { 618 | return { unpaid: true, nextPaid: next.start } 619 | } 620 | } else { 621 | return { unpaid: false, paidEnds: this.currentSpan.end! } 622 | } 623 | } 624 | 625 | endSpan (instant: number) { 626 | if (this.currentSpan == null || this.currentSpanIdx == null) { 627 | console.log('web-monetization: VideoPaid.endSpan called before startSpan') 628 | return 629 | } 630 | /*if (this.currentSpan.paid.isEmpty() && this.currentSpan.paidUncommitted.isEmpty()) { 631 | this.spans.splice(this.currentSpanIdx, 1) 632 | this.currentSpan = null 633 | return 634 | }*/ 635 | 636 | if (instant != null) { 637 | if (instant < this.currentSpan.start) { 638 | console.log('web-monetization: VideoPaid.endSpan called at ' + hms(instant) + ' which is earlier than span start ' + hms(this.currentSpan.start) + ', ignoring it') 639 | console.log(this.display()) 640 | debugger 641 | } 642 | if (this.currentSpan.end < instant) { 643 | this.sessionTime += instant - this.currentSpan.end 644 | } 645 | this.currentSpan.end = Math.max(this.currentSpan.end, instant) 646 | } 647 | this.currentSpan.end = Math.max(this.currentSpan.end, this.currentSpan.start) 648 | const nextIdx = this.currentSpanIdx + 1 649 | var limit = 0 650 | while (true) { 651 | if (10000 < limit) { 652 | console.log('web-monetization: VideoPaid.endSpan loop limit') 653 | break 654 | } 655 | if (nextIdx < this.spans.length) { 656 | var next = this.spans[nextIdx] 657 | if (next.start <= this.currentSpan.end + 0.001) { 658 | // The spans overlap, merge if `change` is same 659 | // if `change` differs, we merge the segments after committing change 660 | if (this.currentSpan.change == next.change) { 661 | this.currentSpan.end = next.end 662 | this.currentSpan.paid.moveFrom(next.paid) 663 | this.spans.splice(nextIdx, 1) 664 | continue 665 | } 666 | } 667 | } 668 | break 669 | } 670 | this.currentSpan = null 671 | } 672 | 673 | deposit (instant: number | null, significand: number, exponent: number, assetCode: string, receipt: any) { 674 | if (significand == 0) { 675 | return 676 | } 677 | this.total.depositReference(significand, exponent, assetCode, false) 678 | this.sessionTotal.depositReference(significand, exponent, assetCode, false) 679 | if (this.currentSpan == null) { 680 | console.log('web-monetization: VideoPaid.deposit without currentSpan') 681 | } else { 682 | this.currentSpan.paidUncommitted.deposit(significand, exponent, assetCode, false, receipt) 683 | this.currentSpan.change = true 684 | if (instant != null) { 685 | if (this.currentSpan.end == null) { 686 | if (this.currentSpan.start < instant) { 687 | this.sessionTime += instant - this.currentSpan.start 688 | } 689 | } else if (this.currentSpan.end < instant) { 690 | this.sessionTime += instant - this.currentSpan.end 691 | } 692 | 693 | if (this.currentSpan.end == null) { 694 | this.currentSpan.end = instant 695 | } else { 696 | this.currentSpan.end = Math.max(this.currentSpan.end, instant) 697 | } 698 | } 699 | } 700 | 701 | if (instant != null) { 702 | const bin = (instant / histogramBinSize) >> 0 703 | while (this.histogram.length <= bin) { 704 | this.histogram.push({ committed: new Amount(false), uncommitted: new Amount(false) }) 705 | } 706 | // Receipt is used above, cannot be verified twice 707 | // TODO: may be possible to have it reference other receipt 708 | this.histogram[bin].uncommitted.deposit(significand, exponent, assetCode, false, null) 709 | } 710 | } 711 | 712 | totalTime (instant: number | null): number { 713 | var sum = 0 714 | for (const span of this.spans) { 715 | sum += span.end - span.start 716 | } 717 | if (instant != null && this.currentSpan != null && this.currentSpan.end < instant) { 718 | sum += instant - this.currentSpan.end 719 | } 720 | return sum 721 | } 722 | 723 | getSessionTime (instant: number | null): number { 724 | if (instant != null && this.currentSpan != null && this.currentSpan.end < instant) { 725 | return this.sessionTime + instant - this.currentSpan.end 726 | } else { 727 | return this.sessionTime 728 | } 729 | } 730 | 731 | displayTotal (): string { 732 | return this.total.display() 733 | } 734 | 735 | display (): string { 736 | var display = '' 737 | for (const span of this.spans) { 738 | if (span.change) { 739 | display += hms(span.start) + ' +++> ' 740 | } else { 741 | display += hms(span.start) + ' ---> ' 742 | } 743 | if (span.end == null) { 744 | display += 'playing...' 745 | } else { 746 | display += hms(span.end) 747 | } 748 | display += ' ' + span.paid.display() 749 | if (span.end != null) { 750 | display += ' (' + span.paid.displayInDuration(span.end - span.start) + ')' 751 | } 752 | display += '\n' 753 | } 754 | if (display.length == 0) { 755 | return 'No spans' 756 | } else { 757 | return display 758 | } 759 | } 760 | 761 | static generateNonce () { 762 | var nonce = '' 763 | var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 764 | for (var i = 0; i < 64; i++) { 765 | nonce += possible.charAt(Math.floor(Math.random() * possible.length)) 766 | } 767 | return nonce 768 | } 769 | 770 | removeCommittedChanges (committed: VideoPaid) { 771 | // committed nonce is null when only histogram (which should be changed) 772 | if (committed.nonce != null && committed.nonce != this.nonce) { 773 | throw '`VideoPaid.removeCommittedChanges` nonces differ ' + committed.nonce + ' ' + this.nonce 774 | } 775 | this.nonce = VideoPaid.generateNonce() 776 | 777 | var c = committed 778 | for (var i = 0; i < c.spans.length; i++) { 779 | for (var j = 0; j < this.spans.length; /* manually increment */) { 780 | if (!this.spans[j].change) { 781 | j += 1 782 | continue 783 | } 784 | 785 | if (!((this.spans[j].start <= c.spans[i].start && c.spans[i].start <= this.spans[j].end) 786 | || (this.spans[j].start <= c.spans[i].end && c.spans[i].end <= this.spans[j].end))) { 787 | // `c` span does not overlaps `this` span 788 | j += 1 789 | continue 790 | } 791 | 792 | this.spans[j].paidUncommitted.subtract(c.spans[i].paidUncommitted) 793 | if (Math.abs(c.spans[i].start - this.spans[j].start) < 0.01) { 794 | // Start's are identical 795 | if (Math.abs(c.spans[i].end - this.spans[j].end) < 0.01) { 796 | // Ends are the same 797 | if (this.spans[j].paidUncommitted.isEmpty()) { 798 | this.spans.splice(j, 1) 799 | } 800 | } else { 801 | // Ends differ 802 | if (c.spans[i].end < this.spans[j].end) { 803 | this.spans[j].start = c.spans[i].end 804 | } else { 805 | throw 'Committed ends after uncommitted span' 806 | } 807 | } 808 | } else { 809 | // Starts differ 810 | if (this.spans[j].start < c.spans[i].start) { 811 | if (Math.abs(c.spans[i].end - this.spans[j].end) < 0.01) { 812 | this.spans[j].end = c.spans[i].start 813 | } else if (c.spans[i].end < this.spans[j].end) { 814 | throw 'VideoPaid unreachable 78458' 815 | } else { 816 | throw 'VideoPaid unreachable 54899' 817 | } 818 | } else { 819 | throw 'Committed starts before uncommitted span' 820 | } 821 | } 822 | } 823 | } 824 | 825 | for (var i = 0; i < c.histogram.length; i++) { 826 | var bin = c.histogram[i] 827 | var binIdx = bin.bin || i 828 | var amount = bin.uncommitted 829 | this.histogram[binIdx].uncommitted.subtract(amount) 830 | this.histogram[binIdx].committed.moveFrom(amount) 831 | } 832 | } 833 | 834 | updateState (state: VideoPaidStorage) { 835 | for (var i = 0; i < this.spans.length; i++) { 836 | if (this.spans[i].change == false) { 837 | this.spans.splice(i, 1) 838 | i-- 839 | } 840 | } 841 | for (var i = 0; i < state.spans.length; i++) { 842 | var insertAt = null 843 | for (var j = 0; j < this.spans.length; j++) { 844 | if (state.spans[i].start < this.spans[j].start) { 845 | insertAt = j 846 | break 847 | } 848 | } 849 | if (insertAt == null) { 850 | insertAt = this.spans.length 851 | } 852 | this.spans.splice(insertAt, 0, { 853 | change: false, 854 | start: state.spans[i].start, 855 | end: state.spans[i].end, 856 | paid: state.spans[i].paid, 857 | paidUncommitted: new Amount(false) 858 | }) 859 | } 860 | } 861 | 862 | serializeChanges (instant: number): SerializedChanges { 863 | if (this.currentSpan != null) { 864 | if (this.currentSpan.end == null || this.currentSpan.end < instant) { 865 | this.currentSpan.end = instant 866 | this.currentSpan.change = true 867 | } 868 | } 869 | if (this.nonce == null) { 870 | throw 'nonce cannot be null in `VideoPaid.serializeChanges`' 871 | } 872 | var changes: SerializedChanges = { nonce: this.nonce, spans: [], histogram: [] } 873 | for (var i = 0; i < this.spans.length; i++) { 874 | if (this.spans[i].change) { 875 | changes.spans.push({ 876 | start: this.spans[i].start, 877 | end: this.spans[i].end, 878 | paidUncommitted: this.spans[i].paidUncommitted.serialize() 879 | }) 880 | } 881 | } 882 | 883 | // 480 bins for a 2 hr movie 884 | // TODO: maybe optimize for insanely long videos 885 | for (var i = 0; i < this.histogram.length; i++) { 886 | if (!this.histogram[i].uncommitted.isEmpty()) { 887 | changes.histogram.push({ bin: i, uncommitted: this.histogram[i].uncommitted.serialize() }) 888 | } 889 | } 890 | 891 | return changes 892 | } 893 | 894 | static deserializeHistogramChanges (obj: SerializedHistogramBinUncommitted[]): VideoPaid { 895 | var ret = new VideoPaid() 896 | ret.nonce = null 897 | for (var i = 0; i < obj.length; i++) { 898 | ret.histogram.push({ 899 | bin: obj[i].bin, 900 | committed: new Amount(), 901 | uncommitted: Amount.deserialize(obj[i].uncommitted), 902 | }) 903 | } 904 | return ret 905 | } 906 | 907 | static deserializeChanges (obj: SerializedChanges): VideoPaid { 908 | var ret = new VideoPaid() 909 | // We don't deserialize the histogram, as it is more useful serialized 910 | // just remember to deserialize the `Amount`s contained 911 | for (var i = 0; i < obj.histogram.length; i++) { 912 | ret.histogram.push({ 913 | bin: obj.histogram[i].bin, 914 | committed: new Amount(), 915 | uncommitted: Amount.deserialize(obj.histogram[i].uncommitted), 916 | }) 917 | } 918 | ret.nonce = obj.nonce 919 | for (var i = 0; i < obj.spans.length; i++) { 920 | const from = obj.spans[i] 921 | ret.spans.push({ 922 | change: true, 923 | start: from.start, 924 | end: from.end, 925 | paid: new Amount(), 926 | paidUncommitted: Amount.deserialize(from.paidUncommitted) 927 | }) 928 | } 929 | 930 | return ret 931 | } 932 | } 933 | 934 | type SpanStorage = { start: number, end: number, paid: Amount } 935 | // export 936 | export class VideoPaidStorage { 937 | total: Amount; 938 | spans: SpanStorage[]; 939 | 940 | constructor () { 941 | this.total = new Amount(true) 942 | this.spans = [] 943 | } 944 | 945 | async verifyReceipts () { 946 | for (var i = 0; i < this.spans.length; i++) { 947 | await this.spans[i].paid.verifyReceipts() 948 | } 949 | this.total.acceptReceipts() 950 | } 951 | 952 | serialize (): SerializedVideoPaid { 953 | var ret: SerializedVideoPaid = { 954 | total: this.total.serialize(), 955 | spans: [] 956 | } 957 | for (var i = 0; i < this.spans.length; i++) { 958 | ret.spans.push({ 959 | start: this.spans[i].start, 960 | end: this.spans[i].end, 961 | paid: this.spans[i].paid.serialize() 962 | }) 963 | } 964 | return ret 965 | } 966 | 967 | static deserialize (obj: SerializedVideoPaid): VideoPaidStorage { 968 | var ret = new VideoPaidStorage() 969 | ret.total = Amount.deserialize(obj.total) 970 | for (var i = 0; i < obj.spans.length; i++) { 971 | ret.spans.push({ 972 | start: obj.spans[i].start, 973 | end: obj.spans[i].end, 974 | paid: Amount.deserialize(obj.spans[i].paid) 975 | }) 976 | } 977 | return ret 978 | } 979 | 980 | // Merge in changes `c` serialized from `VideoPaid`, 981 | commitChanges (c: SerializedChanges) { 982 | // assumes that `spans` in `this` is sorted such that the spans strictly increase by `start` and do not overlap 983 | var changed = false 984 | for (var i = 0; i < c.spans.length; i++) { 985 | const paidUncommitted = Amount.deserialize(c.spans[i].paidUncommitted) 986 | // Span is empty 987 | if (c.spans[i].start == c.spans[i].end && paidUncommitted.isEmpty()) { 988 | continue 989 | } 990 | var spanMerged = false 991 | for (var j = 0; j < this.spans.length; j++) { 992 | if (c.spans[i].start < this.spans[j].start) { 993 | if (c.spans[i].end < this.spans[j].start) { 994 | // c: [#####]---------- 995 | // this: ---------[####]-- 996 | // final: [#####]--[####]-- 997 | this.spans.splice(j, 0, { start: c.spans[i].start, end: c.spans[i].end, paid: paidUncommitted }) 998 | this.total.addFrom(paidUncommitted) 999 | spanMerged = true 1000 | break 1001 | } else { 1002 | // this.spans[j].start <= c.spans[i].end 1003 | // c: [#####]---------- 1004 | // this: ----[####]------- 1005 | // final: [########]------- 1006 | this.spans[j].start = c.spans[i].start 1007 | this.spans[j].paid.moveFromMakeReference(paidUncommitted) 1008 | this.total.addFrom(paidUncommitted) 1009 | spanMerged = true 1010 | break 1011 | } 1012 | } else { 1013 | if (this.spans[j].end < c.spans[i].start) { 1014 | // c: ---------[#####]- 1015 | // this: --[####]-???????? 1016 | continue 1017 | } else { 1018 | // c.spans[i].start <= this.spans[j].end 1019 | // c: ------[#####]---- 1020 | // this: --[####]-???????? 1021 | // final A: --[#########]---- 1022 | // final B: --[#############] 1023 | this.spans[j].end = c.spans[i].end 1024 | this.spans[j].paid.moveFromMakeReference(paidUncommitted) 1025 | this.total.addFrom(paidUncommitted) 1026 | // Now, we must handle a case such as: 1027 | // c: ------[#####]---- 1028 | // this: --[####]-[#][###] 1029 | // as currently, our merged segment is overlapping 1030 | while (j + 1 < this.spans.length && this.spans[j + 1].start < this.spans[j].end) { 1031 | this.spans[j].end = this.spans[j + 1].end 1032 | this.spans[j].paid.moveFromMakeReference(this.spans[j + 1].paid) 1033 | // Do not add `paid` to total as it is already accounted for 1034 | this.spans.splice(j + 1, 1) 1035 | } 1036 | spanMerged = true 1037 | break 1038 | } 1039 | } 1040 | } 1041 | if (!spanMerged) { 1042 | // c: ---------[#####]- 1043 | // this: --[####]--------- 1044 | // final: --[####] [#####]- 1045 | this.spans.push({ start: c.spans[i].start, end: c.spans[i].end, paid: paidUncommitted }) 1046 | this.total.addFrom(paidUncommitted) 1047 | } 1048 | changed = true 1049 | } 1050 | return changed 1051 | } 1052 | } 1053 | 1054 | function roundTo (value: number, places: number): number { 1055 | const precision = 10 ** Math.ceil(places) 1056 | return Math.round(value * precision) / precision 1057 | } 1058 | 1059 | // Currencies supported by CoinGecko. They may change them, but we would need to update the additional details anyway. 1060 | // `coinGeckoId` only listed when supported by API 1061 | export type Currency = { 1062 | coinGeckoQuote: string, 1063 | coinGeckoId: string | null, 1064 | network: string, 1065 | nameSingular: string, 1066 | namePlural: string, 1067 | code: string, 1068 | symbol: string | null, 1069 | } 1070 | 1071 | export const quoteCurrencies: Record = { 1072 | btc: { coinGeckoQuote: 'btc', coinGeckoId: 'bitcoin', network: 'Bitcoin', nameSingular: 'bitcoin', namePlural: 'bitcoins', code: 'BTC', symbol: '₿' }, 1073 | eth: { coinGeckoQuote: 'eth', coinGeckoId: 'ethereum', network: 'Ethereum', nameSingular: 'ether', namePlural: 'ether', code: 'ETH', symbol: 'Ξ' }, 1074 | ltc: { coinGeckoQuote: 'ltc', coinGeckoId: 'litecoin', network: 'Litecoin', nameSingular: 'litecoin', namePlural: 'litecoins', code: 'LTC', symbol: null }, 1075 | bch: { coinGeckoQuote: 'bch', coinGeckoId: 'bitcoin-cash', network: 'Bitcoin Cash', nameSingular: 'bitcoin cash', namePlural: 'bitcoin cash', code: 'BCH', symbol: null }, 1076 | bnb: { coinGeckoQuote: 'bnb', coinGeckoId: null, network: 'Binance coin', nameSingular: 'Binance coin', namePlural: 'Binance coins', code: 'BNB', symbol: null }, 1077 | eos: { coinGeckoQuote: 'eos', coinGeckoId: 'eos', network: 'EOS', nameSingular: 'EOS', namePlural: 'EOS', code: 'EOS', symbol: null }, 1078 | xrp: { coinGeckoQuote: 'xrp', coinGeckoId: 'ripple', network: 'RippleNet', nameSingular: 'XRP', namePlural: 'XRP', code: 'XRP', symbol: null }, 1079 | xlm: { coinGeckoQuote: 'xlm', coinGeckoId: 'stellar', network: 'Stellar', nameSingular: 'lumen', namePlural: 'lumens', code: 'XLM', symbol: null }, 1080 | link: { coinGeckoQuote: 'link', coinGeckoId: 'chainlink', network: 'Chainlink', nameSingular: 'Chainlink token', namePlural: 'Chainlink tokens', code: 'LINK', symbol: null }, 1081 | dot: { coinGeckoQuote: 'dot', coinGeckoId: 'polkadot', network: 'Polkadot', nameSingular: 'DOT', namePlural: 'DOT', code: 'DOT', symbol: null }, 1082 | yfi: { coinGeckoQuote: 'yfi', coinGeckoId: 'yearn-finance', network: 'yearn.finance', nameSingular: 'YFI', namePlural: 'YFI', code: 'YFI', symbol: null }, 1083 | usd: { coinGeckoQuote: 'usd', coinGeckoId: 'usd-coin', network: 'US dollar', nameSingular: 'dollar', namePlural: 'dollars', code: 'USD', symbol: '$' }, 1084 | aed: { coinGeckoQuote: 'aed', coinGeckoId: null, network: 'Emirati dirham', nameSingular: 'dirham', namePlural: 'dirhams', code: 'AED', symbol: 'د.إ' }, 1085 | ars: { coinGeckoQuote: 'ars', coinGeckoId: null, network: 'Argentine peso', nameSingular: 'peso', namePlural: 'pesos', code: 'ARS', symbol: '$m/n' }, 1086 | aud: { coinGeckoQuote: 'aud', coinGeckoId: null, network: 'Australian dollar', nameSingular: 'dollar', namePlural: 'dollars', code: 'AUD', symbol: 'AU$' }, 1087 | bdt: { coinGeckoQuote: 'bdt', coinGeckoId: null, network: 'Bangladeshi taka', nameSingular: 'taka', namePlural: 'takas', code: 'BDT', symbol: '৳' }, 1088 | bhd: { coinGeckoQuote: 'bhd', coinGeckoId: null, network: 'Bahraini dinar', nameSingular: 'dinar', namePlural: 'dinars', code: 'BHD', symbol: 'BD ' }, 1089 | bmd: { coinGeckoQuote: 'bmd', coinGeckoId: null, network: 'Bermudan dollar', nameSingular: 'dollar', namePlural: 'dollars', code: 'BMD', symbol: 'BD$' }, 1090 | brl: { coinGeckoQuote: 'brl', coinGeckoId: null, network: 'Brailian real', nameSingular: 'real', namePlural: 'reals', code: 'BRL', symbol: 'R$' }, 1091 | cad: { coinGeckoQuote: 'cad', coinGeckoId: null, network: 'Canadian dollar', nameSingular: 'dollar', namePlural: 'dollars', code: 'CAD', symbol: 'CA$' }, 1092 | chf: { coinGeckoQuote: 'chf', coinGeckoId: null, network: 'Swiss Franc', nameSingular: 'franc', namePlural: 'francs', code: 'CHF', symbol: 'CHF ' }, 1093 | clp: { coinGeckoQuote: 'clp', coinGeckoId: null, network: 'Chilean peso', nameSingular: 'peso', namePlural: 'pesos', code: 'CLP', symbol: 'CLP$' }, 1094 | cny: { coinGeckoQuote: 'cny', coinGeckoId: null, network: 'Renminbi', nameSingular: 'yuan', namePlural: 'yuan', code: 'CNY', symbol: 'CN¥' }, 1095 | czk: { coinGeckoQuote: 'czk', coinGeckoId: null, network: 'Czech koruna', nameSingular: 'koruna', namePlural: 'korunas', code: 'CZK', symbol: 'Kč ' }, 1096 | dkk: { coinGeckoQuote: 'dkk', coinGeckoId: null, network: 'Danish krone', nameSingular: 'krone', namePlural: 'kroner', code: 'DKK', symbol: 'kr.' }, 1097 | eur: { coinGeckoQuote: 'eur', coinGeckoId: null, network: 'Euro', nameSingular: 'Euro', namePlural: 'Euros', code: 'EUR', symbol: '€' }, 1098 | gbp: { coinGeckoQuote: 'gbp', coinGeckoId: null, network: 'British Pound', nameSingular: 'Pound', namePlural: 'Pounds', code: 'GBP', symbol: '£' }, 1099 | hkd: { coinGeckoQuote: 'hkd', coinGeckoId: null, network: 'Hong Kong dollar', nameSingular: 'dollar', namePlural: 'dollars', code: 'HKD', symbol: 'HK$' }, 1100 | /* huf: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1101 | idr: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1102 | ils: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1103 | inr: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1104 | jpy: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1105 | krw: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1106 | kwd: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1107 | lkr: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1108 | mmk: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1109 | mxn: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1110 | myr: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1111 | ngn: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1112 | nok: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1113 | nzd: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1114 | php: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1115 | pkr: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1116 | pln: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1117 | rub: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1118 | sar: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1119 | sek: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1120 | sgd: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1121 | thb: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1122 | 'try': { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1123 | twd: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1124 | uah: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1125 | vef: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1126 | vnd: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1127 | zar: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1128 | xdr: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1129 | xag: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1130 | xau: { coinGeckoQuote: '', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null }, 1131 | bits: { coinGeckoQuote: 'bits', coinGeckoId: null, network: '', nameSingular: '', namePlural: '', code: null, symbol: null },*/ 1132 | sats: { coinGeckoQuote: 'sats', coinGeckoId: null, network: 'Satoshi', nameSingular: 'Satoshi', namePlural: 'Satoshis', code: 'sat', symbol: null } 1133 | } 1134 | 1135 | // I've only seen XRP, include others mentioned on the Interledger GitHub page, excluding EUR as it needs a trustworthy stablecoin on CoinGecko 1136 | const interledgerCurrencies: Record = { 1137 | xrp: quoteCurrencies['xrp'], 1138 | btc: quoteCurrencies['btc'], 1139 | eth: quoteCurrencies['eth'], 1140 | // CoinGecko id is of currency pegged to dollar, though not actual dollar 1141 | usd: quoteCurrencies['usd'] 1142 | 1143 | } 1144 | 1145 | export class Exchange { 1146 | apiEndpoint: string; 1147 | assets: Map>; 1148 | 1149 | constructor (apiEndpoint: string = 'https://api.coingecko.com/api') { 1150 | // TODO: Proxy client requests through server, so we don't use their API so much 1151 | this.apiEndpoint = apiEndpoint 1152 | this.assets = new Map() 1153 | } 1154 | 1155 | static currencyFromInterledgerCode (assetCode: string): Currency { 1156 | return interledgerCurrencies[assetCode.toLowerCase()] 1157 | } 1158 | 1159 | async getPrice (base: Currency, quote: Currency): Promise { 1160 | var inverse = false 1161 | if (base.coinGeckoId == null) { 1162 | if (quote.coinGeckoId != null && base.coinGeckoQuote != null) { 1163 | inverse = !inverse 1164 | const tmp = quote 1165 | quote = base 1166 | base = tmp 1167 | } else { 1168 | throw 'Base currency not supported' 1169 | } 1170 | } 1171 | if (quote.coinGeckoQuote == null) { 1172 | if (quote.coinGeckoId != null && base.coinGeckoQuote != null) { 1173 | inverse = !inverse 1174 | const tmp = quote 1175 | quote = base 1176 | base = tmp 1177 | } else { 1178 | throw 'Quote currency not supported' 1179 | } 1180 | } 1181 | 1182 | var baseData = this.assets.get(base.code) 1183 | if (baseData == null) { 1184 | var quoteData = this.assets.get(quote.code) 1185 | if (quoteData != null && quote.coinGeckoId != null && base.coinGeckoQuote != null) { 1186 | var inversePrice = quoteData.get(base.code) 1187 | if (inversePrice != null) { 1188 | inverse = !inverse 1189 | const tmp = quote 1190 | quote = base 1191 | base = tmp 1192 | baseData = quoteData 1193 | } 1194 | } 1195 | 1196 | if (baseData == null) { 1197 | this.assets.set(base.code, new Map()) 1198 | baseData = this.assets.get(base.code)! 1199 | } 1200 | } 1201 | var price = baseData.get(quote.code) 1202 | if (price == null) { 1203 | baseData.set(quote.code, { price: 0.0, lastUpdate: null }) 1204 | price = baseData.get(quote.code)! 1205 | } 1206 | 1207 | if (price.lastUpdate == null || 4 * 3600 * 1000 < Date.now() - price.lastUpdate) { 1208 | var res = await fetch(this.apiEndpoint + '/v3/simple/price?ids=' + base.coinGeckoId + '&vs_currencies=' + quote.coinGeckoQuote, { 1209 | method: 'GET', 1210 | headers: { accept: 'application/json' } 1211 | }) 1212 | const resData = await res.json() 1213 | if (resData[base.coinGeckoId!] == null || resData[base.coinGeckoId!][quote.coinGeckoQuote] == null) { 1214 | console.error(resData) 1215 | throw 'web-monetization: paid.js Exchange: API bad' 1216 | } 1217 | price.price = resData[base.coinGeckoId!][quote.coinGeckoQuote] 1218 | price.lastUpdate = Date.now() 1219 | } 1220 | 1221 | if (inverse) { 1222 | return 1 / price.price 1223 | } else { 1224 | return price.price 1225 | } 1226 | } 1227 | } 1228 | --------------------------------------------------------------------------------