add mpv anilist

This commit is contained in:
sudacode 2025-03-11 01:57:43 -07:00
parent c3555288a9
commit 81471617f2
Signed by: sudacode
SSH Key Fingerprint: SHA256:lT5C2bB398DcX6daCF/gYFNSTK3y+Du3oTGUnYzfTEw
5 changed files with 704 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
immersive-data
.ytdl_preload/
.ytdl-preload/
scripts/anilistUpdater/anilistToken.txt

View File

@ -205,3 +205,7 @@ ctrl+g script-binding sponsorblock/set_segment
ctrl+G script-binding sponsorblock/submit_segment
ctrl+h script-binding sponsorablock/upvote_setment
ctrl+H script-binding sponsorablock/downvote_segment
ctrl+A script-binding update_anilist
ctrl+B script-binding launch_anilist
ctrl+E script-binding open_folder

1
mpv-anilist-updater Submodule

@ -0,0 +1 @@
Subproject commit 6cc573ec7dadaec4acca58b7e1b91f86c8e54e95

View File

@ -0,0 +1,568 @@
import ast
import hashlib
import os
import re
import sys
import time
import webbrowser
import requests
from guessit import guessit
class AniListUpdater:
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):
self.access_token = (
self.load_access_token()
) # Replace token here if you don't use the .txt
self.user_id = self.get_user_id()
# Load token from anilistToken.txt
def load_access_token(self):
try:
with open(self.TOKEN_PATH, "r") 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):
try:
with open(self.TOKEN_PATH, "r") 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):
try:
with open(self.TOKEN_PATH, "r+") 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):
try:
with open(self.TOKEN_PATH, "a") 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):
return hashlib.sha256(path.encode("utf-8")).hexdigest()
def check_and_clean_cache(self, path, guessed_name):
try:
valid_lines = []
unique = set()
path = self.hash_path(os.path.dirname(path))
cached_result = (None, None)
with open(self.TOKEN_PATH, "r+") 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") 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):
try:
with open(self.TOKEN_PATH, "r") 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") 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):
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,
)
# print(f"Made an API Query with: Query: {query}\nVariables: {variables} ")
if response.status_code == 200:
return response.json()
else:
print(
f"API request failed: {response.status_code} - {response.text}\nQuery: {query}\nVariables: {variables}"
)
return None
@staticmethod
def season_order(season):
return {"WINTER": 1, "SPRING": 2, "SUMMER": 3, "FALL": 4}.get(season, 5)
def filter_valid_seasons(self, 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):
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):
file_info = self.parse_filename(filename)
cached_result, line_index = self.check_and_clean_cache(
filename, file_info.get("name")
)
# str -> tuple
cached_result = ast.literal_eval(cached_result) if cached_result else 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}")
# Change to the episode that needs to be updated
cached_result = cached_result[:4] + (file_info.get("episode"),)
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(f"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)
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):
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"]
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):
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 name != "":
break # If we got the name, its probable we already got season and part from the way folders are usually structured
# 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):
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})")
anime_data = (
seasons[0]["id"],
seasons[0]["title"]["romaji"],
(
seasons[0]["mediaListEntry"]["progress"]
if seasons[0]["mediaListEntry"] is not None
else None
),
seasons[0]["episodes"],
file_progress,
)
# 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)
print(
f"Final guessed anime: {next(season for season in seasons if season['id'] == anime_data[0])}"
) # Print data of the show
print(
f"Absolute episode {file_progress} corresponds to Anime: {anime_data[1]}, Episode: {anime_data[-1]}"
)
else:
print(f"Final guessed anime: {seasons[0]}") # Print data of the show
return anime_data
return (None, None, None, None)
# Update the anime based on file progress
def update_episode_count(self, result):
if result is None:
raise Exception("Parameter in update_episode_count is null.")
anime_id, anime_name, current_progress, total_episodes, file_progress = 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 watching/planning list?"
)
# 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})"
)
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}
# Handle changing "Planned to watch" animes to "Watching"
if file_progress != total_episodes:
variables["status"] = (
"CURRENT" # Set to "CURRENT" if it isn't the final episode.
)
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,
file_progress,
)
else:
print("Failed to update episode count.")
return False
def main():
try:
updater = AniListUpdater()
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,129 @@
local utils = require("mp.utils")
-- The directory the script will work on
-- Leaving it blank will make it work on every video you watch with mpv
-- You can still update manually via Ctrl+A
-- Setting a directory will only work if the path of the video contains this directory
DIRECTORY = ""
-- Example: DIRECTORY = "D:/Torrents" or "D:/Anime"
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 85% 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 >= 85 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 }
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 DIRECTORY ~= "" 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 string.find(path, DIRECTORY) ~= 1 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)