Is Tubi: Picture in Picture [QVI] safe?

Low risk

Tubi: Picture in Picture reads your Tubi viewing history and profile data and sends them to a third-party server, metricmuse.xyz.

On startup the extension fetches your Tubi profile details and continue-watching history from Tubi's own authenticated APIs and uploads them, tagged with your Tubi account ID, to metricmuse.xyz. It also watches Tubi's built-in playback analytics traffic in the page and forwards a timeline of what you watched and when to the same server. A settings toggle reports whether you've opted out, but does not stop either upload from happening.

SVODExtensionsv1.0.15Chrome Web Store
24Risk
Who publishes it

HideApp - 39 other listings from the same operator, 1 of them carrying a finding

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
SVODExtensions
Declared legal entity
HideApp

Shared hosts - 1 hostname

Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.

svodextensions.com
Also called by 2 other listings, including ViX: Picture in Picture with Subtitles

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

Tubi profile and watch history sent to metricmuse.xyz regardless of opt-out

Code analysis shows that on every visit to tubitv.com, the extension reads your Tubi session, then uploads an account fingerprint and, on server command, your profile and watch history to metricmuse.xyz, regardless of the opt-out setting.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-506
Source
Static analysis
What actually happens
You did this

You open tubitv.com while signed into your Tubi account, with the extension installed.

The extension did this

The content script reads your Tubi session and profile ID, then posts an account fingerprint to metricmuse.xyz, a server unrelated to Tubi or Google.

Fields sent in the initial fingerprint POST to metricmuse.xyz/n/tubi/start
  • Tubi account ID
    8241093

    Identifies your specific Tubi account to the third-party server.

  • Extension install ID
    3fa85f64-5717-4562-b3fc-2c963f66afa6

    A random ID generated once and stored locally, letting metricmuse.xyz link every future request back to this browser install.

  • Device fingerprint
    hid_9c72e1a0d4f3

    A device identifier used to recognize the browser across sessions.

  • Country and language
    US / en-US

    Your approximate location and browser language.

  • Opt-out flag, reported not enforced
    optedOut: false

    Reports whether you turned the share-insights toggle off, but this value never stops the upload itself.

Commands returned by metricmuse.xyz and the Tubi data each one pulls
  1. profiles command

    You did

    metricmuse.xyz's response includes a 'profiles' command.

    The extension did

    The content script requests your Tubi account settings and profile, then uploads your gender and birth year.

  2. continue_watching command

    You did

    metricmuse.xyz's response includes a 'continue_watching' command.

    The extension did

    The content script requests your Tubi watch history and each title's metadata, then uploads the full list.

Data returned to metricmuse.xyz/n/tubi/upload per command
  • Tubi account/profile ID
    8241093

    Ties every uploaded record back to your specific Tubi account.

  • Gender
    female

    Read from your Tubi account settings and included in the profile upload.

  • Birth year
    1991

    Computed from your Tubi account birthday and included in the profile upload.

  • Watch history
    Ozark, S1E4, contentId 645221

    Every title in your Tubi continue-watching list, each enriched with its content metadata.

The code that does this

The opt-out toggle is reported to the server but never checked before uploads run

Readable version

The share-insights toggle is turned on for every new install

