Документация

Аддоны для MyHub

Как написать расширение для приложения MyHub на компьютере: свою вкладку в настройках, секцию на странице тайтла, полосу в библиотеке или фоновую синхронизацию списка с внешним сервисом.

Что такое аддон

Аддон — это manifest.json и один JS-файл. Приложение MyHub исполняет код в изолированной песочнице: без файловой системы, процессов и доступа к базе приложения. В сеть аддон ходит только по HTTPS и только на домены из своего манифеста, а к библиотеке пользователя — только в пределах разрешений, которые пользователь подтвердил при установке.

Здесь описан тип extension — расширение, которое добавляет места в интерфейс приложения и может работать с библиотекой. Пример такого аддона — официальная синхронизация списка аниме с Шикимори.

Аддоны распространяются через официальный каталог MyHub: он открыт у каждого пользователя во вкладке «Аддоны → Маркетплейс», а обновления оттуда приходят сами. Как попасть в каталог — в разделе «Публикация».

Быстрый старт

Самый простой аддон — счётчик на странице тайтла. Создайте папку с двумя файлами:

my-counter/
  manifest.json
  my-counter.js
{
  "id": "my-counter",
  "name": "My counter",
  "version": "1.0.0",
  "sdk": 2,
  "type": "extension",
  "author": "You",
  "lang": "en",
  "description": { "ru": "Счётчик на странице тайтла", "en": "A counter on a title page" },
  "network": { "allowed_domains": [] },
  "contributes": [
    { "point": "item.section", "id": "counter", "icon": "exposure_plus_1",
      "label": { "ru": "Счётчик", "en": "Counter" } }
  ],
  "payload_file": "my-counter.js"
}
class Provider {
  render({ ctx }) {
    const key = 'count:' + ctx.item.item_id;
    const count = storage.get(key) || 0;
    return {
      ui: { type: 'row', align: 'between', children: [
        { type: 'text', text: String(count) },
        { type: 'button', label: '+1', icon: 'add', action: { id: 'inc' } },
      ] },
    };
  }

  onAction({ ctx, action }) {
    if (action.id !== 'inc') return {};
    const key = 'count:' + ctx.item.item_id;
    storage.set(key, (storage.get(key) || 0) + 1);
    return { effects: [{ type: 'toast', text: 'OK', tone: 'success' }] };
  }
}

Установите его в приложении: «Аддоны» → «Установить аддон» → «Для разработчиков» → «Выбрать manifest.json» и укажите файл. Код подставится из payload_file. После правок установите файл заново: новая версия заменит старую, настройки и данные storage сохранятся.

Ошибки кода видны в журнале приложения со строками [addon:id]; туда же попадает console.log.

Манифест

ЧтоОписание
idУникальный идентификатор: строчная латиница, цифры и дефис, от 2 до 41 символа.
nameНазвание для пользователя.
versionВерсия в формате semver: 1.0.0, 1.1.0-beta. Обновление предлагается, только когда версия в каталоге больше установленной.
sdkВсегда 2.
typeextension. Можно не указывать: тип выводится из contributes.
author, langАвтор и основной язык (ru, en) — для карточки в каталоге.
descriptionОписание по языкам: ru, en.
iconПубличный HTTPS-адрес или встроенная картинка PNG или WebP до 32 КиБ. SVG не принимается. При разработке можно положить файл рядом и указать icon_file.
network.allowed_domainsДомены для запросов: точный хост или *.example.com (любой поддомен). Расширению без сети хватит пустого списка.
contributesМеста в интерфейсе — раздел «Места в интерфейсе».
permissions, library_kinds, scheduleДоступ к библиотеке, фоновая работа и аккаунт с сайта — разделы ниже.
user_configПоля настроек аддона — раздел «Настройки и секреты».
payload_file, payloadПри разработке — payload_file, имя JS-файла рядом с манифестом. В каталоге код встраивается в манифест полем payload. Размер кода — до 1 МиБ.

Манифест с ошибкой не установится: приложение скажет «Неверный манифест», причина — в журнале приложения.

Места в интерфейсе

Каждое место — элемент contributes: point, id, подпись label по языкам, icon из Material Symbols и необязательные kinds и order. До 16 мест на аддон, id уникален внутри аддона.

ЧтоОписание
item.sectionСекция на странице тайтла. Нужна label.
item.actionsКнопка под обложкой тайтла.
item.infoБлок фактов на странице тайтла.
library.stripПолоса в библиотеке. Нужна label.
settings.tabВкладка в настройках приложения. Нужны label и icon.

kinds ограничивает места на странице тайтла видами: game, anime, movie, tv. Места — такое же разрешение, как домены: пользователь видит их в окне установки, и обновление, которое добавляет новое место, снова спрашивает согласия.

render и onAction

