Initial commit
This commit is contained in:
		
							
								
								
									
										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 | ||||
|  | ||||
		Reference in New Issue
	
	Block a user