2024-02-26 17:39:11 +01:00

612 lines
22 KiB
Python

"""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>')