Stage new submodule locations

This commit is contained in:
2025-08-17 16:59:41 -07:00
parent e4afe79832
commit cfc6ac22e5
48 changed files with 12753 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
anilistToken.txt
.pylintrc
pyrightconfig.json
.vscode

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 AzuredBlue
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.

View File

@@ -0,0 +1,166 @@
# mpv-anilist-updater
A script for MPV that automatically updates your AniList based on the file you just watched.
> [!IMPORTANT]
> By default, the anime must be set to "watching", "planning" or "rewatching" to update progress. This is done in order to prevent updating the wrong show.<br>
> **Recommendation:** Check out the configuration options in your `anilistUpdater.conf` file to customize the script to your needs. See the [Configuration](#configuration-anilistupdaterconf) section for details.<br>
> [!TIP]
> In order for the script to work properly, make sure your files are named correctly:<br>
>
> - Either the file or folder its in must have the anime title in it<br>
> - The file must have the episode number in it (absolute numbering should work)<br>
> - In case of remakes, specify the year of the remake to ensure it updates the proper one<br>
>
> To avoid the script running and making useless API calls, you can set one or more directories in the config file. See the [Configuration](#configuration-anilistupdaterconf) section below.
For any issues, you can either open an issue on here, or message me on discord (azuredblue)
## Requirements
You will need Python 3 installed, as well as the libraries `guessit` and `requests`:
```bash
pip install guessit requests
```
## Installation
Simply download the `anilistUpdater` folder and put it in your mpv scripts folder, or download the contents and make the folder yourself.
You **WILL** need an AniList access token for it to work:
1. Visit `https://anilist.co/api/v2/oauth/authorize?client_id=20740&response_type=token`
2. Authorize the app
3. Copy the token
4. Create an `anilistToken.txt` file in the `anilistUpdater` folder (if not already there) and paste the token there.
This .txt file is also used to cache your AniList user id and to cache recently seen shows, avoiding extra API Calls.
This token is what allows the script to update the anime episode count and make api requests, it is not used for anything else.
## Configuration (`anilistUpdater.conf`)
> [!IMPORTANT]
> The config file is only generated after you run mpv at least once with the script installed. If the file is not created due to lack of write permissions, you can either run mpv as administrator once to generate it, or create the file manually in the appropriate directory. It is recommended to be in the `script-opts` directory.
When you first run the script, it will automatically create a configuration file called `anilistUpdater.conf` if it does not already exist. This file is typically created in your mpv `script-opts` directory where you have mpv installed. If it cannot be created there, it will try the `scripts` directory or your mpv config directory (e.g. `~/.config/mpv/` on Linux, `%APPDATA%/mpv/` on Windows).
**Config file search order:**
The script checks for the config file in the following order:
1. `script-opts` directory (recommended)
2. `scripts` directory (where the script itself is located)
3. mpv config directory (e.g. `~/.config/mpv/` or `%APPDATA%/mpv/`)
If the config file exists in more than one location, the one in the highest-priority directory (script-opts) will be used. For example, if you have a config in both `script-opts` and `scripts`, the one in `script-opts` will take precedence.
**You should edit this file to change any options.**
### Example `anilistUpdater.conf`
```ini
# Use 'yes' or 'no' for boolean options below
# Example for multiple directories (comma or semicolon separated):
# DIRECTORIES=D:/Torrents,D:/Anime
# or
# DIRECTORIES=D:/Torrents;D:/Anime
DIRECTORIES=
UPDATE_PERCENTAGE=85
SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE=no
UPDATE_PROGRESS_WHEN_REWATCHING=yes
SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT=yes
SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING=yes
```
#### Option Descriptions
- **DIRECTORIES**: Comma or semicolon separated list of directories. If empty, the script works for every video. Example: `DIRECTORIES=D:/Torrents,D:/Anime`
- Restricting directories only prevents the script from automatically updating AniList for files outside the specified directories. Manual actions using the keybinds (Ctrl+A, Ctrl+B, Ctrl+D) will still work for any file, regardless of its location.
- **UPDATE_PERCENTAGE**: Number (0-100). The percentage of the video you need to watch before it updates AniList automatically. Default is `85`.
- **SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE**: `yes`/`no`. If `yes`, when watching episode 1 of a completed anime, set it to rewatching and update progress. Default is `no`.
- **UPDATE_PROGRESS_WHEN_REWATCHING**: `yes`/`no`. If `yes`, allow updating progress for anime set to rewatching. Default is `yes`.
- **SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT**: `yes`/`no`. If `yes`, set to COMPLETED after last episode if status was CURRENT. **Default is `yes`.**
- **SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING**: `yes`/`no`. If `yes`, set to COMPLETED after last episode if status was REPEATING (rewatching). Default is `yes`.
> [!NOTE]
> All boolean options must be `yes` or `no` (not `true`/`false`).
## Usage
This script has 3 keybinds:
- Ctrl + A: Manually updates your AniList with the current episode you are watching.
- Ctrl + B: Opens the AniList page of the anime you are watching on your browser. Useful to see if it guessed the anime correctly.
- Ctrl + D: Opens the folder where the current video is playing. Useful if you have "your own" anime library, and navigating through folders is a pain.
The script will automatically update your AniList when the video you are watching reaches 85% completion (or the percentage you set in the config file).
You can change the keybinds in your input.conf:
```bash
A script-binding update_anilist
B script-binding launch_anilist
D script-binding open_folder
```
Or in the `.lua` file:
```lua
mp.add_key_binding('ctrl+a', 'update_anilist', function()
update_anilist("update")
end)
mp.add_key_binding('ctrl+b', 'launch_anilist', function()
update_anilist("launch")
end)
mp.add_key_binding('ctrl+d', 'open_folder', open_folder)
```
## How It Works
The script uses Guessit to try to get as much information as possible from the file name.
If the "episode" and "season" guess are before the title, it will consider that title wrong and try to get the title from the name of the folder it is in.
If the torrent file has absolute numbering (looking at you, SubsPlease), it will try to guess the season and episode by:
1. Searching for the anime name on the AniList API.
2. Get all results with a similar name, whose format are `TV` and the duration greater than 21 minutes.
3. Sort them based on release date.
4. Get the season based on the absolute episode number
It is not a flawless method. It won't work properly if the anime has seasons as ONA's. If it doesn't work properly, consider
changing the episode number to the normal format yourself, or simply give up on that series.
## FAQ (Probably)
**Q: On what formats does it work?**
A: It should work on most formats as long as the name is present in the file itself or the folder name.
`[SubsPlease] Boku no Hero Academia - 152 (1080p) [AAC292E7].mkv` will be detected as S7 E14 and updated accordingly.
`E12 - Welcome [F1119374].mkv` will work if the folder that it is in has `86` in the name. If it has `86 Part 2` then it should be `Episode 1`
If it does not, try changing the name of the file / folder, so the search has a better chance at finding it
**Q: Can I see which anime got detected before it updates?**
A: Ctrl + B will launch the AniList page of the anime it detects. To see more debug info launch via command line with `mpv file.mkv` or press the `` ` `` keybind to open the console.
**Q: Can it wrongfully update my anime?**
A: No, AniList's API does not allow updating an anime which isnt on your watch list. If it didn't detect your anime correctly, then it will
simply error.
**Q: It does not work with X format. What do I do?**
A: You can try launching the file through the command line with `mpv file.mkv` or opening the console through the keybind \` and see `Guessed name: X`. Try changing the file's name or folder so it has
a better chance at guessing the anime. If it still doesn't work, try opening a GitHub issue or messaging me on discord (azuredblue).
## Credits
This script was inspired by [mpv-open-anilist-page](https://github.com/ehoneyse/mpv-open-anilist-page) by ehoneyse.

View File

@@ -0,0 +1,727 @@
"""
mpv-anilist-updater: Automatically updates your AniList based on the file you just watched in MPV.
This script parses anime filenames, determines the correct AniList entry, and updates your progress
or status accordingly.
"""
# Configuration options for anilistUpdater (set in anilistUpdater.conf):
#
# DIRECTORIES: List or comma/semicolon-separated string. The directories the script will work on. Leaving it empty will make it work on every video you watch with mpv. Example: DIRECTORIES = ["D:/Torrents", "D:/Anime"]
#
# UPDATE_PERCENTAGE: Integer (0-100). The percentage of the video you need to watch before it updates AniList automatically. Default is 85 (usually before the ED of a usual episode duration).
#
# SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE: Boolean. If true, when watching episode 1 of a completed anime, set it to rewatching and update progress.
#
# UPDATE_PROGRESS_WHEN_REWATCHING: Boolean. If true, allow updating progress for anime set to rewatching. This is for if you want to set anime to rewatching manually, but still update progress automatically.
#
# SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT: Boolean. If true, set to COMPLETED after last episode if status was CURRENT.
#
# SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING: Boolean. If true, set to COMPLETED after last episode if status was REPEATING (rewatching).
import sys
import os
import webbrowser
import time
import ast
import hashlib
import re
import json
import requests
from guessit import guessit
class AniListUpdater:
"""
Handles AniList authentication, file parsing, API requests, and updating anime progress/status.
"""
ANILIST_API_URL = 'https://graphql.anilist.co'
TOKEN_PATH = os.path.join(os.path.dirname(__file__), 'anilistToken.txt')
OPTIONS = "--excludes country --excludes language --type episode"
CACHE_REFRESH_RATE = 24 * 60 * 60
# Load token and user id
def __init__(self, options):
"""
Initializes the AniListUpdater, loading the access token and user ID.
"""
self.access_token = self.load_access_token() # Replace token here if you don't use the .txt
self.user_id = self.get_user_id()
self.options = options
# Load token from anilistToken.txt
def load_access_token(self):
"""
Loads the AniList access token from the token file.
Returns:
str or None: The access token, or None if not found.
"""
try:
with open(self.TOKEN_PATH, 'r', encoding='utf-8') as file:
content = file.read().strip()
if ':' in content:
token = content.split(':', 1)[1].splitlines()[0]
return token
return content
except Exception as e:
print(f'Error reading access token: {e}')
return None
# Load user id from file, if not then make api request and save it.
def get_user_id(self):
"""
Loads the AniList user ID from the token file, or fetches and caches it if not present.
Returns:
int or None: The user ID, or None if not found.
"""
try:
with open(self.TOKEN_PATH, 'r', encoding='utf-8') as file:
content = file.read().strip()
if ':' in content:
return int(content.split(':')[0])
except Exception as e:
print(f'Error reading user ID: {e}')
query = '''
query {
Viewer {
id
}
}
'''
response = self.make_api_request(query, None, self.access_token)
if response and 'data' in response:
user_id = response['data']['Viewer']['id']
self.save_user_id(user_id)
return user_id
return None
# Cache user id
def save_user_id(self, user_id):
"""
Saves the user ID to the token file, prepending it to the existing content.
Args:
user_id (int): The AniList user ID.
"""
try:
with open(self.TOKEN_PATH, 'r+', encoding='utf-8') as file:
content = file.read()
file.seek(0)
file.write(f'{user_id}:{content}')
except Exception as e:
print(f'Error saving user ID: {e}')
def cache_to_file(self, path, guessed_name, result):
"""
Appends a cache entry to the token file for a given file path and guessed anime name.
Args:
path (str): The file path.
guessed_name (str): The guessed anime name.
result (tuple): The result to cache.
"""
try:
with open(self.TOKEN_PATH, 'a', encoding='utf-8') as file:
# Epoch Time, hash of the path, guessed name, result
file.write(f'\n{time.time()};;{self.hash_path(os.path.dirname(path))};;{guessed_name};;{result}')
except Exception as e:
print(f'Error trying to cache {result}: {e}')
def hash_path(self, path):
"""
Returns a SHA256 hash of the given path.
Args:
path (str): The path to hash.
Returns:
str: The hashed path.
"""
return hashlib.sha256(path.encode('utf-8')).hexdigest()
def check_and_clean_cache(self, path, guessed_name):
"""
Checks the cache for a matching entry and cleans out expired entries.
Args:
path (str): The file path.
guessed_name (str): The guessed anime name.
Returns:
tuple: (cached_result, line_index) or (None, None) if not found.
"""
try:
valid_lines = []
unique = set()
path = self.hash_path(os.path.dirname(path))
cached_result = (None, None)
with open(self.TOKEN_PATH, 'r+', encoding='utf-8') as file:
orig_lines = file.readlines()
for line in orig_lines:
if line.strip():
if ';;' in line:
epoch, dir_path, guess, result = line.strip().split(';;')
if time.time() - float(epoch) < self.CACHE_REFRESH_RATE and (dir_path, guess) not in unique:
unique.add((dir_path, guess))
valid_lines.append(line)
if dir_path == path and guess == guessed_name:
cached_result = (result, len(valid_lines) - 1)
else:
valid_lines.append(line)
if valid_lines != orig_lines:
with open(self.TOKEN_PATH, 'w', encoding='utf-8') as file:
file.writelines(valid_lines)
return cached_result
except Exception as e:
print(f'Error trying to read cache file: {e}')
def update_cache(self, path, guessed_name, result, index):
"""
Updates a cache entry at the given index with new data.
Args:
path (str): The file path.
guessed_name (str): The guessed anime name.
result (tuple): The result to cache.
index (int): The line index in the cache file.
"""
try:
with open(self.TOKEN_PATH, 'r', encoding='utf-8') as file:
lines = file.readlines()
if 0 <= index < len(lines):
# Update the line at the given index with the new cache data
updated_line = f'{time.time()};;{self.hash_path(os.path.dirname(path))};;{guessed_name};;{result}\n' if result is not None else ''
lines[index] = updated_line
# Write the updated lines back to the file
with open(self.TOKEN_PATH, 'w', encoding='utf-8') as file:
file.writelines(lines)
else:
print(f"Invalid index {index} for updating cache.")
except Exception as e:
print(f'Error trying to update cache file: {e}')
# Function to make an api request to AniList's api
def make_api_request(self, query, variables=None, access_token=None):
"""
Makes a POST request to the AniList GraphQL API.
Args:
query (str): The GraphQL query string.
variables (dict, optional): Variables for the query.
access_token (str, optional): AniList access token.
Returns:
dict or None: The API response as a dict, or None on error.
"""
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
if access_token:
headers['Authorization'] = f'Bearer {access_token}'
response = requests.post(self.ANILIST_API_URL, json={'query': query, 'variables': variables}, headers=headers, timeout=10)
# print(f"Made an API Query with: Query: {query}\nVariables: {variables} ")
if response.status_code == 200:
return response.json()
print(f'API request failed: {response.status_code} - {response.text}\nQuery: {query}\nVariables: {variables}')
return None
@staticmethod
def season_order(season):
"""
Returns a numeric order for seasons for sorting.
Args:
season (str): The season name (WINTER, SPRING, SUMMER, FALL).
Returns:
int: The order value.
"""
return {'WINTER': 1, 'SPRING': 2, 'SUMMER': 3, 'FALL': 4}.get(season, 5)
def filter_valid_seasons(self, seasons):
"""
Filters and sorts valid TV seasons for absolute numbering logic.
Args:
seasons (list): List of season dicts from AniList API.
Returns:
list: Filtered and sorted list of seasons.
"""
# Filter only to those whose format is TV and duration > 21 OR those who have no duration and are releasing.
# This is due to newly added anime having duration as null
seasons = [
season for season in seasons
if ((season['duration'] is None and season['status'] == 'RELEASING') or
(season['duration'] is not None and season['duration'] > 21)) and season['format'] == 'TV'
]
# One of the problems with this filter is needing the format to be 'TV'
# But if accepted any format, it would also include many ONA's which arent included in absolute numbering.
# Sort them based on release date
seasons = sorted(seasons, key=lambda x: (x['seasonYear'] if x['seasonYear'] else float("inf"), self.season_order(x['season'] if x['season'] else float("inf"))))
return seasons
# Finds the season and episode of an anime with absolute numbering
def find_season_and_episode(self, seasons, absolute_episode):
"""
Finds the correct season and relative episode for an absolute episode number.
Args:
seasons (list): List of season dicts.
absolute_episode (int): The absolute episode number.
Returns:
tuple: (season_id, season_title, progress, episodes, relative_episode)
"""
accumulated_episodes = 0
for season in seasons:
season_episodes = season.get('episodes', 12) if season.get('episodes') else 12
if accumulated_episodes + season_episodes >= absolute_episode:
return (
season.get('id'),
season.get('title', {}).get('romaji'),
season.get('mediaListEntry', {}).get('progress') if season.get('mediaListEntry') else None,
season.get('episodes'),
absolute_episode - accumulated_episodes
)
accumulated_episodes += season_episodes
return (None, None, None, None, None)
def handle_filename(self, filename):
"""
Main entry point for handling a file: parses, checks cache, updates AniList, and manages cache.
Args:
filename (str): The path to the video file.
"""
file_info = self.parse_filename(filename)
cached_result, line_index = self.check_and_clean_cache(filename, file_info.get('name'))
# str -> tuple
if cached_result:
try:
cached_result = ast.literal_eval(cached_result)
if not isinstance(cached_result, (tuple, list)):
cached_result = None
except Exception:
cached_result = None
else:
cached_result = None
# True if:
# Is not cached
# Tries to update and current episode is not the next one.
# It is not in your watching/planning list.
# This means that for shows with absolute numbering, if it updates, it will always call the API
# Since it needs to convert from absolute to relative.
if cached_result is None or (cached_result and (file_info.get('episode') != cached_result[2] + 1) and sys.argv[2] != 'launch'):
result = self.get_anime_info_and_progress(file_info.get('name'), file_info.get('episode'), file_info.get('year'))
result = self.update_episode_count(result) # Returns either the same, or the updated result
# If it returned a result and the progress isnt None, then put it in cache, since it wasn't.
if result and result[2] is not None:
if line_index is not None:
print(f'Updating cache to: {result}')
self.update_cache(filename, file_info.get('name'), result, line_index)
else:
print(f'Not found in cache! Adding to file... {result}')
self.cache_to_file(filename, file_info.get('name'), result)
# True for opening AniList and updating next episode.
else:
print(f'Found in cache! {cached_result}')
# Only proceed if cached_result is a tuple/list and has enough elements
if isinstance(cached_result, (tuple, list)) and len(cached_result) >= 4:
# Change to the episode that needs to be updated
if len(cached_result) > 5:
cached_result = tuple(cached_result[:4]) + (file_info.get('episode'),) + tuple(cached_result[5:])
else:
cached_result = tuple(cached_result[:4]) + (file_info.get('episode'),)
# Ensure tuple is 6 elements (pad with "CURRENT" if needed)
if len(cached_result) == 5:
cached_result = cached_result + ("CURRENT",)
result = self.update_episode_count(cached_result)
# If it's different, update in cache as well.
if cached_result != result and result:
print(f'Updating cache to: {result}')
self.update_cache(filename, file_info.get('name'), result, line_index)
# If it either errored or couldn't update, retry without cache.
if not result:
print('Failed to update through cache, retrying without.')
# Deleting from the cache
self.update_cache(filename, file_info.get('name'), None, line_index)
# Retrying
self.handle_filename(filename)
else:
print('Cached result is invalid, ignoring cache.')
# Remove invalid cache entry
if line_index is not None:
self.update_cache(filename, file_info.get('name'), None, line_index)
# Retry without cache
self.handle_filename(filename)
return
# Hardcoded exceptions to fix detection
# Easier than just renaming my files 1 by 1 on Qbit
# Every exception I find will be added here
def fix_filename(self, path_parts):
"""
Applies hardcoded exceptions and fixes to the filename and folder structure for better title detection.
Args:
path_parts (list): List of path components.
Returns:
list: Modified path components.
"""
guess = guessit(path_parts[-1], self.OPTIONS) # Simply easier for fixing the filename if we have what it is detecting.
path_parts[-1] = os.path.splitext(path_parts[-1])[0]
pattern = r'[\\\/:!\*\?"<>\|\._-]'
title_depth = -1
# Fix from folders if the everything is not in the filename
if 'title' not in guess:
# Depth=2
for depth in range(2, min(4, len(path_parts))):
folder_guess = guessit(path_parts[-depth], self.OPTIONS)
if 'title' in folder_guess:
guess['title'] = folder_guess['title']
title_depth = -depth
break
if 'title' not in guess:
print(f"Couldn't find title in filename '{path_parts[-1]}'! Guess result: {guess}")
return path_parts
# Only clean up titles for some series
cleanup_titles = ['Ranma', 'Chi', 'Bleach', 'Link Click']
if any(title in guess['title'] for title in cleanup_titles):
path_parts[title_depth] = re.sub(pattern, ' ', path_parts[title_depth])
path_parts[title_depth] = " ".join(path_parts[title_depth].split())
if 'Centimeters per Second' == guess['title'] and 5 == guess.get('episode', 0):
path_parts[title_depth] = path_parts[title_depth].replace(' 5 ', ' Five ')
# For some reason AniList has this film in 3 parts.
path_parts[title_depth] = path_parts[title_depth].replace('per Second', 'per Second 3')
return path_parts
# Parse the file name using guessit
def parse_filename(self, filepath):
"""
Parses the filename and folder structure to extract anime title, episode, season, and year.
Args:
filepath (str): The path to the video file.
Returns:
dict: Parsed info with keys 'name', 'episode', 'year'.
"""
path_parts = self.fix_filename(filepath.replace('\\', '/').split('/'))
filename = path_parts[-1]
name, season, part, year = '', '', '', ''
episode = 1
# First, try to guess from the filename
guess = guessit(filename, self.OPTIONS)
print(f'File name guess: {filename} -> {dict(guess)}')
# Episode guess from the title.
# Usually, releases are formated [Release Group] Title - S01EX
# If the episode index is 0, that would mean that the episode is before the title in the filename
# Which is a horrible way of formatting it, so assume its wrong
# If its 1, then the title is probably 0, so its okay. (Unless season is 0)
# Really? What is the format "S1E1 - {title}"? That's almost psycopathic.
# If its >2, theres probably a Release Group and Title / Season / Part, so its good
episode = guess.get('episode', None)
season = guess.get('season', '')
part = str(guess.get('part', ''))
year = str(guess.get('year', ''))
# Quick fixes assuming season before episode
# 'episode_title': '02' in 'S2 02'
if guess.get('episode_title', '').isdigit() and episode is None:
print(f'Detected episode in episode_title. Episode: {int(guess.get("episode_title"))}')
episode = int(guess.get('episode_title'))
# 'episode': [86, 13] (EIGHTY-SIX), [1, 2, 3] (RANMA) lol.
if isinstance(episode, list):
print(f'Detected multiple episodes: {episode}. Picking last one.')
episode = episode[-1]
# 'season': [2, 3] in "S2 03"
if isinstance(season, list):
print(f'Detected multiple seasons: {season}. Picking first one as season.')
if episode is None:
print('Episode still not detected. Picking last position of the season list.')
episode = season[-1]
season = season[0]
episode = episode or 1
season = str(season)
keys = list(guess.keys())
episode_index = keys.index('episode') if 'episode' in guess else 1
season_index = keys.index('season') if 'season' in guess else -1
title_in_filename = 'title' in guess and (episode_index > 0 and (season_index > 0 or season_index == -1))
# If the title is not in the filename or episode index is 0, try the folder name
# If the episode index > 0 and season index > 0, its safe to assume that the title is in the file name
if title_in_filename:
name = guess['title']
else:
# If it isnt in the name of the file, try to guess using the name of the folder it is stored in
# Depth=2 folders
for depth in [2, 3]:
folder_guess = guessit(path_parts[-depth], self.OPTIONS) if len(path_parts) > depth-1 else ''
if folder_guess != '':
print(f'{depth-1}{"st" if depth-1==1 else "nd"} Folder guess:\n{path_parts[-depth]} -> {dict(folder_guess)}')
name = str(folder_guess.get('title', ''))
season = season or str(folder_guess.get('season', ''))
part = part or str(folder_guess.get('part', ''))
year = year or str(folder_guess.get('year', ''))
# If we got the name, its probable we already got season and part from the way folders are usually structured
if name != '':
break
# Add season and part if there are
if season and (int(season) > 1 or part):
name += f" Season {season}"
if part:
name += f" Part {part}"
print('Guessed name: ' + name)
return {
'name': name,
'episode': episode,
'year': year,
}
def get_anime_info_and_progress(self, name, file_progress, year):
"""
Queries AniList for anime info and user progress for a given title and year.
Args:
name (str): Anime title.
file_progress (int): Episode number from the file.
year (str): Year string (may be empty).
Returns:
tuple: (anime_id, anime_name, current_progress, total_episodes, file_progress, current_status)
"""
query = '''
query($search: String, $year: FuzzyDateInt, $page: Int) {
Page(page: $page) {
media (search: $search, type: ANIME, startDate_greater: $year) {
id
title { romaji }
season
seasonYear
episodes
duration
format
status
mediaListEntry {
status
progress
media {
episodes
}
}
}
}
}
'''
variables = {'search': name, 'year': year or 1, 'page': 1}
response = self.make_api_request(query, variables, self.access_token)
if response and 'data' in response:
seasons = response['data']['Page']['media']
# This is the first element, which is the same as Media(search: $search)
if len(seasons) == 0:
raise Exception(f"Couldn\'t find an anime from this title! ({name})")
entry = seasons[0]['mediaListEntry']
anime_data = (
seasons[0]['id'],
seasons[0]['title']['romaji'],
entry['progress'] if entry is not None else None,
seasons[0]['episodes'],
file_progress,
entry['status'] if entry is not None else None
)
# If the episode in the file name is larger than the total amount of episodes
# Then they are using absolute numbering format for episodes (looking at you SubsPlease)
# Try to guess season and episode.
if seasons[0]['episodes'] is not None and file_progress > seasons[0]['episodes']:
seasons = self.filter_valid_seasons(seasons)
print('Related shows:', ", ".join(season["title"]["romaji"] for season in seasons))
anime_data = self.find_season_and_episode(seasons, file_progress)
found_season = next((season for season in seasons if season['id'] == anime_data[0]), None)
found_entry = found_season['mediaListEntry'] if found_season and found_season['mediaListEntry'] else None
anime_data = (
anime_data[0],
anime_data[1],
anime_data[2],
anime_data[3],
anime_data[4],
found_entry['status'] if found_entry else None
)
print(f"Final guessed anime: {found_season}")
print(f'Absolute episode {file_progress} corresponds to Anime: {anime_data[1]}, Episode: {anime_data[-2]}')
else:
print(f"Final guessed anime: {seasons[0]}")
return anime_data
return (None, None, None, None, None, None)
# Update the anime based on file progress
def update_episode_count(self, result):
"""
Updates the episode count and/or status for an anime entry on AniList, according to user settings.
Args:
result (tuple): (anime_id, anime_name, current_progress, total_episodes, file_progress, current_status)
Returns:
tuple or bool: Updated result tuple, or False on failure.
"""
if result is None:
raise Exception('Parameter in update_episode_count is null.')
anime_id, anime_name, current_progress, total_episodes, file_progress, current_status = result
# Only launch anilist
if sys.argv[2] == 'launch':
print(f'Opening AniList for "{anime_name}": https://anilist.co/anime/{anime_id}')
webbrowser.open_new_tab(f'https://anilist.co/anime/{anime_id}')
return result
if current_progress is None:
raise Exception('Failed to get current episode count. Is it on your list?')
# Handle completed -> rewatching on first episode
if (current_status == 'COMPLETED' and file_progress == 1 and self.options['SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE']):
# Needs to update in 2 steps, since AniList doesn't allow setting progress while changing the status from completed to rewatching. If you try, it will just reset the progress to 0.
print('Setting status to REPEATING (rewatching) and updating progress for first episode of completed anime.')
# Step 1: Set to REPEATING, progress=0
query = '''
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) {
status
id
progress
}
}
'''
variables = {'mediaId': anime_id, 'progress': 0, 'status': 'REPEATING'}
response = self.make_api_request(query, variables, self.access_token)
# Step 2: Set progress to 1
variables = {'mediaId': anime_id, 'progress': 1}
response = self.make_api_request(query, variables, self.access_token)
if response and 'data' in response:
updated_progress = response['data']['SaveMediaListEntry']['progress']
print(f'Episode count updated successfully! New progress: {updated_progress}')
return (anime_id, anime_name, updated_progress, total_episodes, 1, 'REPEATING')
print('Failed to update episode count.')
return False
# Handle updating progress for rewatching
if (current_status == 'REPEATING' and self.options['UPDATE_PROGRESS_WHEN_REWATCHING']):
print('Updating progress for anime set to REPEATING (rewatching).')
status_to_set = 'REPEATING'
# Only update if status is CURRENT or PLANNING
elif current_status in ['CURRENT', 'PLANNING']:
# If its lower than the current progress, dont update.
if file_progress <= current_progress:
raise Exception(f'Episode was not new. Not updating ({file_progress} <= {current_progress})')
status_to_set = 'CURRENT' if file_progress != total_episodes else None
else:
raise Exception(f'Anime is not in a modifiable state (status: {current_status}). Not updating.')
set_to_completed = False
if file_progress == total_episodes:
if current_status == 'CURRENT' and self.options['SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT']:
set_to_completed = True
elif current_status == 'REPEATING' and self.options['SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING']:
set_to_completed = True
query = '''
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) {
status
id
progress
}
}
'''
variables = {'mediaId': anime_id, 'progress': file_progress}
if status_to_set:
variables['status'] = status_to_set
response = self.make_api_request(query, variables, self.access_token)
if response and 'data' in response:
updated_progress = response['data']['SaveMediaListEntry']['progress']
print(f'Episode count updated successfully! New progress: {updated_progress}')
if set_to_completed:
print('Setting status to COMPLETED after last episode.')
complete_query = '''
mutation ($mediaId: Int, $status: MediaListStatus) {
SaveMediaListEntry (mediaId: $mediaId, status: $status) {
status
id
progress
}
}
'''
complete_variables = {'mediaId': anime_id, 'status': 'COMPLETED'}
complete_response = self.make_api_request(complete_query, complete_variables, self.access_token)
if complete_response and 'data' in complete_response:
print('Status set to COMPLETED successfully.')
else:
print('Failed to set status to COMPLETED.')
return (anime_id, anime_name, updated_progress, total_episodes, file_progress, current_status)
print('Failed to update episode count.')
return False
def main():
"""
Main entry point for the script. Handles encoding and runs the updater.
"""
try:
# Reconfigure to utf-8
if sys.stdout.encoding != 'utf-8':
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except Exception as e_reconfigure:
print(f"Couldn\'t reconfigure stdout/stderr to UTF-8: {e_reconfigure}", file=sys.stderr)
# Parse options from argv[3] if present
options = {
"SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE": False,
"UPDATE_PROGRESS_WHEN_REWATCHING": True,
"SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT": False,
"SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING": True
}
if len(sys.argv) > 3:
user_options = json.loads(sys.argv[3])
options.update(user_options)
# Pass options to AniListUpdater
updater = AniListUpdater(options)
updater.handle_filename(sys.argv[1])
except Exception as e:
print(f'ERROR: {e}')
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,262 @@
--[[
Configuration options for anilistUpdater (set in anilistUpdater.conf):
DIRECTORIES: Table or comma/semicolon-separated string. The directories the script will work on. Leaving it empty will make it work on every video you watch with mpv. Example: DIRECTORIES = {"D:/Torrents", "D:/Anime"}
UPDATE_PERCENTAGE: Number (0-100). The percentage of the video you need to watch before it updates AniList automatically. Default is 85 (usually before the ED of a usual episode duration).
SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE: Boolean. If true, when watching episode 1 of a completed anime, set it to rewatching and update progress.
UPDATE_PROGRESS_WHEN_REWATCHING: Boolean. If true, allow updating progress for anime set to rewatching. This is for if you want to set anime to rewatching manually, but still update progress automatically.
SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT: Boolean. If true, set to COMPLETED after last episode if status was CURRENT.
SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING: Boolean. If true, set to COMPLETED after last episode if status was REPEATING (rewatching).
]]
local utils = require 'mp.utils'
local mpoptions = require("mp.options")
local conf_name = "anilistUpdater.conf"
local script_dir = (debug.getinfo(1).source:match("@?(.*/)") or "./")
-- Try script-opts directory (sibling to scripts)
local script_opts_dir = script_dir:match("^(.-)[/\\]scripts[/\\]")
if script_opts_dir then
script_opts_dir = utils.join_path(script_opts_dir, "script-opts")
else
-- Fallback: try to find mpv config dir
script_opts_dir = os.getenv("APPDATA") and utils.join_path(utils.join_path(os.getenv("APPDATA"), "mpv"), "script-opts") or
os.getenv("HOME") and utils.join_path(utils.join_path(utils.join_path(os.getenv("HOME"), ".config"), "mpv"), "script-opts") or
nil
end
local script_opts_path = script_opts_dir and utils.join_path(script_opts_dir, conf_name) or nil
-- Try script directory
local script_path = utils.join_path(script_dir, conf_name)
-- Try mpv config directory
local mpv_conf_dir = os.getenv("APPDATA") and utils.join_path(os.getenv("APPDATA"), "mpv") or os.getenv("HOME") and
utils.join_path(utils.join_path(os.getenv("HOME"), ".config"), "mpv") or nil
local mpv_conf_path = mpv_conf_dir and utils.join_path(mpv_conf_dir, conf_name) or nil
local conf_paths = {script_opts_path, script_path, mpv_conf_path}
local default_conf = [[
# Use 'yes' or 'no' for boolean options below
# Example for multiple directories (comma or semicolon separated):
# DIRECTORIES=D:/Torrents,D:/Anime
# or
# DIRECTORIES=D:/Torrents;D:/Anime
DIRECTORIES=
UPDATE_PERCENTAGE=85
SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE=no
UPDATE_PROGRESS_WHEN_REWATCHING=yes
SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT=yes
SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING=yes
]]
-- Try to find config file
local conf_path = nil
for _, path in ipairs(conf_paths) do
if path then
local f = io.open(path, "r")
if f then
f:close()
conf_path = path
print("Found config at: " .. path)
break
end
end
end
-- If not found, try to create in order
if not conf_path then
for _, path in ipairs(conf_paths) do
if path then
local f = io.open(path, "w")
if f then
f:write(default_conf)
f:close()
conf_path = path
print("Created config at: " .. path)
break
end
end
end
end
-- If still not found or created, warn and use defaults
if not conf_path then
mp.msg.warn("Could not find or create anilistUpdater.conf in any known location! Using default options.")
end
-- Now load options as usual
local options = {
DIRECTORIES = "",
UPDATE_PERCENTAGE = 85,
SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE = false,
UPDATE_PROGRESS_WHEN_REWATCHING = true,
SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT = true,
SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING = true
}
if conf_path then
mpoptions.read_options(options, "anilistUpdater")
end
-- Parse DIRECTORIES if it's a string (comma or semicolon separated)
if type(options.DIRECTORIES) == "string" and options.DIRECTORIES ~= "" then
local dirs = {}
for dir in string.gmatch(options.DIRECTORIES, "([^,;]+)") do
table.insert(dirs, (dir:gsub("^%s*(.-)%s*$", "%1"))) -- trim
end
options.DIRECTORIES = dirs
elseif type(options.DIRECTORIES) == "string" then
options.DIRECTORIES = {}
end
-- When calling Python, pass only the options relevant to it
local python_options = {
SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE = options.SET_COMPLETED_TO_REWATCHING_ON_FIRST_EPISODE,
UPDATE_PROGRESS_WHEN_REWATCHING = options.UPDATE_PROGRESS_WHEN_REWATCHING,
SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT = options.SET_TO_COMPLETED_AFTER_LAST_EPISODE_CURRENT,
SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING = options.SET_TO_COMPLETED_AFTER_LAST_EPISODE_REWATCHING
}
local python_options_json = utils.format_json(python_options)
DIRECTORIES = options.DIRECTORIES
UPDATE_PERCENTAGE = tonumber(options.UPDATE_PERCENTAGE) or 85
local function path_starts_with_any(path, directories)
for _, dir in ipairs(directories) do
if path:sub(1, #dir) == dir then
return true
end
end
return false
end
function callback(success, result, error)
if result.status == 0 then
mp.osd_message("Updated anime correctly.", 2)
end
end
local function get_python_command()
local os_name = package.config:sub(1, 1)
if os_name == '\\' then
-- Windows
return "python"
else
-- Linux
return "python3"
end
end
local python_command = get_python_command()
-- Make sure it doesnt trigger twice in 1 video
local triggered = false
-- Function to check if we've reached the user-defined percentage of the video
function check_progress()
if triggered then
return
end
local percent_pos = mp.get_property_number("percent-pos")
if percent_pos then
if percent_pos >= UPDATE_PERCENTAGE then
update_anilist("update")
triggered = true
end
end
end
-- Function to launch the .py script
function update_anilist(action)
if action == "launch" then
mp.osd_message("Launching AniList", 2)
end
local script_dir = debug.getinfo(1).source:match("@?(.*/)")
local directory = mp.get_property("working-directory")
-- It seems like in Linux working-directory sometimes returns it without a "/" at the end
directory = (directory:sub(-1) == '/' or directory:sub(-1) == '\\') and directory or directory .. '/'
-- For some reason, "path" sometimes returns the absolute path, sometimes it doesn't.
local file_path = mp.get_property("path")
local path = utils.join_path(directory, file_path)
local table = {}
table.name = "subprocess"
table.args = {python_command, script_dir .. "anilistUpdater.py", path, action, python_options_json}
local cmd = mp.command_native_async(table, callback)
end
mp.observe_property("percent-pos", "number", check_progress)
-- Reset triggered
mp.register_event("file-loaded", function()
triggered = false
if #DIRECTORIES > 0 then
local directory = mp.get_property("working-directory")
directory = (directory:sub(-1) == '/' or directory:sub(-1) == '\\') and directory or directory .. '/'
local file_path = mp.get_property("path")
local path = utils.join_path(directory, file_path)
path = path:gsub("\\", "/")
if not path_starts_with_any(path, DIRECTORIES) then
mp.unobserve_property(check_progress)
end
end
end)
-- Keybinds, modify as you please
mp.add_key_binding('ctrl+a', 'update_anilist', function()
update_anilist("update")
end)
mp.add_key_binding('ctrl+b', 'launch_anilist', function()
update_anilist("launch")
end)
-- Open the folder that the video is
function open_folder()
local path = mp.get_property("path")
local directory
if not path then
mp.msg.warn("No file is currently playing.")
return
end
if path:find('\\') then
directory = path:match("(.*)\\")
elseif path:find('\\\\') then
directory = path:match("(.*)\\\\")
else
directory = mp.get_property("working-directory")
end
-- Use the system command to open the folder in File Explorer
local args
if package.config:sub(1, 1) == '\\' then
-- Windows
args = {'explorer', directory}
elseif os.getenv("XDG_CURRENT_DESKTOP") or os.getenv("WAYLAND_DISPLAY") or os.getenv("DISPLAY") then
-- Linux (assume a desktop environment like GNOME, KDE, etc.)
args = {'xdg-open', directory}
elseif package.config:sub(1, 1) == '/' then
-- macOS
args = {'open', directory}
end
mp.command_native({
name = "subprocess",
args = args,
detach = true
})
end
mp.add_key_binding('ctrl+d', 'open_folder', open_folder)