background.js:1-27
try {    importScripts('typ.min.js');} catch (e) {    console.error('import TYP background: ', e);}chrome.action.onClicked.addListener(function() {    let newURL = 'https://tubitv.com/'    chrome.tabs.create({ url: newURL })})chrome.runtime.onInstalled.addListener(reason => {    if (reason.reason === chrome.runtime.OnInstalledReason.INSTALL) {        chrome.storage.sync.get(['isSurveyShowed'], (result) => {            if (result.isSurveyShowed === undefined) {                chrome.storage.sync.set({ isSurveyShowed: true }, () => {                    globalThis.openThankYouPage('https://svodextensions.com/thank-you-survey/');                });            }        })    }    chrome.storage.sync.get(['shareInsights'], (result) => {        if (result.shareInsights === undefined) {            chrome.storage.sync.set({ shareInsights: true }, () => {                console.log('Default shareInsights set to true');            });        }

isOptedOut() only returns true if shareInsights was explicitly set to false

tubi.min.js:1004-1059
var isOptedOut = function() {    var _ref = Helper_async_to_generator(function() {        var SHARE_INSIGHTS_NAME, _globalThis_chrome, shareInsights, error;        return Helper_ts_generator(this, function(_state) {            switch(_state.label){                case 0:                    SHARE_INSIGHTS_NAME = "shareInsights";                    _state.label = 1;                case 1:                    _state.trys.push([                        1,                        3,                        ,                        4                    ]);                    if (!((_globalThis_chrome = globalThis.chrome) === null || _globalThis_chrome === void 0 ? void 0 : _globalThis_chrome.storage)) {                        return [                            2,                            false                        ];                    }                    return [                        4,                        chrome.storage.sync.get(SHARE_INSIGHTS_NAME)                    ];                case 2:                    shareInsights = _state.sent()[SHARE_INSIGHTS_NAME];                    if (shareInsights === false) {                        return [                            2,                            true                        ];                    }                    return [                        2,                        false                    ];                case 3:                    error = _state.sent();                    console.warn(error);                    return [                        3,                        4                    ];                case 4:                    return [                        2,                        false                    ];            }        });    });    return function isOptedOut() {        return _ref.apply(this, arguments);    };}();

InitCommandsData carries an optedOut field

tubi.min.js:2197-2218
var InitCommandsData = function InitCommandsData(accountId, profileId, extensionUserId, hid, gmtDiffSeconds, browserLanguage, dist, country, optedOut) {    "use strict";    _class_call_check(this, InitCommandsData);    InitCommandsData_define_property(this, "accountId", void 0);    InitCommandsData_define_property(this, "profileId", void 0);    InitCommandsData_define_property(this, "extensionUserId", void 0);    InitCommandsData_define_property(this, "hid", void 0);    InitCommandsData_define_property(this, "gmtDiffSeconds", void 0);    InitCommandsData_define_property(this, "browserLanguage", void 0);    InitCommandsData_define_property(this, "dist", void 0);    InitCommandsData_define_property(this, "country", void 0);    InitCommandsData_define_property(this, "optedOut", void 0);    this.accountId = accountId;    this.profileId = profileId;    this.extensionUserId = extensionUserId;    this.hid = hid;    this.gmtDiffSeconds = gmtDiffSeconds;    this.browserLanguage = browserLanguage;    this.dist = dist;    this.country = country;    this.optedOut = optedOut;};

fetchCommandsInitData reads isOptedOut() but only to fill that field

tubi.min.js:3196-3229
var fetchCommandsInitData = function() {    var _ref = Init_async_to_generator(function() {        var userId, extensionUserId, country, optedOut, accountId, profileId;        return Init_ts_generator(this, function(_state) {            switch(_state.label){                case 0:                    return [                        4,                        provideIdentity()                    ];                case 1:                    userId = _state.sent().userId;                    return [                        4,                        (0,Helper/* getOrCreateExtensionUserId */.dI)()                    ];                case 2:                    extensionUserId = _state.sent() || "";                    country = provideCountry();                    return [                        4,                        (0,Helper/* isOptedOut */.bz)()                    ];                case 3:                    optedOut = _state.sent();                    accountId = userId.toString();                    profileId = userId.toString();                    return [                        2,                        new InitCommandsData(accountId, profileId, extensionUserId, (0,Helper/* getHid */.SY)(), (0,Helper/* getLocalTimeDiff */.UL)(), (0,Helper/* getBrowserLanguage */.xd)(), (0,Dist/* getDist */.l)(), country, optedOut)                    ];            }        });

executeCommandsToSend dispatches every command with no opt-out check

tubi.min.js:3629-3677
var executeCommandsToSend = function() {    var _ref = Execute_async_to_generator(function(param) {        var commands, promises, commandsToSend;        return Execute_ts_generator(this, function(_state) {            switch(_state.label){                case 0:                    commands = param.commands;                    if (!commands) {                        return [                            2,                            []                        ];                    }                    return [                        4,                        allSettled(commands.map(getCommandData))                    ];                case 1:                    promises = _state.sent();                    return [                        4,                        logRejectedPromisesFromAll(promises)                    ];                case 2:                    _state.sent();                    commandsToSend = fetchDataFromFulfilledPromises(promises);                    return [                        2,                        commandsToSend.filter(function(commandToSend) {                            return commandToSend && commandToSend.data;                        })                    ];            }        });    });    return function executeCommandsToSend(_) {        return _ref.apply(this, arguments);    };}();var getCommandData = function(param) {    var id = param.id, type = param.type, config = param.config;    switch(type){        case CommandTypes.PROFILES:            return executeAsyncDataGetter(getProfile, id, config);        case CommandTypes.CONTINUE_WATCHING:            return executeAsyncDataGetter(getContinueWatching, id, config);
Destinations that receive your Tubi identifiers
    • metricmuse.xyz

    Receives the account fingerprint and every harvested Tubi profile or watch-history record; not a Tubi or Google domain.

    • svodextensions.com

    Receives the same extension install ID through a one-time page shown at install, linking the harvest identity to the extension vendor's own site.

Observation

Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.

Tubi playback and ad-break events forwarded to metricmuse.xyz

Code analysis shows the extension watches Tubi's own Youbora analytics beacons for play, pause, and ad-break events, then re-sends each one, tagged with your Tubi account ID, to metricmuse.xyz unless you turned off share-insights.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-200
Source
Static analysis
What actually happens
You did this

You play a video on tubitv.com, including any ad break, with the extension active.

The extension did this

The content script watches Tubi's own Youbora analytics requests with a PerformanceObserver and re-sends each one, tagged with your account ID, to metricmuse.xyz.

Fields sent per playback event to metricmuse.xyz/n/tubi/upload-event
  • Tubi account ID
    8241093

    Ties every playback event to your specific Tubi account.

  • Extension install ID
    3fa85f64-5717-4562-b3fc-2c963f66afa6

    The same persistent random ID used across every upload from this browser.

  • Event type
    adBreakStart

    Which playback moment this is: start, stop, ping, or an ad-break boundary.

  • Title metadata
    Ozark, S1E4, contentId 645221

    The specific show or movie and episode being watched, looked up by content ID.

The code that does this

The one harvest path in this extension that checks the opt-out toggle before uploading

Readable version

The PerformanceObserver callback checks isOptedOut() before mapping any beacon

tubi.min.js:4222-4273
var scrapeTubiEventData = function() {    var _ref = Scrape_async_to_generator(function() {        var extensionUserId, userId, dist, country, STATE, observer;        return Scrape_ts_generator(this, function(_state) {            switch(_state.label){                case 0:                    return [                        4,                        (0,Helper/* getOrCreateExtensionUserId */.dI)()                    ];                case 1:                    extensionUserId = _state.sent();                    return [                        4,                        provideApiProfileData()                    ];                case 2:                    userId = (_state.sent() || {}).userId;                    dist = (0,Dist/* getDist */.l)();                    country = provideCountry();                    STATE = {                        START: "init",                        STOP: "stop",                        PING: "ping",                        AD_BREAK_START: "adBreakStart",                        AD_BREAK_STOP: "adBreakStop",                        AD_START: "adStart",                        AD_STOP: "adStop"                    };                    observer = new PerformanceObserver(function() {                        var _ref = Scrape_async_to_generator(function(list, observer) {                            var entries;                            return Scrape_ts_generator(this, function(_state) {                                switch(_state.label){                                    case 0:                                        entries = list.getEntries().filter(function(entry) {                                            return entry.name.includes("youboranqs");                                        });                                        if (!(entries.length !== 0)) return [                                            3,                                            2                                        ];                                        return [                                            4,                                            (0,Helper/* isOptedOut */.bz)()                                        ];                                    case 1:                                        if (_state.sent()) {                                            return [                                                2                                            ];                                        }
Destination that receives your playback timeline
    • metricmuse.xyz

    Receives each tagged playback/ad-break event derived from Tubi's own Youbora analytics beacons; not a Tubi or Google domain.

Observation

Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.

What it can do

Permissions this extension asks for, as declared in version 1.0.15. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to.

  • Read and change your data on tubitv.com

    *://*.tubitv.com/*

  • Store data in your browser

    storage

Where it sends data

Destinations our analysis observed Tubi: Picture in Picture [QVI] contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.

  • metricmuse.xyz

    Tubi: Picture in Picture [QVI] sends data to metricmuse.xyz. 12 other extensions we have analyzed send data here.

Updated 30 September 2026jgebaclmgjdkbkoolfladnkiegnbgkkh