diff --git a/addon/addon.js b/addon/addon.js
index 6f4253b..fd4eaf3 100644
--- a/addon/addon.js
+++ b/addon/addon.js
@@ -38,9 +38,9 @@ builder.defineStreamHandler((args) => {
});
builder.defineCatalogHandler((args) => {
- const mochKey = args.id.replace("torrentio-", '');
+ const [_, mochKey, catalogId] = args.id.split('-');
console.log(`Incoming catalog ${args.id} request with skip=${args.extra.skip || 0}`)
- return getMochCatalog(mochKey, args.extra)
+ return getMochCatalog(mochKey, catalogId, args.extra)
.then(metas => ({
metas: metas,
cacheMaxAge: CATALOG_CACHE_MAX_AGE
diff --git a/addon/lib/landingTemplate.js b/addon/lib/landingTemplate.js
index ba98f9f..59206d7 100644
--- a/addon/lib/landingTemplate.js
+++ b/addon/lib/landingTemplate.js
@@ -206,6 +206,7 @@ export default function landingTemplate(manifest, config = {}) {
const allDebridApiKey = config[MochOptions.alldebrid.key] || '';
const debridLinkApiKey = config[MochOptions.debridlink.key] || '';
const offcloudApiKey = config[MochOptions.offcloud.key] || '';
+ const torboxApiKey = config[MochOptions.torbox.key] || '';
const putioKey = config[MochOptions.putio.key] || '';
const putioClientId = putioKey.replace(/@.*/, '');
const putioToken = putioKey.replace(/.*@/, '');
@@ -330,6 +331,11 @@ export default function landingTemplate(manifest, config = {}) {
+
@@ -396,6 +402,7 @@ export default function landingTemplate(manifest, config = {}) {
$('#iAllDebrid').val("${allDebridApiKey}");
$('#iDebridLink').val("${debridLinkApiKey}");
$('#iOffcloud').val("${offcloudApiKey}");
+ $('#iTorbox').val("${torboxApiKey}");
$('#iPutioClientId').val("${putioClientId}");
$('#iPutioToken').val("${putioToken}");
$('#iSort').val("${sort}");
@@ -422,6 +429,7 @@ export default function landingTemplate(manifest, config = {}) {
$('#dAllDebrid').toggle(provider === '${MochOptions.alldebrid.key}');
$('#dDebridLink').toggle(provider === '${MochOptions.debridlink.key}');
$('#dOffcloud').toggle(provider === '${MochOptions.offcloud.key}');
+ $('#dTorbox').toggle(provider === '${MochOptions.torbox.key}');
$('#dPutio').toggle(provider === '${MochOptions.putio.key}');
}
@@ -439,7 +447,8 @@ export default function landingTemplate(manifest, config = {}) {
const allDebridValue = $('#iAllDebrid').val() || '';
const debridLinkValue = $('#iDebridLink').val() || ''
const premiumizeValue = $('#iPremiumize').val() || '';
- const offcloudValue = $('#iOffcloud').val() || ''
+ const offcloudValue = $('#iOffcloud').val() || '';
+ const torboxValue = $('#iTorbox').val() || '';
const putioClientIdValue = $('#iPutioClientId').val() || '';
const putioTokenValue = $('#iPutioToken').val() || '';
@@ -457,6 +466,7 @@ export default function landingTemplate(manifest, config = {}) {
const allDebrid = allDebridValue.length && allDebridValue.trim();
const debridLink = debridLinkValue.length && debridLinkValue.trim();
const offcloud = offcloudValue.length && offcloudValue.trim();
+ const torbox = torboxValue.length && torboxValue.trim();
const putio = putioClientIdValue.length && putioTokenValue.length && putioClientIdValue.trim() + '@' + putioTokenValue.trim();
const preConfigurations = {
@@ -475,6 +485,7 @@ export default function landingTemplate(manifest, config = {}) {
['${MochOptions.alldebrid.key}', allDebrid],
['${MochOptions.debridlink.key}', debridLink],
['${MochOptions.offcloud.key}', offcloud],
+ ['${MochOptions.torbox.key}', torbox],
['${MochOptions.putio.key}', putio]
].filter(([_, value]) => value.length).map(([key, value]) => key + '=' + value).join('|');
configurationValue = Object.entries(preConfigurations)
diff --git a/addon/lib/manifest.js b/addon/lib/manifest.js
index c12e64b..4141f4d 100644
--- a/addon/lib/manifest.js
+++ b/addon/lib/manifest.js
@@ -5,7 +5,7 @@ import { getManifestOverride } from './configuration.js';
import { Type } from './types.js';
const DefaultProviders = Providers.options.map(provider => provider.key);
-const CatalogMochs = Object.values(MochOptions).filter(moch => moch.catalog);
+const MochProviders = Object.values(MochOptions);
export function manifest(config = {}) {
const overrideManifest = getManifestOverride(config);
@@ -36,7 +36,7 @@ export function dummyManifest() {
function getName(manifest, config) {
const rootName = manifest?.name || 'Torrentio';
- const mochSuffix = Object.values(MochOptions)
+ const mochSuffix = MochProviders
.filter(moch => config[moch.key])
.map(moch => moch.shortName)
.join('/');
@@ -48,11 +48,11 @@ function getDescription(config) {
const enabledProvidersDesc = Providers.options
.map(provider => `${provider.label}${providersList.includes(provider.key) ? '(+)' : '(-)'}`)
.join(', ')
- const enabledMochs = Object.values(MochOptions)
+ const enabledMochs = MochProviders
.filter(moch => config[moch.key])
.map(moch => moch.name)
.join(' & ');
- const possibleMochs = Object.values(MochOptions).map(moch => moch.name).join('/')
+ const possibleMochs = MochProviders.map(moch => moch.name).join('/')
const mochsDesc = enabledMochs ? ` and ${enabledMochs} enabled` : '';
return 'Provides torrent streams from scraped torrent providers.'
+ ` Currently supports ${enabledProvidersDesc}${mochsDesc}.`
@@ -60,14 +60,15 @@ function getDescription(config) {
}
function getCatalogs(config) {
- return CatalogMochs
+ return MochProviders
.filter(moch => showDebridCatalog(config) && config[moch.key])
- .map(moch => ({
- id: `torrentio-${moch.key}`,
- name: `${moch.name}`,
+ .map(moch => moch.catalogs.map(catalogName => ({
+ id: catalogName ? `torrentio-${moch.key}-${catalogName.toLowerCase()}` : `torrentio-${moch.key}`,
+ name: catalogName ? `${moch.name} ${catalogName}` : `${moch.name}`,
type: 'other',
extra: [{ name: 'skip' }],
- }));
+ })))
+ .reduce((a, b) => a.concat(b), []);
}
function getResources(config) {
@@ -79,9 +80,9 @@ function getResources(config) {
const metaResource = {
name: 'meta',
types: [Type.OTHER],
- idPrefixes: CatalogMochs.filter(moch => config[moch.key]).map(moch => moch.key)
+ idPrefixes: MochProviders.filter(moch => config[moch.key]).map(moch => moch.key)
};
- if (showDebridCatalog(config) && CatalogMochs.filter(moch => config[moch.key]).length) {
+ if (showDebridCatalog(config) && MochProviders.filter(moch => config[moch.key]).length) {
return [streamResource, metaResource];
}
return [streamResource];
diff --git a/addon/moch/alldebrid.js b/addon/moch/alldebrid.js
index ba33225..10d0e8e 100644
--- a/addon/moch/alldebrid.js
+++ b/addon/moch/alldebrid.js
@@ -20,11 +20,11 @@ export async function getCachedStreams(streams, apiKey, ip) {
}, {})
}
-export async function getCatalog(apiKey, offset = 0, ip) {
- if (offset > 0) {
+export async function getCatalog(apiKey, catalogId, config) {
+ if (config.skip > 0) {
return [];
}
- const options = await getDefaultOptions(ip);
+ const options = await getDefaultOptions(config.ip);
const AD = new AllDebridClient(apiKey, options);
return AD.magnet.status()
.then(response => response.data.magnets)
diff --git a/addon/moch/debridlink.js b/addon/moch/debridlink.js
index b6f1e92..9a6b852 100644
--- a/addon/moch/debridlink.js
+++ b/addon/moch/debridlink.js
@@ -18,8 +18,8 @@ export async function getCachedStreams(streams, apiKey) {
}, {})
}
-export async function getCatalog(apiKey, offset = 0) {
- if (offset > 0) {
+export async function getCatalog(apiKey, catalogId, config) {
+ if (config.skip > 0) {
return [];
}
const options = await getDefaultOptions();
diff --git a/addon/moch/moch.js b/addon/moch/moch.js
index dd82be6..e64b993 100644
--- a/addon/moch/moch.js
+++ b/addon/moch/moch.js
@@ -1,10 +1,10 @@
-import namedQueue from 'named-queue';
import * as options from './options.js';
import * as realdebrid from './realdebrid.js';
import * as premiumize from './premiumize.js';
import * as alldebrid from './alldebrid.js';
import * as debridlink from './debridlink.js';
import * as offcloud from './offcloud.js';
+import * as torbox from './torbox.js';
import * as putio from './putio.js';
import StaticResponse, { isStaticUrl } from './static.js';
import { cacheWrapResolvedUrl } from '../lib/cache.js';
@@ -21,42 +21,49 @@ export const MochOptions = {
instance: realdebrid,
name: "RealDebrid",
shortName: 'RD',
- catalog: true
+ catalogs: ['']
},
premiumize: {
key: 'premiumize',
instance: premiumize,
name: 'Premiumize',
shortName: 'PM',
- catalog: true
+ catalogs: ['']
},
alldebrid: {
key: 'alldebrid',
instance: alldebrid,
name: 'AllDebrid',
shortName: 'AD',
- catalog: true
+ catalogs: ['']
},
debridlink: {
key: 'debridlink',
instance: debridlink,
name: 'DebridLink',
shortName: 'DL',
- catalog: true
+ catalogs: ['']
},
offcloud: {
key: 'offcloud',
instance: offcloud,
name: 'Offcloud',
shortName: 'OC',
- catalog: true
+ catalogs: ['']
+ },
+ torbox: {
+ key: 'torbox',
+ instance: torbox,
+ name: 'TorBox',
+ shortName: 'TB',
+ catalogs: [`Torrents`, `Usenet`, `WebDL`]
},
putio: {
key: 'putio',
instance: putio,
name: 'Put.io',
shortName: 'Putio',
- catalog: true
+ catalogs: ['']
}
};
@@ -112,7 +119,7 @@ export async function resolve(parameters) {
return unrestrictQueues[moch.key].wrap(id, method);
}
-export async function getMochCatalog(mochKey, config) {
+export async function getMochCatalog(mochKey, catalogId, config, ) {
const moch = MochOptions[mochKey];
if (!moch) {
return Promise.reject(new Error(`Not a valid moch provider: ${mochKey}`));
@@ -120,7 +127,7 @@ export async function getMochCatalog(mochKey, config) {
if (isInvalidToken(config[mochKey], mochKey)) {
return Promise.reject(new Error(`Invalid API key for moch provider: ${mochKey}`));
}
- return moch.instance.getCatalog(config[moch.key], config.skip, config.ip)
+ return moch.instance.getCatalog(config[moch.key], catalogId, config)
.catch(rawError => {
const commonError = moch.instance.toCommonError(rawError);
if (commonError === BadTokenError) {
diff --git a/addon/moch/offcloud.js b/addon/moch/offcloud.js
index 61757cd..acb0ef6 100644
--- a/addon/moch/offcloud.js
+++ b/addon/moch/offcloud.js
@@ -34,8 +34,8 @@ export async function getCachedStreams(streams, apiKey) {
}, {})
}
-export async function getCatalog(apiKey, offset = 0) {
- if (offset > 0) {
+export async function getCatalog(apiKey, catalogId, config) {
+ if (config.skip > 0) {
return [];
}
const options = await getDefaultOptions();
diff --git a/addon/moch/premiumize.js b/addon/moch/premiumize.js
index f26d7c2..128d849 100644
--- a/addon/moch/premiumize.js
+++ b/addon/moch/premiumize.js
@@ -37,8 +37,8 @@ async function _getCachedStreams(PM, apiKey, streams) {
}, {}));
}
-export async function getCatalog(apiKey, offset = 0) {
- if (offset > 0) {
+export async function getCatalog(apiKey, catalogId, config) {
+ if (config.skip > 0) {
return [];
}
const options = await getDefaultOptions();
diff --git a/addon/moch/putio.js b/addon/moch/putio.js
index 3a00575..4dc42dd 100644
--- a/addon/moch/putio.js
+++ b/addon/moch/putio.js
@@ -22,8 +22,8 @@ export async function getCachedStreams(streams, apiKey) {
}, {});
}
-export async function getCatalog(apiKey, offset = 0) {
- if (offset > 0) {
+export async function getCatalog(apiKey, catalogId, config) {
+ if (config.skip > 0) {
return [];
}
const Putio = createPutioAPI(apiKey)
diff --git a/addon/moch/realdebrid.js b/addon/moch/realdebrid.js
index 6673ab5..c8213d6 100644
--- a/addon/moch/realdebrid.js
+++ b/addon/moch/realdebrid.js
@@ -39,11 +39,8 @@ function _getCachedFileIds(fileIndex, cachedResults) {
return cachedIds || [];
}
-export async function getCatalog(apiKey, offset, ip) {
- if (offset > 0) {
- return [];
- }
- const options = await getDefaultOptions(ip);
+export async function getCatalog(apiKey, catalogId, config) {
+ const options = await getDefaultOptions(config.ip);
const RD = new RealDebridClient(apiKey, options);
const downloadsMeta = {
id: `${KEY}:${DEBRID_DOWNLOADS}`,
diff --git a/addon/moch/static.js b/addon/moch/static.js
index 4483e81..b0f079d 100644
--- a/addon/moch/static.js
+++ b/addon/moch/static.js
@@ -5,7 +5,9 @@ const staticVideoUrls = {
FAILED_RAR: `videos/failed_rar_v2.mp4`,
FAILED_OPENING: `videos/failed_opening_v2.mp4`,
FAILED_UNEXPECTED: `videos/failed_unexpected_v2.mp4`,
- FAILED_INFRINGEMENT: `videos/failed_infringement_v2.mp4`
+ FAILED_INFRINGEMENT: `videos/failed_infringement_v2.mp4`,
+ LIMITS_EXCEEDED: `videos/limits_exceeded_v1.mp4`,
+ BLOCKED_ACCESS: `videos/blocked_access_v1.mp4`,
}
diff --git a/addon/moch/torbox.js b/addon/moch/torbox.js
new file mode 100644
index 0000000..9daa75f
--- /dev/null
+++ b/addon/moch/torbox.js
@@ -0,0 +1,249 @@
+import axios from 'axios';
+import { Type } from '../lib/types.js';
+import { isVideo } from '../lib/extension.js';
+import StaticResponse from './static.js';
+import { getMagnetLink } from '../lib/magnetHelper.js';
+import { chunkArray, BadTokenError, sameFilename, streamFilename } from './mochHelper.js';
+
+const KEY = 'torbox';
+const timeout = 30000;
+const baseUrl = 'https://api.torbox.app/v1'
+
+export async function getCachedStreams(streams, apiKey, ip) {
+ const hashBatches = chunkArray(streams.map(stream => stream.infoHash), 150)
+ .map(hashes => getAvailabilityResponse(apiKey, hashes));
+ const available = await Promise.all(hashBatches)
+ .then(results => results
+ .map(data => data.map(entry => entry.hash))
+ .reduce((all, result) => all.concat(result), []))
+ .catch(error => {
+ if (toCommonError(error)) {
+ return Promise.reject(error);
+ }
+ const message = error.message || error;
+ console.warn('Failed TorBox cached torrent availability request:', message);
+ return undefined;
+ });
+ return available && streams
+ .reduce((mochStreams, stream) => {
+ const isCached = available.includes(stream.infoHash);
+ const fileName = streamFilename(stream);
+ mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
+ url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`,
+ cached: isCached
+ };
+ return mochStreams;
+ }, {})
+}
+
+export async function getCatalog(apiKey, type, config) {
+ if (config.skip > 0) {
+ return [];
+ }
+ return getItemList(apiKey, type, null, config.skip)
+ .then(items => (items || [])
+ .filter(item => statusReady(item))
+ .map(item => ({
+ id: `${KEY}:${type}-${item.id}`,
+ type: Type.OTHER,
+ name: item.name
+ })));
+}
+
+export async function getItemMeta(itemId, apiKey) {
+ const [type, id] = itemId.split('-');
+ const item = await getItemList(apiKey, type, id);
+ const createDate = item ? new Date(item.created_at) : new Date();
+ return {
+ id: `${KEY}:${itemId}`,
+ type: Type.OTHER,
+ name: item.name,
+ infoHash: item.hash,
+ videos: item.files
+ .filter(file => isVideo(file.short_name))
+ .map((file, index) => ({
+ id: `${KEY}:${itemId}:${file.id}`,
+ title: file.name,
+ released: new Date(createDate.getTime() - index).toISOString(),
+ streams: [{ url: `${apiKey}/null/${itemId}-${file.id}/null` }]
+ }))
+ }
+}
+
+export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
+ console.log(`Unrestricting TorBox ${infoHash} [${fileIndex}]`);
+ return _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip)
+ .catch(error => {
+ if (isAccessDeniedError(error)) {
+ console.log(`Access denied to TorBox ${infoHash} [${fileIndex}]`);
+ return StaticResponse.FAILED_ACCESS;
+ }
+ if (isLimitExceededError(error)) {
+ console.log(`Limits exceeded to TorBox ${infoHash} [${fileIndex}]`);
+ return StaticResponse.LIMITS_EXCEEDED;
+ }
+ return Promise.reject(`Failed TorBox adding torrent: ${JSON.stringify(error.message || error)}`);
+ });
+}
+
+async function _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip) {
+ if (infoHash === 'null') {
+ const [type, rootId, fileId] = cachedEntryInfo.split('-');
+ return getDownloadLink(apiKey, type, rootId, fileId, ip);
+ }
+ const torrent = await _createOrFindTorrent(apiKey, infoHash);
+ if (torrent && statusReady(torrent)) {
+ return _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex);
+ } else if (torrent && statusDownloading(torrent)) {
+ console.log(`Downloading to TorBox ${infoHash} [${fileIndex}]...`);
+ return StaticResponse.DOWNLOADING;
+ } else if (torrent && statusError(torrent)) {
+ console.log(`Retry failed download in TorBox ${infoHash} [${fileIndex}]...`);
+ return _retryCreateTorrent(apiKey, infoHash, cachedEntryInfo, fileIndex);
+ }
+
+ return Promise.reject(`Failed TorBox adding torrent ${JSON.stringify(torrent)}`);
+}
+
+async function _createOrFindTorrent(apiKey, infoHash) {
+ return _findTorrent(apiKey, infoHash)
+ .catch(() => _createTorrent(apiKey, infoHash));
+}
+
+async function _findTorrent(apiKey, infoHash) {
+ const torrents = await getTorrentList(apiKey);
+ const foundTorrents = torrents.filter(torrent => torrent.hash === infoHash);
+ const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent));
+ const foundTorrent = nonFailedTorrent || foundTorrents[0];
+ return foundTorrent || Promise.reject('No recent torrent found');
+}
+
+async function _createTorrent(apiKey, infoHash) {
+ const magnetLink = await getMagnetLink(infoHash);
+ return createTorrent(apiKey, magnetLink)
+ .then(data => {
+ if (data.torrent_id) {
+ return getTorrentList(apiKey, data.torrent_id);
+ }
+ if (data.queued_id) {
+ return Promise.resolve({ ...data, download_state: 'metaDL' })
+ }
+ return Promise.reject(`Unexpected create data: ${JSON.stringify(data)}`);
+ });
+}
+
+async function _retryCreateTorrent(apiKey, infoHash, cachedEntryInfo, fileIndex) {
+ const newTorrent = await _createTorrent(apiKey, infoHash);
+ return newTorrent && statusReady(newTorrent)
+ ? _unrestrictLink(apiKey, infoHash, newTorrent, cachedEntryInfo, fileIndex)
+ : StaticResponse.FAILED_DOWNLOAD;
+}
+
+async function _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex) {
+ const targetFileName = decodeURIComponent(cachedEntryInfo);
+ const videos = torrent.files
+ .filter(file => isVideo(file.short_name))
+ .sort((a, b) => b.size - a.size);
+ const targetVideo = Number.isInteger(fileIndex)
+ && videos.find(video => sameFilename(video.name, targetFileName))
+ || videos[0];
+
+ if (!targetVideo) {
+ return Promise.reject(`No TorBox file found for index ${fileIndex} in: ${JSON.stringify(torrent)}`);
+ }
+ return getDownloadLink(apiKey, 'torrents', torrent.id, targetVideo.id);
+}
+
+async function getAvailabilityResponse(apiKey, hashes) {
+ const url = `${baseUrl}/api/torrents/checkcached`;
+ const headers = getHeaders(apiKey);
+ const params = { hash: hashes.join(','), format: 'list' };
+ return axios.get(url, { params, headers, timeout })
+ .then(response => {
+ if (response.data?.success) {
+ return Promise.resolve(response.data.data || []);
+ }
+ return Promise.reject(response.data);
+ })
+ .catch(error => Promise.reject(error.response?.data || error));
+}
+
+async function createTorrent(apiKey, magnetLink){
+ const url = `${baseUrl}/api/torrents/createtorrent`
+ const headers = getHeaders(apiKey);
+ const data = new URLSearchParams();
+ data.append('magnet', magnetLink);
+ data.append('allow_zip', 'false');
+ return axios.post(url, data, { headers, timeout })
+ .then(response => {
+ if (response.data?.success) {
+ return Promise.resolve(response.data.data);
+ }
+ return Promise.reject(response.data);
+ })
+ .catch(error => Promise.reject(error.response?.data || error));
+}
+
+async function getTorrentList(apiKey, id = undefined, offset = 0) {
+ return getItemList(apiKey, 'torrents', id, offset);
+}
+
+async function getItemList(apiKey, type, id = undefined, offset = 0) {
+ const url = `${baseUrl}/api/${type}/mylist`;
+ const headers = getHeaders(apiKey);
+ const params = { id, offset };
+ return axios.get(url, { params, headers, timeout })
+ .then(response => {
+ if (response.data?.success) {
+ return Promise.resolve(response.data.data);
+ }
+ return Promise.reject(response.data);
+ })
+ .catch(error => Promise.reject(error.response?.data || error));
+}
+
+async function getDownloadLink(token, type, rootId, file_id, user_ip) {
+ const url = `${baseUrl}/api/${type}/requestdl`;
+ const params = { token, torrent_id: rootId, usenet_id: rootId, web_id: rootId, file_id, user_ip };
+ return axios.get(url, { params, timeout })
+ .then(response => {
+ if (response.data?.success) {
+ console.log(`Unrestricted TorBox ${type} [${rootId}] to ${response.data.data}`);
+ return Promise.resolve(response.data.data);
+ }
+ return Promise.reject(response.data);
+ })
+ .catch(error => Promise.reject(error.response?.data || error));
+}
+
+function getHeaders(apiKey) {
+ return { Authorization: `Bearer ${apiKey}` };
+}
+
+export function toCommonError(data) {
+ const error = data?.response?.data || data;
+ if (['AUTH_ERROR', 'BAD_TOKEN'].includes(error?.error)) {
+ return BadTokenError;
+ }
+ return undefined;
+}
+
+function statusDownloading(torrent) {
+ return ['metaDL', 'downloading', 'stalled', 'processing', 'completed'].includes(torrent?.download_state);
+}
+
+function statusError(torrent) {
+ return !torrent?.active && !torrent?.download_finished;
+}
+
+function statusReady(torrent) {
+ return torrent?.download_present;
+}
+
+function isAccessDeniedError(error) {
+ return ['AUTH_ERROR', 'BAD_TOKEN', 'PLAN_RESTRICTED_FEATURE'].includes(error?.error);
+}
+
+function isLimitExceededError(error) {
+ return ['DOWNLOAD_TOO_LARGE', 'MONTHLY_LIMIT', 'COOLDOWN_LIMIT', 'ACTIVE_LIMIT'].includes(error?.error);
+}
diff --git a/addon/static/videos/limits_exceeded_v1.mp4 b/addon/static/videos/limits_exceeded_v1.mp4
new file mode 100644
index 0000000..3e80a1e
Binary files /dev/null and b/addon/static/videos/limits_exceeded_v1.mp4 differ