SecondThundeR / AnimeGoSync

// ==UserScript==
// @name         AnimeGoSync
// @namespace    https://animego.org/anime/*
// @version      1.1.2
// @description  Simple script for syncing watch status from AnimeGO to Shikimori!
// @author       lelykmaryan (1.0) / secondthunder (1.1+)
// @match        https://animego.org/anime/*
// @icon         https://www.google.com/s2/favicons?domain=animego.org
// @grant        none
// @run-at       document-end
// @license      MIT
// ==/UserScript==

// Константы
const shikimoriURL = 'https://shikimori.one';
const animesEndpoint = `${shikimoriURL}/api/animes`;
const userRatesEndpoint = `${shikimoriURL}/api/v2/user_rates`;
const oauthEndpoint = `${shikimoriURL}/oauth/token`;
const authorizeEndpoint = `${shikimoriURL}/oauth/authorize`;
const whoamiEndpoint = `${shikimoriURL}/api/users/whoami`;
const oauthWindowParams = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=450,height=650`;

const appData = {
    clientID: 'IGtPiT4zLudryFhJMA4yPdNtZ_3T_E4GFVrma1O4KkI',
    clientSecret: 'bxTapg5D3FIoNVFwxeowYyQizqQLbN0kdITcZVOzmUo',
    redirectURI: 'https://animego.org/',
    scope: 'user_rates',
};

const grantTypes = {
    refreshToken: 'refresh_token',
    code: 'code',
    authorizationCode: 'authorization_code',
};

const extensionDivProperties = {
    false: {
        textColor: '#ff6666',
        fontWeight: 'bold',
        mainText:
            'Авторизируйтесь на Shikimori, чтобы сохранять данные в своем списке',
        btnText: 'Авторизоваться',
        btnOnClick: loginBtnOnClick,
    },
    true: {
        textColor: '#fff',
        fontWeight: 'normal',
        mainText: 'Вы зашли в Shikimori',
        btnText: 'Выйти с аккаунта',
        btnOnClick: logoutBtnOnClick,
    },
};

// Переменные-хранители
/*
    Переменная прокси для индикации входа
    Управляет пересозданием контейнера при входе/выходе из аккаунта
*/
const loginProxy = new Proxy(
    { status: false, onLoadCompleted: false },
    {
        set(target, prop, val) {
            target[prop] = val;
            if (prop === 'status' && target['onLoadCompleted'] === true) {
                rebuildExtensionDivData();
            }
        },
    }
);

/*
    Переменная прокси для данных об аниме
    Управляет заменой контента о текущем статусе просмотра аниме
    (В случае, если выполнена авторизация и инициализация при загрузке)
*/
const currentAnimeDataProxy = new Proxy(
    {
        id: undefined,
        status: undefined,
        episodes: undefined,
        myEpisodes: undefined,
        rewatches: undefined,
    },
    {
        set(target, prop, val) {
            target[prop] = val;
            if (
                prop === 'myEpisodes' &&
                loginProxy.onLoadCompleted &&
                loginProxy.status
            ) {
                updateExtensionAnimeData();
            }
        },
    }
);

// Функции-утилиты
// Генерация контейнера скрипта
function generateExtensionDiv() {
    // Получение параметров контейнера в зависимости от статуса входа в аккаунт
    const divProperties = extensionDivProperties[loginProxy.status];

    // Создание контейнера для данных из скрипта
    const extensionDiv = document.createElement('div');
    extensionDiv.setAttribute('id', 'animeGoSync');

    // Создание разделителя (для красоты)
    const customHr = document.createElement('hr');
    customHr.style.cssText = 'border-top: 1px solid rgb(70, 70, 70)';

    // Создание заголовка с названием
    const divHeading = document.createElement('h4');
    divHeading.appendChild(document.createTextNode('AnimeGoSync'));

    // Создание текста с текущими данными
    // Если выполнен вход, то выводится также ссылка на аккаунт в Shikimori
    const divInfo = document.createElement('h6');
    divInfo.setAttribute('id', 'animeGoSyncInfo');
    divInfo.style.cssText = `color: ${divProperties.textColor};`;
    divInfo.className = `mb-${loginProxy.status ? 0 : 3} font-weight-${
        divProperties.fontWeight
    }`;
    if (localStorage.getItem('shiki_nickname') !== null) {
        divInfo.innerHTML = `${
            divProperties.mainText
        }${` как <a href="${shikimoriURL}/${localStorage.getItem(
            'shiki_nickname'
        )}" target="_blank">${localStorage.getItem('shiki_nickname')}</a>`}`;
    } else {
        divInfo.appendChild(document.createTextNode(divProperties.mainText));
    }

    // Создаем контейнер с данными из списка просмотренного
    // Если вход не совершен, не создаем контейнер
    let watchStatusParentDiv;
    if (loginProxy.status) {
        watchStatusParentDiv = document.createElement('div');
        watchStatusParentDiv.className = `episode-info mb-3`;

        const watchStatusDiv = document.createElement('div');
        watchStatusDiv.className = `episode-info-item mt-2`;

        const watchStatusHeadingText = document.createElement('span');
        watchStatusHeadingText.className = 'text-player-gray mr-1';
        watchStatusHeadingText.appendChild(
            document.createTextNode('Статус аниме в списке:')
        );

        const watchStatusDataText = document.createElement('span');
        watchStatusDataText.setAttribute('id', 'currentUserRateData');
        watchStatusDataText.appendChild(
            document.createTextNode(getUserRateString())
        );

        watchStatusDiv.appendChild(watchStatusHeadingText);
        watchStatusDiv.appendChild(watchStatusDataText);
        watchStatusParentDiv.appendChild(watchStatusDiv);
    }

    // Создание кнопки для входа/выхода
    const divBtn = document.createElement('div');
    divBtn.setAttribute('id', 'animeGoSyncBtn');
    divBtn.className = 'btn btn-primary br-2 cursor-pointer';
    divBtn.onclick = divProperties.btnOnClick;
    divBtn.appendChild(document.createTextNode(divProperties.btnText));

    // Добавление элементов в родительский контейнер
    extensionDiv.appendChild(customHr);
    extensionDiv.appendChild(divHeading);
    extensionDiv.appendChild(divInfo);
    if (loginProxy.status) extensionDiv.appendChild(watchStatusParentDiv);
    extensionDiv.appendChild(divBtn);

    return extensionDiv;
}

// Установка созданного контейнера под плеером
function createAndSetExtensionDiv() {
    const newExtensionDiv = generateExtensionDiv();
    document.querySelector('.dropdown').after(newExtensionDiv);
}

// Пересборка контейнера
function rebuildExtensionDivData() {
    document.getElementById('animeGoSync').remove();
    createAndSetExtensionDiv();
}

// Обновление текста о текущем статусе просмотра аниме
function updateExtensionAnimeData() {
    const newUserRateString = getUserRateString();
    const userRateSpan = document.getElementById('currentUserRateData');
    userRateSpan.textContent = newUserRateString;
}

// Получение названия текущего аниме на странице
function getAnimeNameFromPage() {
    return document.querySelector('.list-unstyled > li').innerText;
}

// Создание нового окна для входа в аккаунта
function openNewOAuthWindow() {
    const authorizeLink =
        `${authorizeEndpoint}?` +
        new URLSearchParams({
            client_id: appData.clientID,
            redirect_uri: appData.redirectURI,
            response_type: grantTypes.code,
            scope: appData.scope,
        });
    return open(authorizeLink, 'oauthWindow', oauthWindowParams);
}

// Функция для кнопки входа
function loginBtnOnClick() {
    const oauthWindow = openNewOAuthWindow();
    const checkForOAuthInterval = setInterval(async () => {
        try {
            const windowParams = new URL(oauthWindow.location).searchParams;
            const authCode = windowParams.get('code');
            if (authCode !== null) oauthWindow.close();

            const authorizationFormData = createFormData(authCode);

            await setNewAuthData(authorizationFormData);
            await setCurrentUserData();
            await setCurrentAnimeUserRate();

            loginProxy.status = true;

            clearInterval(checkForOAuthInterval);
        } catch (e) {
            if (!(e instanceof DOMException)) {
                console.log(`There is an error: ${e}`);
                console.log(
                    `Clearing OAuth check interval. Try to login again.`
                );
                clearInterval(checkForOAuthInterval);
            }
        }
    }, 500);
}

// Убирает кастомный onClick для кнопки отметить просмотренным
function removeWatchedBtnOnClick() {
    document
        .querySelector('.video-player-bar-series-watch')
        .removeAttribute('onclick');
}

/*
    Создание тела для запроса авторизации/OAuth 2.0
    Если data не равняется null, то запрос на авторизацию иначе на OAuth 2.0
*/
function createFormData(data = null) {
    const newFormData = new FormData();
    if (data !== null) {
        newFormData.append('grant_type', grantTypes.authorizationCode);
        newFormData.append('client_id', appData.clientID);
        newFormData.append('client_secret', appData.clientSecret);
        newFormData.append('code', data);
        newFormData.append('redirect_uri', appData.redirectURI);
    } else {
        newFormData.append('grant_type', grantTypes.refreshToken);
        newFormData.append('client_id', appData.clientID);
        newFormData.append('client_secret', appData.clientSecret);
        newFormData.append(
            'refresh_token',
            localStorage.getItem('refresh_token')
        );
    }
    return newFormData;
}

// Проверка на истечение токена
function isAccessTokenExpired() {
    return Date.now() / 1000 > localStorage.getItem('expired_token');
}

// Проверка на наличие токена в localStorage
function isAccessTokenInLocalStorage() {
    return (
        String(localStorage.getItem('access_token')) != undefined &&
        localStorage.getItem('access_token') != null
    );
}

// Перевод строки статуса в локализованное значение
function getLocalizedUserRateStatus(status) {
    switch (String(status)) {
        case 'completed':
            return 'Просмотрено';
        case 'planned':
            return 'Запланировано';
        case 'watching':
            return 'Смотрю';
        case 'rewatching':
            return 'Пересматриваю';
        case 'on_hold':
            return 'Отложено';
        case 'dropped':
            return 'Брошено';
        case 'undefined':
            return 'Нет статуса';
    }
}

// Получение строкового статуса о текущем статусе аниме в списке
function getUserRateString() {
    let currentUserRateStatus = getLocalizedUserRateStatus(
        currentAnimeDataProxy.status
    );
    if (currentAnimeDataProxy.status === undefined)
        return currentUserRateStatus;
    currentUserRateStatus += ` (${currentAnimeDataProxy.myEpisodes}/${currentAnimeDataProxy.episodes} эпизодов)`;
    return currentUserRateStatus;
}

// Получение и установка данных для входа в localStorage
async function setNewAuthData(formData) {
    const oauthData = await fetchOAuthData(formData);
    localStorage.setItem(
        'expired_token',
        oauthData.created_at + oauthData.expires_in
    );
    localStorage.setItem('access_token', oauthData.access_token);
    localStorage.setItem('refresh_token', oauthData.refresh_token);
}

// Установка данных о пользователе в localStorage
function setNewUserData(userData) {
    localStorage.setItem('shiki_id', userData.id);
    localStorage.setItem('shiki_nickname', userData.nickname);
}

// Удаление данных для входа и о пользователе из localStorage
function removeLocalShikiData() {
    localStorage.removeItem('expired_token');
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    localStorage.removeItem('shiki_id');
    localStorage.removeItem('shiki_nickname');
}

// Генерация данных для отправки обновленного статуса аниме в списке
function generateUserRateBody() {
    return {
        user_rate: {
            episodes: `${currentAnimeDataProxy.myEpisodes}`,
            status: `${currentAnimeDataProxy.status}`,
            rewatches: `${currentAnimeDataProxy.rewatches}`,
            target_id: `${currentAnimeDataProxy.id}`,
            target_type: 'Anime',
            user_id: `${localStorage.getItem('shiki_id')}`,
        },
    };
}

/*
    Устанавливает нажатие на кнопку для отметки аниме как просмотренного
    Заметка: В данный момент нет хендлера кнопок отметки в списке серий аниме (Будет добавлено потом)
*/
function setWatchedBtnOnClick() {
    const watchCheckInteval = setInterval(() => {
        document.querySelector('.video-player-bar-series-watch').onclick =
            playerWatchedBtnLogic;
        clearInterval(watchCheckInteval);
    }, 1000);
}

/*
    Первая инициализация при загрузке
    Проверяется наличие токена в localStorage
    Если присутствует, получаем информацию об аниме от пользователя, обновляем статусы и показываем кнопку для выхода из аккаунта
    Иначе показываем кнопку для авторизации
*/
async function onLoadInitialization() {
    await setInitialAnimeData();
    if (!isAccessTokenInLocalStorage()) {
        createAndSetExtensionDiv();
        loginProxy.onLoadCompleted = true;
        return;
    }

    loginProxy.status = true;
    await refreshAccessToken();
    await setCurrentAnimeUserRate();
    setWatchedBtnOnClick();
    createAndSetExtensionDiv();
    loginProxy.onLoadCompleted = true;
}

/*
    Функция для кнопки выхода
    Вызывает метод для выхода из аккаунта,
    затем очищает данные из localStorage и убирает onclick
    для кнопки отметить просмотренным
*/
async function logoutBtnOnClick() {
    await fetch('https://shikimori.one/api/users/sign_out');
    removeLocalShikiData();
    removeWatchedBtnOnClick();
    loginProxy.status = false;
}

// Получение информации об аниме на текущей странице
async function fetchCurrentAnimeData() {
    const animesResponse = await fetch(
        `${animesEndpoint}?` +
            new URLSearchParams({
                search: getAnimeNameFromPage(),
            })
    );
    return await animesResponse.json();
}

// Установка начального значения об аниме на текущей странице
async function setInitialAnimeData() {
    const animeShikiData = await fetchCurrentAnimeData();
    currentAnimeDataProxy.id = animeShikiData[0].id;
    currentAnimeDataProxy.name =
        animeShikiData[0].russian || animeShikiData[0].name;
    currentAnimeDataProxy.episodes = animeShikiData[0].episodes;
}

// Получение текущих данных о профиле пользователя Shikimori
async function fetchCurrentUser() {
    const currentUserData = await fetch(whoamiEndpoint, {
        headers: {
            Authorization: `Bearer ${localStorage.getItem('access_token')}`,
        },
    });
    return await currentUserData.json();
}

// Получение и установка данных о текущем пользователе
async function setCurrentUserData() {
    const userData = await fetchCurrentUser();
    localStorage.setItem('shiki_id', userData.id);
    localStorage.setItem('shiki_nickname', userData.nickname);
}

// Запрос на обновление токена доступа
async function fetchOAuthData(formData) {
    const oauthResponse = await fetch(oauthEndpoint, {
        method: 'POST',
        body: formData,
    });
    return await oauthResponse.json();
}

/*
    Обновление токенов
    Если не устарел, пропускаем
    Иначе запрашиваем новые
    Заметка: refresh_token активен 28 дней, если он истечёт, наиболее вероятно будет ошибка, исправлю позже
*/
async function refreshAccessToken() {
    if (!isAccessTokenExpired()) return;
    const oauthFormData = createFormData();
    await setNewAuthData(oauthFormData);
}

// Получение текущего статуса просмотра аниме в профиле
async function fetchAnimeUserRate() {
    const response = await fetch(
        `${userRatesEndpoint}?` +
            new URLSearchParams({
                target_id: currentAnimeDataProxy.id,
                target_type: 'Anime',
                user_id: localStorage.getItem('shiki_id'),
            }),
        {
            headers: {
                Authorization: `Bearer ${localStorage.getItem('access_token')}`,
            },
        }
    );
    return await response.json();
}

// Установка текущего статуса просмотра аниме в профиле
async function setCurrentAnimeUserRate() {
    const animeData = await fetchAnimeUserRate();
    currentAnimeDataProxy.status = animeData[0]?.status;
    currentAnimeDataProxy.myEpisodes =
        animeData[0]?.episodes != undefined ? animeData[0]?.episodes : 0;
    currentAnimeDataProxy.rewatches =
        animeData[0]?.episodes != undefined ? animeData[0]?.rewatches : 0;
}

/*
    Логика обновления данных для кнопки отметки аниме как просмотренного в плеере
    - Если отметка была поставлена как "Не просмотрено", то игнорируем
    - Иначе устанавливаем новую серию в плеере и отправляем новый эпизод/статус аниме в профиле
    - Также есть проверка от дурака (Попытка установить серию, которая еще не вышла в Японии)

    Заметка: Возможно потом будет придуман некий откат при снятии отметки просмотрено
    (Формула: Устанавливается серия, которая находится то той, на которой была снята метка просмотрено)
*/
async function playerWatchedBtnLogic() {
    const currentWatchBtnClasses = document.querySelector(
        '.video-player-bar-series-watch'
    ).classList;
    if (currentWatchBtnClasses.contains('watched')) return;

    const iFramesList = document
        .querySelector('.video-player-online')
        .getElementsByTagName('iframe');
    if (iFramesList.length === 0) return;

    const numSeries =
        document.querySelector(
            `.video-player-bar-series-item.video-player__active`
        ) != null
            ? document
                  .querySelector(
                      `.video-player-bar-series-item.video-player__active`
                  )
                  .getAttribute('data-episode')
            : 1;
    if (+numSeries <= currentAnimeDataProxy.myEpisodes) return;

    const nextSeriesBtn = document.getElementById(
        `video-carousel-item${+numSeries}`
    );
    nextSeriesBtn != null
        ? nextSeriesBtn.querySelector(`.video-player-bar-series-item`).click()
        : false;

    currentAnimeDataProxy.status =
        +numSeries === currentAnimeDataProxy.episodes
            ? 'completed'
            : 'watching';
    currentAnimeDataProxy.myEpisodes = +numSeries;

    await sendNewUserRateStatus();
}

// Отправка обновленного статуса аниме в списке
async function sendNewUserRateStatus() {
    await fetch(userRatesEndpoint, {
        method: 'POST',
        headers: {
            Authorization: `Bearer ${localStorage.getItem('access_token')}`,
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(generateUserRateBody()),
    });
}

/*
    Ожидание загрузки всей страницы
    После загрузки страницы, идет запуск инициализации скрипта
*/
window.addEventListener('load', async () => {
    console.log('Страница загружена! Идет запуск скрипта AnimeGoSync');
    await onLoadInitialization();
});