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

Changes to {src}:

' 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'
  • Score: {src.score} -> {trgt.score}
  • ') if src.status != trgt.status: changes.append(f'
  • Status: {src.status} -> {trgt.status}
  • ') if src.progress != trgt.progress and (src.status != 'completed' or trgt.status != 'completed'): changes.append( f'
  • Progress: {src.progress} -> {trgt.progress}
  • ')