diff --git a/.gitignore b/.gitignore index 3d97b40..6d8e1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -220,5 +220,6 @@ __marimo__/ .streamlit/secrets.toml # Anical +airingdata.json +listdata.json config.toml -data.json \ No newline at end of file diff --git a/example.config.toml b/example.config.toml index 6225d79..c55829e 100644 --- a/example.config.toml +++ b/example.config.toml @@ -2,5 +2,8 @@ userId = 8283179 mediaType = "ANIME" +[anical] +listNames = ["Completed", "Planning", "Watching", "Rewatching", "Paused"] + [ical] # reserved for future use \ No newline at end of file diff --git a/main.py b/main.py index 327829d..e90b28d 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,7 @@ def getMediaListCollection( mediaType=config["anilist"]["mediaType"], userId=config["anilist"]["userId"], *, - cachePath="data.json" + cachePath="listdata.json" ): """ Returns a tuple of (entries, cached) @@ -62,7 +62,7 @@ def getMediaListCollection( if not cacheExpired and not parametersChanged: print("Cache is fresh, using cached data...") - return (entries["response"], True) + return (entries, True) if cacheExpired: print("Cache has gone stale, refreshing...") @@ -147,9 +147,193 @@ def getMediaListCollection( return (form, False) +def getAiringScheduleBatch( + animeIds, + *, + ttl=TTL, + cachePath="airingdata.json", + maxRetries=5 +): + """ + Returns a tuple of (entries, cached) + + Entries is a dictionary of the values to be written to the cache file, such as: + - timestamp + - ttl + - params supplied + - response + + response is a dictionary of mediaId (as a string) -> {"title": {"romaji", "english"}, "schedule": [upcoming airing schedule items]} + + cached is a boolean indicating whether the cache file was used or not + + Takes the following arguments: + - animeIds: list of AniList media ids to fetch airing schedules for + - ttl: seconds the cache stays fresh for. Only applied when the cache is refreshed; + an existing cache keeps the ttl it was written with (defaults to value from config.toml) + - cachePath: the path to the cache file (if not supplied, defaults to "airingdata.json") + - maxRetries: how many times in a row to retry after a 429 before giving up (defaults to 5) + """ + + if not animeIds: + raise RuntimeError("animeIds must not be empty") + + animeIds = sorted(set(int(i) for i in animeIds)) + + if path.exists(cachePath): + with open(cachePath, "r") as f: + entries = json.load(f) + + if ( + "timestamp" in entries + and "ttl" in entries + and "response" in entries + and "params" in entries + ): + timestamp = float(entries["timestamp"]) + cacheTtl = float(entries["ttl"]) + + cacheIds = entries["params"].get("animeIds") + + cacheExpired = time.time() - timestamp > cacheTtl + parametersChanged = cacheIds != animeIds + + if not cacheExpired and not parametersChanged: + print("Airing cache is fresh, using cached data...") + return (entries, True) + + if cacheExpired: + print("Airing cache has gone stale, refreshing...") + elif parametersChanged: + print("Airing cache parameters changed, refreshing...") + else: + print("Airing cache is invalid, refreshing...") + + query = """ + query ($ids: [Int], $page: Int) { + Page(page: $page, perPage: 50) { + pageInfo { + hasNextPage + currentPage + } + airingSchedules(mediaId_in: $ids, notYetAired: true, sort: TIME) { + id + airingAt + timeUntilAiring + episode + mediaId + media { + title { + romaji + english + } + } + } + } + } + """ + + schedules = {} + page = 1 + retries = 0 + + while True: + variables = { + "ids": animeIds, + "page": page, + } + + resp = requests.post( + "https://graphql.anilist.co", + json={ + "query": query, + "variables": variables, + }, + timeout=30, + ) + + # retry if rate limited + if resp.status_code == 429: + retries += 1 + if retries > maxRetries: + raise RuntimeError("Rate limited too many times, giving up") + wait = int(resp.headers.get("Retry-After", 60)) + print(f"Rate limited, waiting {wait}s...") + time.sleep(wait) + continue + + retries = 0 + resp.raise_for_status() + + data = resp.json() + + if "errors" in data: + raise RuntimeError(data["errors"]) + + pageData = data["data"]["Page"] + + for item in pageData["airingSchedules"]: + # media can be null, fall back to empty titles + media = item.pop("media", None) or {} + title = media.get("title") or {} + + entry = schedules.setdefault( + str(item["mediaId"]), + { + "title": { + "romaji": title.get("romaji"), + "english": title.get("english"), + }, + "schedule": [], + }, + ) + entry["schedule"].append(item) + + if not pageData["pageInfo"]["hasNextPage"]: + break + + page += 1 + + form = { + "timestamp": str(time.time()), + "ttl": str(ttl), + "params": { + "animeIds": animeIds, + }, + "response": schedules + } + + return (form, False) + if __name__ == "__main__": entries, cached = getMediaListCollection() - + if not cached: - with open("data.json", "w") as f: - f.write(f"{json.dumps(entries, indent=2)}") \ No newline at end of file + with open("listdata.json", "w") as f: + f.write(f"{json.dumps(entries, indent=2)}") + + lists = entries["response"] + + print("\nList Names updating for:") + for li in config["anical"]["listNames"]: + print(li) + + # get a list of all the animes ids in these lists: + animeIds = [] + for li in config["anical"]["listNames"]: + if li not in lists: + print(f"Warning: list '{li}' not found, skipping") + continue + for entry in lists[li]: + animeIds.append(entry["media"]["id"]) + + print(f"\nFound {len(animeIds)} anime ids") + + airing, airingCached = getAiringScheduleBatch(animeIds) + + if not airingCached: + with open("airingdata.json", "w") as f: + f.write(f"{json.dumps(airing, indent=2)}") + + schedules = airing["response"] + print(f"\nGot upcoming episodes for {len(schedules)} anime") \ No newline at end of file