diff --git a/file_renamer_ai.py b/file_renamer_ai.py new file mode 100755 index 0000000..e6f44c9 --- /dev/null +++ b/file_renamer_ai.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Rename files according to filename mappings returned by OpenRouter.""" + +import argparse +import json +import os +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +# Fill in the instructions that should guide the filename changes. +SYSTEM_PROMPT = """ +Your task is to rename files. You will be given a JSON array of filenames. +You must return a JSON array of objects, each with "find" and "replace" fields, where "find" is the original filename and "replace" is the new filename. +Only include files that need to be renamed; do not include files that should remain unchanged. +Ensure that the new filenames are valid and do not contain any directory paths or invalid characters. + +The output format is "$actors -- @$studio -- $title -- $date" +- $actors are separated by commas, and should be the names of the actors in the video. +- $studio is the name of the studio that produced the video. Use "Unknown" if the studio is not known. +- Not all files have title or studio or date, leave those bits out if they are not known. +- $date must be in YYYY-MM-DD format. PRT releases are usually in YY.MM.DD format, so you will need to convert that to YYYY-MM-DD. + +Examples: +- 15.01.10_-_Aspen_Ora_-_Cock_Crazy_-_x265.mp4 -> Aspen Ora -- @Unknown -- Cock Crazy -- 2015-01-10.mp4 +- [onlyfans.com] Lilijunex - Huge Dildo Machine Slams Lilijunex [2026-06-28 -> Lilijunex -- @OnlyFans -- Huge Dildo Machine Slams Lilijunex -- 2026-06-28.mp4 +""" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" + + +def send_request(payload: dict[str, Any], api_key: str) -> dict[str, Any]: + request = Request( + OPENROUTER_URL, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urlopen(request, timeout=60) as response: + result = json.loads(response.read().decode("utf-8")) + except HTTPError as error: + detail = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"OpenRouter returned HTTP {error.code}: {detail}") from error + except URLError as error: + raise RuntimeError(f"Could not reach OpenRouter: {error.reason}") from error + return result + + +def get_renames(filenames: list[str], api_key: str, model: str) -> dict[str, str]: + payload = { + "model": model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": json.dumps(filenames)}, + ], + } + result = send_request(payload, api_key) + + try: + content = result["choices"][0]["message"]["content"] + mappings = json.loads(content) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error: + raise RuntimeError("OpenRouter response did not contain a JSON mapping array.") from error + + if not isinstance(mappings, list): + raise RuntimeError("OpenRouter response must be a JSON array.") + for mapping in mappings: + if not isinstance(mapping, dict) or not isinstance(mapping.get("find"), str) or not isinstance(mapping.get("replace"), str): + raise RuntimeError('Each mapping must have string "find" and "replace" fields.') + return {mapping["find"]: mapping["replace"] for mapping in mappings} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Ask OpenRouter for filename changes and rename the supplied files.") + parser.add_argument("paths", nargs="+", type=Path, help="one or more file paths") + parser.add_argument( + "--openrouter-api-key", + default=os.environ.get("OPENROUTER_API_KEY"), + help="OpenRouter API key (defaults to OPENROUTER_API_KEY)", + ) + parser.add_argument( + "--openrouter-model", + default=os.environ.get("OPENROUTER_MODEL", "~deepseek/deepseek-flash-latest"), + help="OpenRouter model (defaults to OPENROUTER_MODEL)", + ) + args = parser.parse_args() + + for path in args.paths: + if not path.exists(): + parser.error(f"file does not exist: {path}") + + filenames = [path.name for path in args.paths] + by_name = get_renames(filenames, args.openrouter_api_key, args.openrouter_model) + + planned = [] + for path in args.paths: + replacement = by_name.get(path.name) + if replacement is not None and replacement != path.name: + if not replacement or replacement in {".", ".."} or "/" in replacement or "\\" in replacement: + raise RuntimeError(f"Invalid replacement filename: {replacement!r}") + planned.append((path, path.with_name(replacement))) + + destinations = [destination for _, destination in planned] + if len(set(destinations)) != len(destinations): + raise RuntimeError("Multiple files would be renamed to the same path.") + for source, destination in planned: + if destination.exists() and destination != source: + raise RuntimeError(f"Destination already exists: {destination}") + + for source, destination in planned: + source.rename(destination) + print(f"{source} -> {destination}") + + +if __name__ == "__main__": + try: + main() + except RuntimeError as error: + raise SystemExit(str(error))