Initial commit
This commit is contained in:
commit
0aa853cea3
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
.DS_Store
|
||||
.huskyrc.json
|
||||
out
|
||||
log.log
|
||||
**/node_modules
|
||||
*.pyc
|
||||
*.vsix
|
||||
envVars.txt
|
||||
**/.vscode/.ropeproject/**
|
||||
**/testFiles/**/.cache/**
|
||||
*.noseids
|
||||
.nyc_output
|
||||
.vscode-test
|
||||
__pycache__
|
||||
npm-debug.log
|
||||
**/.mypy_cache/**
|
||||
!yarn.lock
|
||||
coverage/
|
||||
cucumber-report.json
|
||||
**/.vscode-test/**
|
||||
**/.vscode test/**
|
||||
**/.vscode-smoke/**
|
||||
**/.venv*/
|
||||
venv/
|
||||
port.txt
|
||||
precommit.hook
|
||||
pythonFiles/lib/**
|
||||
pythonFiles/get-pip.py
|
||||
debug_coverage*/**
|
||||
languageServer/**
|
||||
languageServer.*/**
|
||||
bin/**
|
||||
obj/**
|
||||
.pytest_cache
|
||||
tmp/**
|
||||
.python-version
|
||||
.vs/
|
||||
test-results*.xml
|
||||
xunit-test-results.xml
|
||||
build/ci/performance/performance-results.json
|
||||
!build/
|
||||
debug*.log
|
||||
debugpy*.log
|
||||
pydevd*.log
|
||||
nodeLanguageServer/**
|
||||
nodeLanguageServer.*/**
|
||||
dist/**
|
||||
# translation files
|
||||
*.xlf
|
||||
package.nls.*.json
|
||||
l10n/
|
||||
# config file
|
||||
**/config.py
|
25
.vscode/launch.json
vendored
Normal file
25
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Flask",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"env": {
|
||||
"FLASK_APP": "anime_easy_sync",
|
||||
"FLASK_ENV": "development",
|
||||
"FLASK_DEBUG": "0"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
"--no-debugger",
|
||||
"--no-reload"
|
||||
],
|
||||
"jinja": true
|
||||
}
|
||||
]
|
||||
}
|
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Michał Leśniak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
3
README.md
Normal file
3
README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# AnimeEasySync
|
||||
|
||||
AnimeEasySync is a Flask application that synchronizes data between Anilist, MyAnimeList and Kitsu.
|
32
anime_easy_sync/__init__.py
Normal file
32
anime_easy_sync/__init__.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Anime Easy Sync Flask server"""
|
||||
import os
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
"""Creates and configures the app"""
|
||||
# create and configure the app
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
app.config.from_mapping(
|
||||
SECRET_KEY=b'\x10\xc3\xf11\x9e\x17\x1f\xf0l\x84~A\x82{TP5Z\xe3\x8b\xbaa\x14\x03'
|
||||
)
|
||||
|
||||
if test_config is None:
|
||||
# load the instance config, if it exists, when not testing
|
||||
app.config.from_pyfile('config.py', silent=True)
|
||||
else:
|
||||
# load the test config if passed in
|
||||
app.config.from_mapping(test_config)
|
||||
|
||||
# ensure the instance folder exists
|
||||
try:
|
||||
os.makedirs(app.instance_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
from . import aes # pylint: disable=import-outside-toplevel
|
||||
app.register_blueprint(aes.bp)
|
||||
app.add_url_rule('/aes', endpoint='index')
|
||||
|
||||
return app
|
611
anime_easy_sync/aes.py
Normal file
611
anime_easy_sync/aes.py
Normal file
@ -0,0 +1,611 @@
|
||||
"""AnimeEasySync module"""
|
||||
from hashlib import sha256
|
||||
from random import choice
|
||||
from string import ascii_lowercase, digits
|
||||
from json import dumps, JSONEncoder, JSONDecoder
|
||||
from base64 import b64decode, b64encode
|
||||
from requests import Request
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from flask import (
|
||||
Blueprint, render_template, request, session, redirect, url_for, current_app
|
||||
)
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField, PasswordField, SubmitField, HiddenField, SelectField
|
||||
)
|
||||
from wtforms.validators import DataRequired
|
||||
from .model import (
|
||||
read_mal, read_anilist, read_kitsu, get_kitsu_user_id, UPDATE_FUNCS
|
||||
)
|
||||
from .oauth import get_oauth_token, MAL_TOKEN_URL, ANILIST_TOKEN_URL, KITSU_TOKEN_URL
|
||||
|
||||
bp = Blueprint('aes', __name__, url_prefix='/aes')
|
||||
|
||||
|
||||
MAL_AUTHORIZE_URL = 'https://myanimelist.net/v1/oauth2/authorize'
|
||||
ANILIST_AUTHORIZE_URL = 'https://anilist.co/api/v2/oauth/authorize'
|
||||
|
||||
BROKEN_MAL = ['35459', '27821', '31561', '38360', '36480', '40807']
|
||||
|
||||
BROKEN_KITSU = ['42279', '42090', '41963']
|
||||
|
||||
BROKEN_ANILIST = ['16932', '106169']
|
||||
|
||||
KITSU_MAL_FIX = {
|
||||
'13298': '37897',
|
||||
'42196': '39551',
|
||||
'42867': '40852',
|
||||
'13066': '34662',
|
||||
'12770': '34451',
|
||||
'8791': '21495'
|
||||
}
|
||||
|
||||
KITSU_ANILIST_FIX = {
|
||||
'41963': '104243',
|
||||
'12770': '97886',
|
||||
'8791': '102471',
|
||||
'43142': '117756'
|
||||
}
|
||||
|
||||
|
||||
def generate_pkce():
|
||||
"""Generate random string"""
|
||||
letters = ascii_lowercase + digits
|
||||
return ''.join(choice(letters) for i in range(100))
|
||||
|
||||
|
||||
def hash_dict(dic):
|
||||
"""Hash dict"""
|
||||
json = dumps(dic)
|
||||
return sha256(json.encode()).hexdigest()
|
||||
|
||||
|
||||
def at_least(count, iterable):
|
||||
"""Return True if bool(x) is True for at least `count` values x in the `iterable`."""
|
||||
i = 0
|
||||
for elem in iterable:
|
||||
if elem:
|
||||
i += 1
|
||||
if i == count:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_tokens(state):
|
||||
"""Return dict of tokens"""
|
||||
ret = {}
|
||||
for key in state:
|
||||
if '_token' in key:
|
||||
ret[key.rstrip('_token')] = state[key]
|
||||
return ret
|
||||
|
||||
|
||||
@bp.route('/', methods=('GET', 'POST'))
|
||||
def index():
|
||||
"""AnimeEasySync index page"""
|
||||
if 'state' in session:
|
||||
state = session['state']
|
||||
else:
|
||||
state = dict()
|
||||
session['pkce'] = generate_pkce()
|
||||
can_sync = at_least(2, (bool(state.get(x))
|
||||
for x in ['mal_token', 'anilist_token', 'kitsu_token']))
|
||||
form = SyncForm()
|
||||
if form.validate_on_submit() and can_sync and form.disc.data:
|
||||
try:
|
||||
disc_data = b64decode(
|
||||
form.disc.data.encode('ascii')).decode('utf-8')
|
||||
disc_lst = JSONDecoder(
|
||||
object_hook=from_json).decode(disc_data)
|
||||
tokens = {
|
||||
'mal': state.get('mal_token'),
|
||||
'anilist': state.get('anilist_token'),
|
||||
'kitsu': state.get('kitsu_token')
|
||||
}
|
||||
source = ''
|
||||
if form.mal.data:
|
||||
source = 'mal'
|
||||
elif form.anilist.data:
|
||||
source = 'anilist'
|
||||
elif form.kitsu.data:
|
||||
source = 'kitsu'
|
||||
if source and tokens[source]:
|
||||
target_tokens_dict = {k: tokens[k]
|
||||
for k in tokens if k != source}
|
||||
sync_anime(disc_lst, source, target_tokens_dict)
|
||||
except Exception as err:
|
||||
print(err)
|
||||
|
||||
discrepancies = DiscrepancyCollection()
|
||||
disc = b''
|
||||
|
||||
if can_sync:
|
||||
if 'kitsu_user_id' in state:
|
||||
kitsu_user_id = state['kitsu_user_id']
|
||||
elif 'kitsu_token' in state:
|
||||
kitsu_user_id = get_kitsu_user_id(
|
||||
state['kitsu_token'])
|
||||
state['kitsu_user_id'] = kitsu_user_id
|
||||
mal_entries = read_mal(
|
||||
state['mal_token']) if 'mal_token' in state else []
|
||||
anilist_entries = read_anilist(
|
||||
state['anilist_token']) if 'anilist_token' in state else []
|
||||
kitsu_entries = read_kitsu(
|
||||
state['kitsu_token'], kitsu_user_id) if 'kitsu_token' in state else []
|
||||
discrepancies = get_discrepancies(
|
||||
mal_entries, anilist_entries, kitsu_entries)
|
||||
disc = b64encode(
|
||||
dumps(discrepancies.get(False), cls=AESEncoder).encode('utf-8'))
|
||||
|
||||
for token_name in ['mal_token', 'anilist_token', 'kitsu_token']:
|
||||
if token_name in state and not state.get(token_name):
|
||||
del state[token_name]
|
||||
|
||||
session['state'] = state
|
||||
|
||||
mal_params = {
|
||||
'response_type': 'code',
|
||||
'client_id': current_app.config['MAL_CLIENT_ID'],
|
||||
'state': hash_dict(state),
|
||||
'redirect_uri': url_for('aes.mal_callback', _external=True),
|
||||
'code_challenge': session['pkce'],
|
||||
'code_challenge_method': 'plain'
|
||||
}
|
||||
mal_url = Request('GET', MAL_AUTHORIZE_URL,
|
||||
params=mal_params).prepare().url
|
||||
|
||||
anilist_params = {
|
||||
'response_type': 'code',
|
||||
'client_id': current_app.config['ANILIST_CLIENT_ID'],
|
||||
'redirect_uri': url_for('aes.anilist_callback', _external=True)
|
||||
}
|
||||
anilist_url = Request('GET', ANILIST_AUTHORIZE_URL,
|
||||
params=anilist_params).prepare().url
|
||||
|
||||
modals_lst = []
|
||||
if 'mal_token' in state:
|
||||
modals_lst.append('MAL')
|
||||
if 'anilist_token' in state:
|
||||
modals_lst.append('Anilist')
|
||||
if 'kitsu_token' in state:
|
||||
modals_lst.append('Kitsu')
|
||||
modals = read_modals(discrepancies, modals_lst)
|
||||
form.disc.data = disc.decode('ascii')
|
||||
|
||||
return render_template('aes/index.jinja', state=state, mal_url=mal_url,
|
||||
anilist_url=anilist_url, discrepancies=discrepancies.get(),
|
||||
modals=modals, form=form)
|
||||
|
||||
|
||||
@bp.route('/callback/anilist')
|
||||
def anilist_callback():
|
||||
"""AnimeEasySync Anilist callback"""
|
||||
code = request.args.get('code', default=None, type=str)
|
||||
state = dict()
|
||||
if 'state' in session:
|
||||
state = session['state']
|
||||
if code:
|
||||
refresh_token = None
|
||||
if 'anilist_token' in state:
|
||||
refresh_token = state['anilist_token'].get('refresh_token')
|
||||
token = get_oauth_token(ANILIST_TOKEN_URL, 'authorization_code', refresh_token,
|
||||
client_id=current_app.config['ANILIST_CLIENT_ID'],
|
||||
client_secret=current_app.config['ANILIST_CLIENT_SECRET'],
|
||||
redirect_uri=url_for(
|
||||
'aes.anilist_callback', _external=True),
|
||||
code=code)
|
||||
if token:
|
||||
state['anilist_token'] = token
|
||||
session['state'] = state
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@bp.route('/callback/mal')
|
||||
def mal_callback():
|
||||
"""AnimeEasySync MAL callback"""
|
||||
code = request.args.get('code', default=None, type=str)
|
||||
state_hash = request.args.get('state', default=None, type=str)
|
||||
state = dict()
|
||||
if 'state' in session:
|
||||
state = session['state']
|
||||
if code and state_hash and state_hash == hash_dict(state):
|
||||
pkce = ''
|
||||
if 'pkce' in session:
|
||||
pkce = session['pkce']
|
||||
auth = HTTPBasicAuth(current_app.config['MAL_CLIENT_ID'],
|
||||
current_app.config['MAL_CLIENT_SECRET'])
|
||||
refresh_token = None
|
||||
if 'mal_token' in state:
|
||||
refresh_token = state['mal_token'].get('refresh_token')
|
||||
token = get_oauth_token(MAL_TOKEN_URL, 'authorization_code', refresh_token, auth=auth,
|
||||
client_id=current_app.config['MAL_CLIENT_ID'], code=code,
|
||||
redirect_uri=url_for(
|
||||
'aes.mal_callback', _external=True),
|
||||
code_verifier=pkce)
|
||||
if token:
|
||||
state['mal_token'] = token
|
||||
session['state'] = state
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@bp.route('/kitsu', methods=['GET', 'POST'])
|
||||
def kitsu():
|
||||
"""Login page for Kitsu"""
|
||||
state = {}
|
||||
if 'state' in session:
|
||||
state = session['state']
|
||||
if 'kitsu_token' in state:
|
||||
return redirect(url_for('index'))
|
||||
form = KitsuForm()
|
||||
if form.validate_on_submit():
|
||||
token = get_oauth_token(KITSU_TOKEN_URL, 'password', None,
|
||||
username=form.username.data, password=form.password.data)
|
||||
if token:
|
||||
state['kitsu_token'] = token
|
||||
session['state'] = state
|
||||
if 'kitsu_token' in state:
|
||||
return redirect(url_for('index'))
|
||||
return render_template('aes/kitsu.jinja', form=form)
|
||||
|
||||
|
||||
def sync_valid(src, trgt):
|
||||
"""Check if entries are valid to sync"""
|
||||
return src != trgt and is_valid_id(src.anime_id) and is_valid_id(trgt.anime_id)
|
||||
|
||||
|
||||
def get_update_list(disc_lst, source, target):
|
||||
"""Gets list of update parameters"""
|
||||
ret = []
|
||||
for dis in [ent for ent in disc_lst if sync_valid(ent.entries[source], ent.entries[target])]:
|
||||
kwargs = {}
|
||||
if dis.entries[source].score != dis.entries[target].score:
|
||||
kwargs['score'] = dis.entries[source].score
|
||||
if dis.entries[source].status != dis.entries[target].status:
|
||||
kwargs['status'] = dis.entries[source].status
|
||||
if 'completed' not in [dis.entries[source].status, dis.entries[target].status] and \
|
||||
dis.entries[source].progress != dis.entries[target].progress:
|
||||
kwargs['progress'] = dis.entries[source].progress
|
||||
anime_id = dis.entries[target].library_entry_id if hasattr(
|
||||
dis.entries[target], 'library_entry_id') else dis.entries[target].anime_id
|
||||
ret.append((anime_id, kwargs))
|
||||
return ret
|
||||
|
||||
|
||||
def get_update_func(target):
|
||||
"""Gets an update func"""
|
||||
return UPDATE_FUNCS.get(target)
|
||||
|
||||
|
||||
def sync_anime(discrepancies, source, target_tokens_dict):
|
||||
"""Sync anime to source"""
|
||||
for target in target_tokens_dict:
|
||||
update = get_update_func(target)
|
||||
if update:
|
||||
for anime_id, kwargs in get_update_list(discrepancies, source, target):
|
||||
update(target_tokens_dict[target], anime_id, **kwargs)
|
||||
|
||||
|
||||
class KitsuForm(FlaskForm):
|
||||
"""Kitsu login form"""
|
||||
username = StringField('Email/Username', validators=[DataRequired()])
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
submit = SubmitField('Login')
|
||||
|
||||
|
||||
class SyncForm(FlaskForm):
|
||||
"""Sync form"""
|
||||
disc = HiddenField('disc', validators=[DataRequired()])
|
||||
mal = SubmitField('Sync to MAL values')
|
||||
anilist = SubmitField('Sync to Anilist values')
|
||||
kitsu = SubmitField('Sync to Kitsu values')
|
||||
|
||||
|
||||
def from_json(json_object):
|
||||
"""AnimeEasySync JSON decoder"""
|
||||
if isinstance(json_object, dict) and '__type__' in json_object:
|
||||
obj_type = json_object['__type__']
|
||||
if obj_type in globals():
|
||||
obj_class = globals()[obj_type]
|
||||
if obj_type == 'Anime':
|
||||
ret = obj_class(from_json(json_object['anime_id']))
|
||||
elif obj_type == 'KitsuAnime':
|
||||
ret = obj_class(from_json(json_object['library_entry_id']), from_json(json_object['anime_id']))
|
||||
else:
|
||||
ret = obj_class()
|
||||
for key in ret.__dict__:
|
||||
setattr(ret, key, from_json(json_object[key]))
|
||||
return ret
|
||||
return json_object
|
||||
|
||||
|
||||
class AESEncoder(JSONEncoder):
|
||||
"""AnimeEasySync JSON encoder"""
|
||||
|
||||
def default(self, o):
|
||||
if isinstance(o, (Anime, Discrepancy, DiscrepancyCollection, KitsuAnime)):
|
||||
ret = {'__type__': o.__class__.__name__}
|
||||
for key in o.__dict__:
|
||||
if isinstance(o.__dict__[key], list):
|
||||
lst = []
|
||||
for elem in o.__dict__[key]:
|
||||
lst.append(self.default(elem))
|
||||
ret[key] = lst
|
||||
else:
|
||||
ret[key] = self.default(o.__dict__[key])
|
||||
return ret
|
||||
if isinstance(o, (int, str, float, dict)):
|
||||
return o
|
||||
if isinstance(o, list):
|
||||
ret = []
|
||||
for elem in o:
|
||||
ret.append(self.default(elem))
|
||||
return ret
|
||||
print(o)
|
||||
return super().default(o)
|
||||
|
||||
|
||||
class Anime:
|
||||
"""Class representing Anime entry"""
|
||||
|
||||
def __init__(self, anime_id):
|
||||
self.anime_id = anime_id
|
||||
self.title = ''
|
||||
self.broken = False
|
||||
self.progress = 'none'
|
||||
self.score = 0
|
||||
self.status = 'none'
|
||||
self.link = ''
|
||||
|
||||
def fill(self, entry, broken):
|
||||
"""Fill fields with data"""
|
||||
self.progress = entry['progress']
|
||||
self.score = entry['score']
|
||||
self.status = entry['status']
|
||||
self.title = entry['title']
|
||||
self.link = entry['link']
|
||||
self.broken = broken
|
||||
|
||||
def __eq__(self, other):
|
||||
check_progress = self.progress == other.progress
|
||||
check_status = self.status == other.status
|
||||
check_score = self.score == other.score
|
||||
all_completed = self.status == 'completed' and check_status
|
||||
if not (check_progress or all_completed):
|
||||
return False
|
||||
if not check_score:
|
||||
return False
|
||||
if not check_status:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class KitsuAnime(Anime):
|
||||
"""Class representing Kitsu Anime entry"""
|
||||
|
||||
def __init__(self, library_entry_id, *args):
|
||||
super().__init__(*args)
|
||||
self.library_entry_id = library_entry_id
|
||||
|
||||
|
||||
def is_valid_id(anime_id):
|
||||
"""Check if anime id is valid"""
|
||||
return anime_id and int(anime_id) > 0
|
||||
|
||||
|
||||
class Discrepancy:
|
||||
"""Class representing a discrepancy"""
|
||||
|
||||
def __init__(self):
|
||||
self.entries = {}
|
||||
|
||||
def fill_mal(self, mal_entry):
|
||||
"""Fill fields from MAL entry"""
|
||||
entry = Anime(mal_entry['mal_id'])
|
||||
entry.fill(mal_entry, mal_entry['mal_id'] in BROKEN_MAL)
|
||||
self.entries['mal'] = entry
|
||||
|
||||
def fill_anilist(self, anilist_entry):
|
||||
"""Fill fields from Anilist entry"""
|
||||
mal_entry = self.entries.get('mal')
|
||||
if not mal_entry and anilist_entry['mal_id']:
|
||||
mal_entry = Anime(anilist_entry['mal_id'])
|
||||
self.entries['mal'] = mal_entry
|
||||
entry = Anime(anilist_entry['anilist_id'])
|
||||
entry.fill(anilist_entry,
|
||||
anilist_entry['anilist_id'] in BROKEN_ANILIST)
|
||||
self.entries['anilist'] = entry
|
||||
|
||||
def fill_kitsu(self, kitsu_entry):
|
||||
"""Fill fields from Kitsu entry"""
|
||||
mal_entry = self.entries.get('mal')
|
||||
if not mal_entry and kitsu_entry['mal_id']:
|
||||
mal_entry = Anime(kitsu_entry['mal_id'])
|
||||
self.entries['mal'] = mal_entry
|
||||
anilist_entry = self.entries.get('anilist')
|
||||
if not anilist_entry and kitsu_entry['anilist_id']:
|
||||
anilist_entry = Anime(kitsu_entry['anilist_id'])
|
||||
self.entries['anilist'] = anilist_entry
|
||||
entry = KitsuAnime(kitsu_entry['library_entry_id'],
|
||||
kitsu_entry['kitsu_id'])
|
||||
entry.fill(kitsu_entry, kitsu_entry['kitsu_id'] in BROKEN_KITSU)
|
||||
self.entries['kitsu'] = entry
|
||||
|
||||
def is_discrepancy(self):
|
||||
"""Check if there is a discrepancy"""
|
||||
if any(not is_valid_id(x.anime_id) for x in self.entries.values()):
|
||||
return True
|
||||
if not self.entries.values():
|
||||
return False
|
||||
return any(x != list(self.entries.values())[0] for x in self.entries.values())
|
||||
|
||||
def is_broken(self):
|
||||
"""Gets broken status"""
|
||||
return any(x.broken for x in self.entries.values())
|
||||
|
||||
def get_title(self):
|
||||
"""Gets anime title"""
|
||||
return next((x.title for x in self.entries.values() if x.title), '')
|
||||
|
||||
|
||||
class DiscrepancyCollection:
|
||||
"""Class representing a collection of Discrepancies"""
|
||||
|
||||
def __init__(self):
|
||||
self.collection = []
|
||||
self.kitsu = {}
|
||||
self.anilist = {}
|
||||
self.mal = {}
|
||||
|
||||
def update_mal(self, mal_entry):
|
||||
"""Add or update a MAL entry to collection"""
|
||||
if mal_entry['mal_id'] in KITSU_MAL_FIX.values():
|
||||
mal_entry['kitsu_id'] = list(KITSU_MAL_FIX.keys())[list(
|
||||
KITSU_MAL_FIX.values()).index(mal_entry['mal_id'])]
|
||||
if mal_entry['mal_id'] in self.mal:
|
||||
entry = self.collection[self.mal[mal_entry['mal_id']]]
|
||||
else:
|
||||
entry = Discrepancy()
|
||||
self.collection.append(entry)
|
||||
self.mal[mal_entry['mal_id']] = len(self.collection) - 1
|
||||
entry.fill_mal(mal_entry)
|
||||
|
||||
def update_anilist(self, anilist_entry):
|
||||
"""Add or update a Anilist entry to collection"""
|
||||
if anilist_entry['anilist_id'] in self.anilist:
|
||||
entry = self.collection[self.anilist[anilist_entry['anilist_id']]]
|
||||
elif anilist_entry['mal_id'] and anilist_entry['mal_id'] in self.mal:
|
||||
entry = self.collection[self.mal[anilist_entry['mal_id']]]
|
||||
self.anilist[anilist_entry['anilist_id']
|
||||
] = self.mal[anilist_entry['mal_id']]
|
||||
else:
|
||||
entry = Discrepancy()
|
||||
self.collection.append(entry)
|
||||
if anilist_entry['mal_id']:
|
||||
self.mal[anilist_entry['mal_id']] = len(self.collection) - 1
|
||||
self.anilist[anilist_entry['anilist_id']] = len(
|
||||
self.collection) - 1
|
||||
entry.fill_anilist(anilist_entry)
|
||||
|
||||
def update_kitsu(self, kitsu_entry):
|
||||
"""Add or update a Kitsu entry to collection"""
|
||||
# WORKAROUND: fix broken ids
|
||||
if kitsu_entry['kitsu_id'] in KITSU_ANILIST_FIX:
|
||||
kitsu_entry['anilist_id'] = KITSU_ANILIST_FIX[kitsu_entry['kitsu_id']]
|
||||
if kitsu_entry['kitsu_id'] in KITSU_MAL_FIX:
|
||||
kitsu_entry['mal_id'] = KITSU_MAL_FIX[kitsu_entry['kitsu_id']]
|
||||
|
||||
if kitsu_entry['kitsu_id'] in self.kitsu:
|
||||
entry = self.collection[self.anilist[kitsu_entry['kitsu_id']]]
|
||||
if kitsu_entry['anilist_id'] in self.anilist:
|
||||
entry = self.collection[self.anilist[kitsu_entry['anilist_id']]]
|
||||
self.kitsu[kitsu_entry['kitsu_id']
|
||||
] = self.anilist[kitsu_entry['anilist_id']]
|
||||
if kitsu_entry['mal_id'] and kitsu_entry['mal_id'] not in self.mal:
|
||||
self.mal[kitsu_entry['mal_id']
|
||||
] = self.anilist[kitsu_entry['anilist_id']]
|
||||
elif kitsu_entry['mal_id'] and kitsu_entry['mal_id'] in self.mal:
|
||||
entry = self.collection[self.mal[kitsu_entry['mal_id']]]
|
||||
self.kitsu[kitsu_entry['kitsu_id']
|
||||
] = self.mal[kitsu_entry['mal_id']]
|
||||
if kitsu_entry['anilist_id']:
|
||||
self.anilist[kitsu_entry['anilist_id']
|
||||
] = self.mal[kitsu_entry['mal_id']]
|
||||
else:
|
||||
entry = Discrepancy()
|
||||
self.collection.append(entry)
|
||||
if kitsu_entry['mal_id']:
|
||||
self.mal[kitsu_entry['mal_id']] = len(self.collection) - 1
|
||||
if kitsu_entry['anilist_id']:
|
||||
self.anilist[kitsu_entry['anilist_id']] = len(
|
||||
self.collection) - 1
|
||||
self.kitsu[kitsu_entry['kitsu_id']] = len(self.collection) - 1
|
||||
entry.fill_kitsu(kitsu_entry)
|
||||
|
||||
def get(self, with_broken=True):
|
||||
"""Get entries that are broken or with a discrepancy"""
|
||||
if with_broken:
|
||||
return [x for x in self.collection if x.is_discrepancy() or x.is_broken()]
|
||||
return [x for x in self.collection if x.is_discrepancy() and not x.is_broken()]
|
||||
|
||||
|
||||
def get_discrepancies(mal_entries, anilist_entries, kitsu_entries):
|
||||
"""Get discrepancies between entries"""
|
||||
discrepancies = DiscrepancyCollection()
|
||||
for entry in mal_entries:
|
||||
discrepancies.update_mal(entry)
|
||||
for entry in anilist_entries:
|
||||
discrepancies.update_anilist(entry)
|
||||
for entry in kitsu_entries:
|
||||
discrepancies.update_kitsu(entry)
|
||||
return discrepancies
|
||||
|
||||
|
||||
def read_modals(discrepancies, lst):
|
||||
"""Get modal info"""
|
||||
ret = []
|
||||
disc = discrepancies.get(False)
|
||||
for target in lst:
|
||||
src_lst = list(lst)
|
||||
src_lst.remove(target)
|
||||
ret.append({
|
||||
'type': target,
|
||||
'msg': changes_syncing(disc, target, *src_lst)
|
||||
})
|
||||
return ret
|
||||
|
||||
|
||||
def changes_syncing_to_mal(lst):
|
||||
"""Get changes to lists while syncing to MAL"""
|
||||
return changes_syncing(lst, 'MAL', 'Anilist', 'Kitsu')
|
||||
|
||||
|
||||
def changes_syncing(lst, target, *args):
|
||||
"""Get changes to lists while syncinc to target"""
|
||||
ret = ''
|
||||
trgt_ent = target.lower()
|
||||
for src in args:
|
||||
src_ent = src.lower()
|
||||
broken = [ent for ent in lst if src_ent not in ent.entries]
|
||||
if broken:
|
||||
print('broken entries detected:')
|
||||
for ent in broken:
|
||||
print(dumps(ent, cls=AESEncoder))
|
||||
lst.remove(ent)
|
||||
|
||||
entries = [ent for ent in lst if ent.entries[trgt_ent]
|
||||
!= ent.entries[src_ent]]
|
||||
if entries:
|
||||
ret += f'<h3>Changes to {src}:</h3><ul>'
|
||||
for dis in entries:
|
||||
if not sync_valid(dis.entries[src_ent], dis.entries[trgt_ent]):
|
||||
continue
|
||||
changes = ['<li><h4>', dis.get_title(), '</h4><ul>']
|
||||
get_changes(
|
||||
changes, dis.entries[src_ent], dis.entries[trgt_ent])
|
||||
ret += ''.join(changes)
|
||||
ret += '</ul></li>'
|
||||
ret += '</ul>'
|
||||
return ret
|
||||
|
||||
|
||||
def changes_syncing_to_anilist(lst):
|
||||
"""Get changes to lists while syncing to Anilist"""
|
||||
return changes_syncing(lst, 'Anilist', 'MAL', 'Kitsu')
|
||||
|
||||
|
||||
def changes_syncing_to_kitsu(lst):
|
||||
"""Get changes to lists while syncing to Kitsu"""
|
||||
return changes_syncing(lst, 'Kitsu', 'MAL', 'Anilist')
|
||||
|
||||
|
||||
def get_changes(changes, src, trgt):
|
||||
"""Get changes between 2 entries"""
|
||||
if not sync_valid(src, trgt):
|
||||
return
|
||||
if src.score != trgt.score:
|
||||
changes.append(f'<li>Score: {src.score} -> {trgt.score}</li>')
|
||||
if src.status != trgt.status:
|
||||
changes.append(f'<li>Status: {src.status} -> {trgt.status}</li>')
|
||||
if src.progress != trgt.progress and (src.status != 'completed' or trgt.status != 'completed'):
|
||||
changes.append(
|
||||
f'<li>Progress: {src.progress} -> {trgt.progress}</li>')
|
366
anime_easy_sync/model.py
Normal file
366
anime_easy_sync/model.py
Normal file
@ -0,0 +1,366 @@
|
||||
"""AnimeEasySync model module"""
|
||||
from base64 import b64decode
|
||||
from json import loads
|
||||
from time import sleep
|
||||
import requests
|
||||
from .oauth import refresh_mal_token, refresh_anilist_token, refresh_kitsu_token
|
||||
|
||||
|
||||
ANILIST_TO_STATUS = {
|
||||
'CURRENT': 'watching',
|
||||
'COMPLETED': 'completed',
|
||||
'PAUSED': 'on_hold',
|
||||
'DROPPED': 'dropped',
|
||||
'PLANNING': 'plan_to_watch',
|
||||
'REPEATING': 'watching'
|
||||
}
|
||||
|
||||
KITSU_TO_STATUS = {
|
||||
'current': 'watching',
|
||||
'completed': 'completed',
|
||||
'on_hold': 'on_hold',
|
||||
'dropped': 'dropped',
|
||||
'planned': 'plan_to_watch'
|
||||
}
|
||||
|
||||
MAL_API_URL = 'https://api.myanimelist.net/v2'
|
||||
KITSU_API_URL = 'https://kitsu.io/api/edge'
|
||||
ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'
|
||||
|
||||
|
||||
def get_userid_from_token(token):
|
||||
"""Get userId from Anilist token"""
|
||||
payload = token['access_token'].split(
|
||||
'.')[1].replace('-', '+').replace('_', '/')
|
||||
payload = payload + '='*(len(payload) % 4)
|
||||
return int(loads(b64decode(payload))['sub'])
|
||||
|
||||
|
||||
class BearerAuth(requests.auth.AuthBase):
|
||||
"""Bearer Auth header"""
|
||||
|
||||
def __init__(self, token):
|
||||
self.token = token['access_token']
|
||||
|
||||
def __call__(self, r):
|
||||
r.headers["authorization"] = "Bearer " + self.token
|
||||
return r
|
||||
|
||||
|
||||
def mal_to_entry(mal_entry):
|
||||
"""Map MAL entry to AES entry"""
|
||||
return {
|
||||
'mal_id': str(mal_entry['node']['id']),
|
||||
'title': mal_entry['node']['title'],
|
||||
'score': mal_entry['list_status']['score'],
|
||||
'status': mal_entry['list_status']['status'],
|
||||
'progress': mal_entry['list_status']['num_episodes_watched'],
|
||||
'link': f'https://myanimelist.net/anime/{mal_entry["node"]["id"]}'
|
||||
}
|
||||
|
||||
|
||||
def read_mal(token):
|
||||
"""Read MAL entries"""
|
||||
entries = []
|
||||
url = MAL_API_URL+'/users/@me/animelist?fields=list_status&limit=1000&nsfw=true'
|
||||
auth = BearerAuth(token)
|
||||
while True:
|
||||
response = requests.get(url=url, auth=auth)
|
||||
if response.status_code == requests.codes.unauthorized and token: # pylint: disable=no-member
|
||||
refresh_mal_token(token)
|
||||
if not token:
|
||||
return entries
|
||||
auth = BearerAuth(token)
|
||||
continue
|
||||
mal = response.json().get('data')
|
||||
if mal is None:
|
||||
print('read_mal exception')
|
||||
print(response.json())
|
||||
return entries
|
||||
for mal_entry in mal:
|
||||
entries.append(mal_to_entry(mal_entry))
|
||||
paging = response.json().get('paging')
|
||||
if paging:
|
||||
url = paging.get('next')
|
||||
if url:
|
||||
continue
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def anilist_to_entry(anilist_entry):
|
||||
"""Map Anilist entry to AES entry"""
|
||||
return {
|
||||
'anilist_id': str(anilist_entry['media']['id']),
|
||||
'mal_id': str(anilist_entry['media']['idMal']) if anilist_entry['media']['idMal'] else '',
|
||||
'title': f"{anilist_entry['media']['title']['english']} " +
|
||||
f"({anilist_entry['media']['title']['romaji']})",
|
||||
'score': anilist_entry['score'],
|
||||
'status': ANILIST_TO_STATUS[anilist_entry['status']],
|
||||
'progress': anilist_entry['progress'],
|
||||
'link': anilist_entry['media']['siteUrl']
|
||||
}
|
||||
|
||||
|
||||
def read_anilist(token):
|
||||
"""Read Anilist entries"""
|
||||
entries = []
|
||||
auth = BearerAuth(token)
|
||||
anilist_query = '''
|
||||
query ($chunk: Int, $userId: Int) {
|
||||
MediaListCollection(userId: $userId, type: ANIME, chunk: $chunk, perChunk: 500) {
|
||||
hasNextChunk
|
||||
lists {
|
||||
name
|
||||
isCustomList
|
||||
isSplitCompletedList
|
||||
status
|
||||
entries {
|
||||
status
|
||||
score(format: POINT_10)
|
||||
progress
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
}
|
||||
siteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
chunk = 1
|
||||
while True:
|
||||
variables = {
|
||||
'chunk': chunk,
|
||||
'userId': get_userid_from_token(token)
|
||||
}
|
||||
response = requests.post(
|
||||
ANILIST_GRAPHQL_URL, json={
|
||||
'query': anilist_query, 'variables': variables},
|
||||
auth=auth)
|
||||
if response.status_code == requests.codes.unauthorized and token: # pylint: disable=no-member
|
||||
refresh_anilist_token(token)
|
||||
if not token:
|
||||
return entries
|
||||
auth = BearerAuth(token)
|
||||
continue
|
||||
anilist = response.json().get('data')
|
||||
if anilist is None:
|
||||
print('read_anilist exception')
|
||||
print(response.json())
|
||||
return entries
|
||||
for lst in anilist['MediaListCollection']['lists']:
|
||||
for anilist_entry in lst['entries']:
|
||||
entries.append(anilist_to_entry(anilist_entry))
|
||||
if anilist['MediaListCollection']['hasNextChunk']:
|
||||
chunk += 1
|
||||
else:
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def get_kitsu_user_id(token):
|
||||
"""Get user's Kitsu user id"""
|
||||
url = KITSU_API_URL+'/users?filter[self]=true&fields[users]=name'
|
||||
auth = BearerAuth(token)
|
||||
while True:
|
||||
response = requests.get(url, auth=auth)
|
||||
if response.ok:
|
||||
user_id = response.json()['data'][0]['id']
|
||||
return user_id
|
||||
if response.status_code == requests.codes.unauthorized and token: # pylint: disable=no-member
|
||||
refresh_kitsu_token(token)
|
||||
if not token:
|
||||
return None
|
||||
auth = BearerAuth(token)
|
||||
continue
|
||||
print('get_kitsu_user_id exception')
|
||||
print(response.json())
|
||||
return None
|
||||
|
||||
|
||||
def kitsu_to_entry(kitsu_entry, kitsu_anime, kitsu_mappings, kitsu_library_entry_id):
|
||||
"""Map Kitsu entry to AES entry"""
|
||||
mal_id = next((str(x['attributes']['externalId'])
|
||||
for x in kitsu_mappings
|
||||
if x['attributes']['externalSite'] == 'myanimelist/anime'), '')
|
||||
anilist_id = next((str(x['attributes']['externalId'])
|
||||
for x in kitsu_mappings
|
||||
if x['attributes']['externalSite'] == 'anilist/anime'), '')
|
||||
if not anilist_id:
|
||||
anilist_id = next((str(x['attributes']['externalId'][6:]
|
||||
if x['attributes']['externalId'][:6] == 'anime/'
|
||||
else x['attributes']['externalId'])
|
||||
if x['attributes']['externalId'] else 0
|
||||
for x in kitsu_mappings
|
||||
if x['attributes']['externalSite'] == 'anilist'), '')
|
||||
return {
|
||||
'kitsu_id': str(kitsu_anime['id']),
|
||||
'mal_id': str(mal_id) if mal_id else '',
|
||||
'anilist_id': str(anilist_id) if anilist_id else '',
|
||||
'title': kitsu_anime['attributes']['canonicalTitle'],
|
||||
'score': (int(kitsu_entry['ratingTwenty']) if kitsu_entry['ratingTwenty'] else 0)//2,
|
||||
'status': KITSU_TO_STATUS[kitsu_entry['status']],
|
||||
'progress': kitsu_entry['progress'],
|
||||
'library_entry_id': kitsu_library_entry_id,
|
||||
'link': f'https://kitsu.io/anime/{kitsu_anime["id"]}'
|
||||
}
|
||||
|
||||
|
||||
def read_kitsu(token, user_id):
|
||||
"""Read Kitsu entries"""
|
||||
entries = []
|
||||
url = KITSU_API_URL+f"/library-entries?filter[user_id]={user_id}" + \
|
||||
"&filter[kind]=anime&include=anime,anime.mappings" + \
|
||||
"&fields[anime]=canonicalTitle,episodeCount,slug,mappings" + \
|
||||
"&fields[mappings]=externalSite,externalId&page[limit]=500&page[offset]=0"
|
||||
auth = BearerAuth(token)
|
||||
retries = 2
|
||||
while True:
|
||||
response = requests.get(url, auth=auth)
|
||||
if response.status_code == requests.codes.unauthorized and token: # pylint: disable=no-member
|
||||
refresh_kitsu_token(token)
|
||||
if not token:
|
||||
return entries
|
||||
auth = BearerAuth(token)
|
||||
continue
|
||||
if response.status_code == requests.codes.server_error and retries > 0: # pylint: disable=no-member
|
||||
retries -= 1
|
||||
sleep(1)
|
||||
continue
|
||||
kitsu = response.json().get('data')
|
||||
if kitsu is None:
|
||||
print('read_kitsu exception')
|
||||
print(response.json())
|
||||
return entries
|
||||
anime = [x for x in response.json()['included']
|
||||
if x['type'] == 'anime']
|
||||
mappings = [x for x in response.json()['included']
|
||||
if x['type'] == 'mappings']
|
||||
for data in kitsu:
|
||||
kitsu_anime = next(
|
||||
y for y in anime if y['id'] == data['relationships']['anime']['data']['id'])
|
||||
kitsu_mappings = []
|
||||
for anime_entry in kitsu_anime['relationships']['mappings']['data']:
|
||||
kitsu_mappings.append(
|
||||
next(z for z in mappings if anime_entry['id'] == z['id']))
|
||||
entries.append(kitsu_to_entry(
|
||||
data['attributes'], kitsu_anime, kitsu_mappings, data['id']))
|
||||
links = response.json().get('links')
|
||||
if links:
|
||||
url = links.get('next')
|
||||
if url:
|
||||
retries = 2
|
||||
continue
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def update_mal(token, mal_id, status=None, score=None, progress=None):
|
||||
"""Update a MAL anime entry on user's list"""
|
||||
auth = BearerAuth(token)
|
||||
url = MAL_API_URL+f'/anime/{mal_id}/my_list_status'
|
||||
data = {}
|
||||
if status:
|
||||
data['status'] = status
|
||||
if score is not None:
|
||||
data['score'] = score
|
||||
if progress is not None:
|
||||
data['num_watched_episodes'] = progress
|
||||
|
||||
while True:
|
||||
response = requests.put(url=url, data=data, auth=auth)
|
||||
if response.status_code == requests.codes.unauthorized: # pylint: disable=no-member
|
||||
refresh_mal_token(token)
|
||||
if not token:
|
||||
return False
|
||||
auth = BearerAuth(token)
|
||||
continue
|
||||
if not response.ok:
|
||||
print(response.json())
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def update_kitsu(token, kitsu_library_entry_id, status=None, score=None, progress=None):
|
||||
"""Update a Kitsu anime entry on user's list"""
|
||||
auth = BearerAuth(token)
|
||||
headers = {'Content-Type': 'application/vnd.api+json'}
|
||||
url = KITSU_API_URL+f'/library-entries/{kitsu_library_entry_id}'
|
||||
data = {
|
||||
'data': {
|
||||
'type': 'libraryEntries',
|
||||
'id': str(kitsu_library_entry_id),
|
||||
'attributes': {}
|
||||
}
|
||||
}
|
||||
if status is not None:
|
||||
data['data']['attributes']['status'] = list(KITSU_TO_STATUS.keys())[
|
||||
list(KITSU_TO_STATUS.values()).index(status)]
|
||||
if score is not None:
|
||||
data['data']['attributes']['ratingTwenty'] = int(score*2)
|
||||
if progress is not None:
|
||||
data['data']['attributes']['progress'] = progress
|
||||
while True:
|
||||
response = requests.patch(
|
||||
url=url, headers=headers, auth=auth, json=data)
|
||||
if response.status_code == requests.codes.unauthorized: # pylint: disable=no-member
|
||||
refresh_kitsu_token(token)
|
||||
if not token:
|
||||
return False
|
||||
auth = BearerAuth(token)
|
||||
continue
|
||||
if not response.ok:
|
||||
print(response.json())
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def update_anilist(token, anilist_id, status=None, score=None, progress=None):
|
||||
"""Add or update an Anilist anime entry on user's list"""
|
||||
auth = BearerAuth(token)
|
||||
anilist_query = '''
|
||||
mutation ($id: Int, $status: MediaListStatus, $score: Float, $progress: Int) {
|
||||
SaveMediaListEntry(id: $id, status: $status, score: $score, progress: $progress) {
|
||||
id
|
||||
status
|
||||
score(format: POINT_10)
|
||||
progress
|
||||
}
|
||||
}
|
||||
'''
|
||||
variables = {
|
||||
'id': int(anilist_id)
|
||||
}
|
||||
if status is not None:
|
||||
variables['status'] = list(ANILIST_TO_STATUS.keys())[
|
||||
list(ANILIST_TO_STATUS.values()).index(status)]
|
||||
if score is not None:
|
||||
variables['score'] = float(score)
|
||||
if progress is not None:
|
||||
variables['progress'] = int(progress)
|
||||
data = {'query': anilist_query, 'variables': variables}
|
||||
while True:
|
||||
response = requests.post(url=ANILIST_GRAPHQL_URL, auth=auth, json=data)
|
||||
if response.status_code == requests.codes.unauthorized: # pylint: disable=no-member
|
||||
refresh_anilist_token(token)
|
||||
if not token:
|
||||
return False
|
||||
auth = BearerAuth(token)
|
||||
continue
|
||||
if not response.ok:
|
||||
print(response.json())
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
UPDATE_FUNCS = {
|
||||
'mal': update_mal,
|
||||
'anilist': update_anilist,
|
||||
'kitsu': update_kitsu
|
||||
}
|
80
anime_easy_sync/oauth.py
Normal file
80
anime_easy_sync/oauth.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""AnimeEasySync OAuth module"""
|
||||
from requests import post, codes
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from flask import current_app
|
||||
|
||||
MAL_TOKEN_URL = 'https://myanimelist.net/v1/oauth2/token'
|
||||
ANILIST_TOKEN_URL = 'https://anilist.co/api/v2/oauth/token'
|
||||
KITSU_TOKEN_URL = 'https://kitsu.io/api/oauth/token'
|
||||
|
||||
OAUTH_PARAMS = {
|
||||
'authorization_code': {
|
||||
'required': ['client_id', 'code', 'redirect_uri'],
|
||||
'optional': ['code_verifier', 'client_secret']
|
||||
},
|
||||
'password': {
|
||||
'required': ['username', 'password'],
|
||||
'optional': []
|
||||
},
|
||||
'refresh_token': {
|
||||
'required': [],
|
||||
'optional': []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_oauth_token(url, grant_type, refresh_token, **kwargs):
|
||||
"""Gets OAuth Token"""
|
||||
print(f'url:{url}, grant_type:{grant_type}, refresh_token:{refresh_token}, kwargs:{kwargs}')
|
||||
if not url or grant_type not in OAUTH_PARAMS or None in kwargs.values():
|
||||
return None
|
||||
data = {'grant_type': grant_type}
|
||||
if refresh_token and grant_type == 'refresh_token':
|
||||
data['refresh_token'] = refresh_token
|
||||
refresh_token = None
|
||||
for req in OAUTH_PARAMS[grant_type]['required']:
|
||||
if req not in kwargs:
|
||||
return None
|
||||
data[req] = kwargs[req]
|
||||
for opt in OAUTH_PARAMS[grant_type]['optional']:
|
||||
if opt in kwargs:
|
||||
data[opt] = kwargs[opt]
|
||||
request_kwargs = {}
|
||||
if 'auth' in kwargs:
|
||||
request_kwargs['auth'] = kwargs['auth']
|
||||
print(f'data:{data}, request_kwargs:{request_kwargs}')
|
||||
response = post(url=url, data=data, **request_kwargs)
|
||||
ret = None
|
||||
if response.status_code == codes.ok: # pylint: disable=no-member
|
||||
ret = response.json()
|
||||
elif response.status_code == codes.unauthorized and refresh_token: # pylint: disable=no-member
|
||||
ret = get_oauth_token(url, 'refresh_token', refresh_token, **kwargs)
|
||||
else:
|
||||
print(response.json())
|
||||
return ret
|
||||
|
||||
|
||||
def refresh_oauth_token(token, url, **kwargs):
|
||||
"""Refresh token"""
|
||||
refresh_token = token['refresh_token']
|
||||
new_token = get_oauth_token(url, 'refresh_token', refresh_token, **kwargs)
|
||||
token.clear()
|
||||
if new_token:
|
||||
token.update(new_token)
|
||||
|
||||
|
||||
def refresh_mal_token(token):
|
||||
"""Refresh MAL token"""
|
||||
mal_auth = HTTPBasicAuth(
|
||||
current_app.config['MAL_CLIENT_ID'], current_app.config['MAL_CLIENT_SECRET'])
|
||||
refresh_oauth_token(token, MAL_TOKEN_URL, auth=mal_auth)
|
||||
|
||||
|
||||
def refresh_anilist_token(token):
|
||||
"""Refresh Anilist token"""
|
||||
refresh_oauth_token(token, ANILIST_TOKEN_URL)
|
||||
|
||||
|
||||
def refresh_kitsu_token(token):
|
||||
"""Refresh Kitsu token"""
|
||||
refresh_oauth_token(token, KITSU_TOKEN_URL)
|
12
anime_easy_sync/templates/_formhelpers.jinja
Normal file
12
anime_easy_sync/templates/_formhelpers.jinja
Normal file
@ -0,0 +1,12 @@
|
||||
{% macro render_field(field) %}
|
||||
<dt>{{ field.label }}
|
||||
<dd>{{ field(**kwargs)|safe }}
|
||||
{% if field.errors %}
|
||||
<ul class=errors>
|
||||
{% for error in field.errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endmacro %}
|
212
anime_easy_sync/templates/aes/index.jinja
Normal file
212
anime_easy_sync/templates/aes/index.jinja
Normal file
@ -0,0 +1,212 @@
|
||||
{% extends "base.jinja" %}
|
||||
{% block head %}
|
||||
<style>
|
||||
body {font-family: Arial, Helvetica, sans-serif;}
|
||||
|
||||
/* The Modal (background) */
|
||||
.modal {
|
||||
display: none; /* Hidden by default */
|
||||
position: fixed; /* Stay in place */
|
||||
z-index: 100; /* Sit on top */
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1000px;
|
||||
height: 500px;
|
||||
max-width: 100%; /* Full width */
|
||||
max-height: 100%; /* Full height */
|
||||
background: white;
|
||||
box-shadow: 0 0 60px 10px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 50;
|
||||
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.modal-content {
|
||||
position: absolute;
|
||||
background-color: #fefefe;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid #888;
|
||||
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19);
|
||||
-webkit-animation-name: animatetop;
|
||||
-webkit-animation-duration: 0.4s;
|
||||
animation-name: animatetop;
|
||||
animation-duration: 0.4s
|
||||
}
|
||||
|
||||
/* Add Animation */
|
||||
@-webkit-keyframes animatetop {
|
||||
from {top:-300px; opacity:0}
|
||||
to {top:0; opacity:1}
|
||||
}
|
||||
|
||||
@keyframes animatetop {
|
||||
from {top:-300px; opacity:0}
|
||||
to {top:0; opacity:1}
|
||||
}
|
||||
|
||||
/* The Close Button */
|
||||
.close {
|
||||
color: white;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.close:hover,
|
||||
.close:focus {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 2px 16px;
|
||||
background-color: #5cb85c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-body {padding: 2px 16px;}
|
||||
|
||||
.modal-footer {
|
||||
padding: 2px 16px;
|
||||
background-color: #5cb85c;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<a href="{{mal_url}}">Log in to MAL</a>
|
||||
<a href="{{anilist_url}}">Log in to Anilist</a>
|
||||
<a href="{{url_for('aes.kitsu')}}">Log in to Kitsu</a>
|
||||
<br/>
|
||||
{% if state %}
|
||||
{% if 'mal_token' in state %}
|
||||
<button id="syncMALBtn">Sync to MAL</button>
|
||||
{% endif %}
|
||||
{% if 'anilist_token' in state %}
|
||||
<button id="syncAnilistBtn">Sync to Anilist</button>
|
||||
{% endif %}
|
||||
{% if 'kitsu_token' in state %}
|
||||
<button id="syncKitsuBtn">Sync to Kitsu</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
{% set sites = 0 %}
|
||||
{% if state %}
|
||||
<tr>
|
||||
<th></th>
|
||||
{% if 'mal_token' in state %}
|
||||
{% set sites = sites + 1 %}
|
||||
<th colspan="4">MyAnimeList</th>
|
||||
{% endif %}
|
||||
{% if 'anilist_token' in state %}
|
||||
{% set sites = sites + 1 %}
|
||||
<th colspan="4">Anilist</th>
|
||||
{% endif %}
|
||||
{% if 'kitsu_token' in state %}
|
||||
{% set sites = sites + 1 %}
|
||||
<th colspan="4">Kitsu</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
{% for i in range(sites) %}
|
||||
<th>Progress</th>
|
||||
<th>Score</th>
|
||||
<th>Status</th>
|
||||
<th>Links</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for x in discrepancies %}
|
||||
<tr>
|
||||
<td>{{ '[BROKEN] ' if x.is_broken() else '' }}{{ x.get_title() }}</td>
|
||||
{% for y in ['mal', 'anilist', 'kitsu'] %}
|
||||
{% if y in x.entries %}
|
||||
<td>{{ x.entries[y].progress|default('') }}</td>
|
||||
<td>{{ x.entries[y].score|default('') }}</td>
|
||||
<td>{{ x.entries[y].status|default('') }}</td>
|
||||
<td>{% if x.entries[y].link %}<a href="{{ x.entries[y].link }}">Link</a>{% endif %}</td>
|
||||
{% elif y + '_token' in state %}
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-overlay"></div>
|
||||
<form action='' method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
{% for x in modals %}
|
||||
<div id="sync{{ x['type'] }}Modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span class="close">×</span>
|
||||
<h2>Sync to {{ x['type'] }}</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{ x['msg'] }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
{% if x['type'] == 'MAL' %}
|
||||
{{ form.mal }}
|
||||
{% elif x['type'] == 'Anilist' %}
|
||||
{{ form.anilist }}
|
||||
{% elif x['type'] == 'Kitsu' %}
|
||||
{{ form.kitsu }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</form>
|
||||
<script>
|
||||
var types = [
|
||||
{% for x in modals %}"{{ x['type'] }}",{% endfor %}
|
||||
]
|
||||
var i;
|
||||
for (i = 0; i < {{ modals|length }}; i++) {
|
||||
let overlay = document.getElementsByClassName("modal-overlay")[0]
|
||||
let modal = document.getElementById("sync"+types[i]+"Modal");
|
||||
let btn = document.getElementById("sync"+types[i]+"Btn");
|
||||
let span = modal.getElementsByClassName("close")[0];
|
||||
btn.onclick = function() {
|
||||
modal.style.display = "block";
|
||||
overlay.style.display = "block";
|
||||
}
|
||||
span.onclick = function() {
|
||||
modal.style.display = "none";
|
||||
overlay.style.display = "none";
|
||||
}
|
||||
window.addEventListener('click', event => {
|
||||
if (event.target == modal) {
|
||||
modal.style.display = "none";
|
||||
overlay.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
12
anime_easy_sync/templates/aes/kitsu.jinja
Normal file
12
anime_easy_sync/templates/aes/kitsu.jinja
Normal file
@ -0,0 +1,12 @@
|
||||
{% extends "base.jinja" %}
|
||||
{% from "_formhelpers.jinja" import render_field %}
|
||||
{% block content %}
|
||||
<form action='' method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
<dl>
|
||||
{{ render_field(form.username) }}
|
||||
{{ render_field(form.password) }}
|
||||
</dl>
|
||||
{{ form.submit() }}
|
||||
</form>
|
||||
{% endblock content %}
|
13
anime_easy_sync/templates/base.jinja
Normal file
13
anime_easy_sync/templates/base.jinja
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Anime Easy Sync</title>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
5
instance/config.py
Normal file
5
instance/config.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Anime Easy Sync config file"""
|
||||
MAL_CLIENT_ID = 'REPLACE_ME'
|
||||
MAL_CLIENT_SECRET = 'REPLACE_ME'
|
||||
ANILIST_CLIENT_ID = 'REPLACE_ME'
|
||||
ANILIST_CLIENT_SECRET = 'REPLACE_ME'
|
21
uwsgi.ini
Normal file
21
uwsgi.ini
Normal file
@ -0,0 +1,21 @@
|
||||
[uwsgi]
|
||||
chdir = /home/pi/AnimeEasySync
|
||||
module = anime_easy_sync:create_app()
|
||||
|
||||
master = true
|
||||
processes = 1
|
||||
threads = 2
|
||||
|
||||
uid = www-data
|
||||
gid = www-data
|
||||
|
||||
socket = /tmp/AnimeEasySync.sock
|
||||
chmod-socket = 664
|
||||
vacuum = true
|
||||
|
||||
die-on-term = true
|
||||
|
||||
buffer-size = 32768
|
||||
|
||||
virtualenv = /home/pi/AnimeEasySync/venv
|
||||
|
Loading…
Reference in New Issue
Block a user