class Provider {
  // point, contribution — which place; state — what you returned last time (null at first)
  render({ point, contribution, ctx, state }) { /* → { ui, state? } */ }
  onAction({ point, contribution, ctx, state, action, form }) { /* → { ui?, state?, effects? } */ }
}
  • Каждый вызов идёт в свежей песочнице: между вызовами в памяти ничего не живёт. Что нужно помнить, возвращайте в state (до 16 КиБ, приложение передаст его обратно) или кладите в storage.
  • ctx.lang — язык интерфейса, ru или en; тексты возвращайте уже на нём. На странице тайтла есть ctx.item с полями item_id, kind, title, в библиотеке — ctx.library.kind, во вкладке настроек — ctx.settings.online.
  • render возвращает ui — дерево узлов — или null, если место сейчас не нужно показывать.
  • onAction получает action — id и необязательное value кнопки или переключателя — и form со значениями полей. Если в ответе нет ui, приложение само вызовет render с новым state.
  • Ошибку сообщайте исключением с кодом из раздела «Ограничения и ошибки»: такие коды пользователь увидит понятным текстом.

Узлы интерфейса

Неизвестный узел или узел без обязательного поля пропускается и не ломает место. Лимиты: глубина до 12, до 500 узлов, ответ до 256 КиБ, текст до 2000 символов.

ЧтоПоляОписание
stackchildren, gapКолонка дочерних узлов.
rowchildren, gap, wrap, alignСтрока дочерних узлов.
gridchildren, columnsСетка на 2–4 колонки.
divider—Разделитель.
headingtext, levelЗаголовок.
texttext, tone, sizeТекст; tone — цвет, size — размер.
imageurl, alt, ratio, widthКартинка по публичному HTTPS-адресу.
badgelabel, tone, iconМетка.
chipsitemsНабор чипов, до 32.
progressvalue, labelПолоса прогресса от 0 до 1.
emptytitle, text, iconПустое состояние.
item-carditem_id, captionКарточка тайтла из библиотеки пользователя.
buttonlabel, action, icon, variant, disabledКнопка; нажатие вызывает onAction с её action.
inputname, label, value, placeholder, multiline, maxПоле ввода, значение приходит в form.
togglename, label, value, actionПереключатель.
selectname, options, label, value, actionВыпадающий список.
segmentedname, options, value, actionПереключатель вариантов, от 2 до 8.

Эффекты

onAction может вернуть до 8 эффектов в effects.

ЧтоПоляОписание
toasttext, toneВсплывающее сообщение.
open_modaltitle, ui, sizeМодальное окно с деревом узлов; его действия приходят в тот же onAction.
close_modal—Закрыть модальное окно.
confirmtitle, text, action, confirm_label, dangerСпросить подтверждение; согласие отправляет action.
navigatepathПереход внутри приложения: только /library, /addons и /app-settings.
refreshcontributionПерерисовать место этого же аддона.
open_urlurlОткрыть ссылку в браузере — только HTTPS на домен из allowed_domains.

Хранилище

storage есть у любого аддона. Методы синхронные, значения — любой JSON.

ЧтоОписание
storage.get(key)Значение или null.
storage.set(key, value, { ttl })Записать; ttl — срок жизни в секундах.
storage.remove(key)Удалить ключ.
storage.keys(prefix)Ключи с префиксом, по алфавиту.
  • Данные свои у каждой пары «аккаунт MyHub + аддон». Обновление и выключение аддона их не трогают, удаление — стирает.
  • Значение до 64 КиБ, до 1000 ключей и до 1 МиБ на аддон; сверх — ошибка storage_full. Запись сохраняется сразу, даже если метод потом упал.

Библиотека

Разрешения перечисляются в манифесте. Пользователь видит их в окне установки, а обновление, которое просит больше, снова спрашивает согласия.

"permissions": ["library:read", "library:write", "background"],
"library_kinds": ["anime"],
"schedule": { "every_minutes": 30 }
  • library:read — читать библиотеку.
  • library:write — менять статусы, прогресс, оценки и избранное (требует library:read).
  • library:delete — просить убрать тайтлы; пользователь каждый раз подтверждает сам.
  • library_kinds — виды тайтлов, которые видит аддон: game, anime, movie, tv. Тайтлов других видов для аддона нет вовсе.

Чтение

ЧтоОписание
library.list({ kind, status, offset, limit })Страница библиотеки: items и total, до 500 строк за раз.
library.get(itemId)Строка тайтла или null.
library.externalIds(itemIds)Внешние идентификаторы тайтлов: MyAnimeList, Шикимори, AniList, IMDb, TMDB, Steam и другие.
library.findByExternal(source, ids)Найти тайтлы каталога MyHub по внешним идентификаторам, до 100 за раз.

Строка: item_id, kind, title, status, episodes_watched, episodes_total, episodes_aired, rating, favorite, private, airing.

Запись

ЧтоОписание
library.setStatus(itemId, status)Статус из набора вида тайтла.
library.setProgress(itemId, episodes)Сколько серий просмотрено — у аниме и сериалов.
library.setRating(itemId, rating)Оценка от 1 до 10.
library.setFavorite(itemId, on)Избранное: явное true или false.
library.remove(itemIds)Попросить убрать тайтлы: пользователь увидит список и решит сам.

Запись возвращает true, если изменение поставлено в очередь, и false, если значение уже такое. Изменения применяются сразу и уходят на сервер через ту же очередь, что и действия самого пользователя, — в том числе без сети.

Фоновая работа

С разрешением background приложение вызывает аддон без открытого интерфейса: по расписанию schedule.every_minutes (от 15 до 1440, по умолчанию 60) и пачками событий об изменениях библиотеки.

class Provider {
  async onSchedule({ reason }) { /* reason: 'startup' | 'interval' */ }
  async onLibraryEvents({ events, overflow }) {
    // events: [{ item_id, kind, type: 'status' | 'progress' | 'rating' | 'favorite' | 'removed',
    //            value, origin: 'user' | 'addon', at }]
  }
}
  • События — изменения на этом устройстве: от пользователя, плеера и других аддонов. Свои изменения аддон не получает.
  • События не сохраняются между запусками приложения, поэтому синхронизация обязана уметь полную сверку в onSchedule — события её только ускоряют.
  • Одновременно идёт не больше одного фонового вызова аддона. Три ошибки подряд ставят фоновую работу на паузу на час.

Аккаунт с сайта

Если пользователь привязал сервис на myhub.media (настройки → «Платформы»), аддону не нужен свой вход: приложение выдаст ему короткоживущий токен этой привязки. Пользователю ничего не нужно вводить. Сейчас так можно с Шикимори — разрешение account:shikimori.

"network": { "allowed_domains": ["shikimori.io"] },
"permissions": ["account:shikimori"]

const account = await accounts.get('shikimori');
// { provider, token, expiresAt, userId, username } or null
const me = await fetch('https://shikimori.io/api/users/whoami', {
  headers: { Authorization: 'Bearer ' + account.token },
});
if (me.status === 401) await accounts.get('shikimori', { fresh: true });
  • Токен выдаёт сервер MyHub; долгоживущие ключи остаются на сервере. Приложение держит токен в памяти до истечения; fresh: true просит новый — например, после ответа 401 от сервиса.
  • С разрешением на аккаунт в allowed_domains могут быть только домены этого сервиса: токен уходит только ему.
  • null — сервис не привязан. Покажите подсказку и кнопку с эффектом navigate на /app-settings?section=links.
  • Не пишите токен в журнал и не кладите его в storage.

Настройки и секреты

Поля настроек описываются в user_config.fields; приложение рисует для них форму в карточке аддона, а код читает значения из config.

"user_config": { "fields": [
  { "name": "api_key", "type": "secret", "label": { "ru": "Ключ API", "en": "API key" } },
  { "name": "compact", "type": "toggle", "label": { "ru": "Компактно", "en": "Compact" } }
] }
ЧтоОписание
textСтрока.
selectВыбор из options.
toggleФлажок.
secretСекрет — ключ API или пароль приложения. Хранится в системном хранилище учётных данных, а не в базе приложения; значение по умолчанию запрещено.
  • Токены, которые аддон получил сам, храните через secrets.get, secrets.set и secrets.remove: это то же системное хранилище, до 1000 символов на значение.
  • Не показывайте секреты в интерфейсе и не пишите их в журнал — только «задан» или «не задан».

Сеть

fetch работает как в браузере, но только по HTTPS и только на домены из allowed_domains. Методы — GET, POST, PUT, PATCH, DELETE; заголовок Authorization и запросы на запись уходят только на домены манифеста.

  • Ответ до 4 МиБ, таймаут 15 секунд, до 3 одновременных запросов на один хост и до 64 запросов за вызов. Текст ответа уже декодирован из кодировки страницы.
  • parseHTML разбирает HTML и даёт select по CSS-селектору, text, attr и html. Ещё доступны sleep, atob, btoa, TextEncoder, TextDecoder.
  • Нет файловой системы, локальной сети, cookie и аккаунта MyHub в запросах. Песочница — ES2020 без URL, setTimeout и модулей: один файл, адреса собираются строками.

Ограничения и ошибки

Один вызов — до 64 МиБ памяти, до 20 секунд работы кода (ожидание сети не в счёт) и до 90 секунд целиком. Зависший код останавливается, приложение продолжает работать.

Ошибку сообщайте исключением с кодом. Пользователь увидит понятный текст для таких кодов:

source_timeout
source_unavailable
source_forbidden
source_rate_limited
source_busy
source_too_large
source_moved
source_changed
invalid_request
domain_not_allowed
storage_full
permission_denied
library_conflict
not_in_library
account_changed
account_expired
account_unavailable

Любое другое исключение покажется как общая ошибка аддона, а его текст попадёт в журнал приложения.

Публикация

Официальный каталог ведёт команда MyHub. Чтобы предложить аддон, напишите нам через страницу поддержки и пришлите папку с манифестом и кодом. Мы проверяем, что аддон делает то, что заявляет, и просит только нужный ему доступ.

В каталог не принимаются аддоны, которые дают доступ к фильмам, сериалам, аниме, играм или другому контенту без разрешения правообладателей, обходят защиту сайтов или собирают данные пользователей сверх нужного для работы.

Новая версия: поднимите version и пришлите изменения. После публикации пользователи получат обновление сами; если оно просит новый доступ, приложение снова спросит их согласия.