[
  {
    "path": ".dockerignore",
    "content": "# Ignore everything\n*\n\n# Allow files and directories\n!/main.py\n!/requirements.txt\n!/jokes.txt\n!/modules\n!/util\n!/scripts\n!/config\n!/VERSION\n!/spud\n!/start.sh\n!/web\n\n# Ignore unnecessary files inside allowed directories\n# This should go after the allowed directories\n**/*~\n**/*.log\n**/.DS_Store\n**/Thumbs.db\n**/config.yml\n**/Dockerfile\n"
  },
  {
    "path": ".github/workflows/on-branch-create.yml",
    "content": "name: Tag Docker Image for New Branch\n\non:\n  create:\n    branches:\n      - '*'  # Triggers on branch creation\n\njobs:\n  docker-tag:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Get the new branch name\n        id: get_branch\n        run: echo \"BRANCH_NAME=${GITHUB_REF#refs/heads/}\" >> $GITHUB_OUTPUT\n\n      - name: Set build number\n        run: echo \"BUILD_NUMBER=$(git rev-list --count HEAD)\" >> $GITHUB_ENV\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ secrets.GH_USERNAME }}\n          password: ${{ secrets.GH_TOKEN }}\n\n      - name: Build and push Docker image to GHCR\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          build-args: |\n            \"BRANCH=${{ steps.get_branch.outputs.BRANCH_NAME }}\"\n            \"BUILD_NUMBER=${{ env.BUILD_NUMBER }}\"\n          push: true\n          tags: |\n            ghcr.io/drazzilb08/daps:${{ steps.get_branch.outputs.BRANCH_NAME }}"
  },
  {
    "path": ".github/workflows/on-branch-delete.yml",
    "content": "name: Delete GHCR Docker Tag for Deleted Branch\n\non:\n  delete:\n    branches:\n      - '*'  # Triggers on branch deletion\n\njobs:\n  ghcr-delete-tag:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Get the deleted branch name\n        id: get_branch\n        run: echo \"BRANCH_NAME=${GITHUB_REF#refs/heads/}\" >> $GITHUB_OUTPUT\n\n      - name: Check if branch is not dev or master\n        run: |\n          if [[ \"${{ steps.get_branch.outputs.BRANCH_NAME }}\" == \"dev\" || \"${{ steps.get_branch.outputs.BRANCH_NAME }}\" == \"master\" ]]; then\n            echo \"Skipping deletion for branch: ${{ steps.get_branch.outputs.BRANCH_NAME }}\"\n            exit 0\n          fi\n\n      - name: Delete tag from GHCR\n        env:\n          GH_TOKEN: ${{ secrets.GH_TOKEN }}\n          TAG_NAME: ${{ steps.get_branch.outputs.BRANCH_NAME }}\n        run: |\n          REPO=\"drazzilb08/daps\"\n          # Get the tag digest\n          DIGEST=$(curl -s -H \"Authorization: Bearer $GH_TOKEN\" \\\n            \"https://ghcr.io/v2/${REPO}/manifests/${TAG_NAME}\" \\\n            -I | grep -i 'docker-content-digest:' | awk '{print $2}' | tr -d '\\r')\n          if [[ -z \"$DIGEST\" ]]; then\n            echo \"Tag not found on GHCR\"\n            exit 0\n          fi\n          # Delete the tag\n          curl -s -X DELETE -H \"Authorization: Bearer $GH_TOKEN\" \\\n            \"https://ghcr.io/v2/${REPO}/manifests/${DIGEST}\" \\\n            && echo \"Deleted GHCR tag: ${TAG_NAME}\" || echo \"Failed to delete GHCR tag: ${TAG_NAME}\""
  },
  {
    "path": ".github/workflows/on-commit.yml",
    "content": "name: Update Docker Image on Branch Commit\n\non:\n  push:\n    branches:\n      - '*'  # Triggers on push to any branch\n    paths:\n      - 'Dockerfile'\n      - 'main.py'\n      - 'requirements.txt'\n      - 'jokes.txt'\n      - 'modules/**'\n      - 'util/**'\n      - 'scripts/**'\n      - 'config/**'\n      - 'spud/**'\n      - 'web/**'\n      - 'VERSION'\n      - 'start.sh'\n\njobs:\n  docker-tag:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Get the current branch name\n        id: get_branch\n        run: echo \"BRANCH_NAME=${GITHUB_REF#refs/heads/}\" >> $GITHUB_OUTPUT\n\n      - name: Set build number\n        run: echo \"BUILD_NUMBER=$(git rev-list --count HEAD)\" >> $GITHUB_ENV\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_TOKEN }}\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ secrets.GH_USERNAME }}\n          password: ${{ secrets.GH_TOKEN }}\n\n      - name: Build and push Docker image (branch-specific)\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          build-args: |\n            \"BRANCH=${{ steps.get_branch.outputs.BRANCH_NAME }}\"\n            \"BUILD_NUMBER=${{ env.BUILD_NUMBER }}\"\n          push: true\n          tags: |\n            ${{ secrets.DOCKER_USERNAME }}/daps:${{ steps.get_branch.outputs.BRANCH_NAME }}\n            ghcr.io/drazzilb08/daps:${{ steps.get_branch.outputs.BRANCH_NAME }}\n\n      - name: Build and push Docker image (latest tag for master)\n        if: ${{ steps.get_branch.outputs.BRANCH_NAME == 'master' }}\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          build-args: |\n            \"BRANCH=master\"\n            \"BUILD_NUMBER=${{ env.BUILD_NUMBER }}\"\n          push: true\n          tags: |\n            ${{ secrets.DOCKER_USERNAME }}/daps:latest\n            ghcr.io/drazzilb08/daps:latest"
  },
  {
    "path": ".github/workflows/version.yml",
    "content": "name: Docker Version Release\n\non:\n  push:\n    tags:\n      - v*\n\njobs:\n\n  docker-version:\n    runs-on: ubuntu-latest\n  \n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Get the version\n        id: get_version\n        run: echo \"VERSION=${GITHUB_REF/refs\\/tags\\//}\" >> $GITHUB_OUTPUT\n\n      - name: Extract branch name\n        shell: bash\n        run: echo \"branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}\" >> $GITHUB_OUTPUT\n        id: extract_branch\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_TOKEN }}\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ secrets.GH_USERNAME }}\n          password: ${{ secrets.GH_TOKEN }}\n\n      - name: Build and push\n        id: docker_build\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          build-args: |\n            \"BRANCH=${{ steps.extract_branch.outputs.branch }}\"\n          push: true\n          tags: |\n            ${{ secrets.DOCKER_USERNAME }}/daps:${{ steps.get_version.outputs.VERSION }}\n            ghcr.io/drazzilb08/daps:${{ steps.get_version.outputs.VERSION }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n\n# C extensions\n*.so\n\n# Distribution / packaging\ndist/\n*.egg-info/\n*.egg\n\n# PyInstaller\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\n\n# Virtual environments\nvenv/\nenv/\nENV/\n.venv/\n\n# Bash\n*.sh~\n\n# IDEs / editors\n.vscode/\n.idea/\n*.swp\n*.swo\n*.swn\n*.bak\n\n# Ignore Directories\n.archives/\n.extra_scripts/\nscreenshots/\nlogs/\ntests/\n\n\n# Ignore Files\n**/config.yml\n**/.DS_Store\n**/TODO.*\n**test.**\n**side-projects**\ntest2.py\n.env\n/tests\npyproject.toml\npyproject.toml\n/config\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Single-stage build for installing Python dependencies and required packages\nFROM python:3.11-slim \n\n# Copy requirements.txt and install Python dependencies\nCOPY requirements.txt .\n\n# Install required packages and Python dependencies\nRUN set -eux; \\\n    apt-get update && \\\n    apt-get install -y --no-install-recommends \\\n        gcc wget curl unzip p7zip-full tzdata jq git build-essential && \\\n    pip3 install --no-cache-dir -r requirements.txt && \\\n    curl https://rclone.org/install.sh | bash && \\\n    git clone https://codeberg.org/jbruchon/libjodycode.git /tmp/libjodycode && \\\n    make -C /tmp/libjodycode && make -C /tmp/libjodycode install && \\\n    ldconfig && \\\n    git clone https://codeberg.org/jbruchon/jdupes.git /tmp/jdupes && \\\n    make -C /tmp/jdupes && make -C /tmp/jdupes install && \\\n    ln -s /usr/local/bin/jdupes /usr/bin/jdupes && \\\n    rm -rf /tmp/libjodycode /tmp/jdupes\n\n# Clean up\nRUN set -eux; \\\n    apt-get remove -y --purge gcc build-essential && \\\n    apt-get autoremove -y && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*\n\n# Metadata and labels\nLABEL maintainer=\"Drazzilb\" \\\n      description=\"daps\" \\\n      org.opencontainers.image.source=\"https://github.com/Drazzilb08/daps\" \\\n      org.opencontainers.image.authors=\"Drazzilb\" \\\n      org.opencontainers.image.title=\"daps\"\n\n# Branch and build number arguments\nARG BRANCH=\"master\"\nARG BUILD_NUMBER=\"\"\n# Pass the build-time BRANCH arg into a runtime environment variable\nENV BRANCH=${BRANCH}\nENV BUILD_NUMBER=${BUILD_NUMBER}\nARG CONFIG_DIR=/config\n\n# Set script environment variables\nENV CONFIG_DIR=/config\nENV APPDATA_PATH=/appdata\nENV LOG_DIR=/config/logs\nENV TZ=America/Los_Angeles\nENV PORT=8000\nENV HOST=0.0.0.0\nENV DOCKER_ENV=true\n\n# Expose the application port\nEXPOSE ${PORT}\n\nVOLUME /config\n\nWORKDIR /app\n\nCOPY . .\n\n# Create a new user called dockeruser with the specified PUID and PGID\nRUN groupadd -g 99 dockeruser; \\\n    useradd -u 100 -g 99 dockeruser; \\\n    chown -R dockeruser:dockeruser /app; \n\n# Entrypoint script\nCMD [\"bash\", \"start.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "piMIT License\n\nCopyright (c) 2023 Drazzilb\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "# Create venv if it doesn't exist\n.PHONY: venv\nvenv:\n\ttest -d venv || python3 -m venv venv\n\n# Install requirements\n.PHONY: install\ninstall: venv\n\t. venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt\n\n# Freeze current venv into requirements.txt\n.PHONY: lock\nlock:\n\t. venv/bin/activate && pip freeze > requirements.txt\n\n# Lint using flake8 (must be installed in requirements.txt)\n.PHONY: lint\nlint:\n\t. venv/bin/activate && flake8"
  },
  {
    "path": "README.md",
    "content": "\n<div align=\"center\">\n\n# DAPS\n\nAutomate, optimize, and take control of your media libraries.\n\n[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)\n[![GitHub Issues](https://img.shields.io/github/issues/Drazzilb08/daps.svg)](https://github.com/Drazzilb08/daps/issues)\n[![GitHub PRs](https://img.shields.io/github/issues-pr/Drazzilb08/daps.svg)](https://github.com/Drazzilb08/daps/pulls)\n[![GitHub Stars](https://img.shields.io/github/stars/Drazzilb08/daps.svg)](https://github.com/Drazzilb08/daps/stargazers)\n[![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/)\n[![Bash](https://img.shields.io/badge/bash-5.0%2B-green.svg)](https://www.gnu.org/software/bash/)\n\n</div>\n\n---\n## 🚀 Quickstart\n\nSee [Wiki](https://github.com/Drazzilb08/daps/wiki) for full install docs.\n\n**Docker (recommended):**\n```bash\ndocker run -d -v /path/to/config:/config -v /path/to/posters:/posters -p 8000:8000 drazzilb08/daps\n```\n\n**Local:**\n```bash\ngit clone https://github.com/Drazzilb08/daps.git\ncd daps\npython3 -m venv .venv && source .venv/bin/activate\npip install -r requirements.txt\npython3 main.py poster_renamerr\n```\n\nand use the built-in Web UI: [http://localhost:8000](http://localhost:8000)\n\n---\n\n## 🙋‍♂️ Contributing & Support\n\nPull requests are welcome for fixes, docs, or new module ideas.  \nIf you spot a bug or want a feature, open an [Issue](https://github.com/Drazzilb08/daps/issues) or jump into a PR.\n\n---\n\n<div align=\"center\">\n  \nMade with ❤️ by Drazzilb  \nIf this saved you time, star the repo, tell a friend, or buy yourself a cookie.\n\n</div>\n"
  },
  {
    "path": "VERSION",
    "content": "2.0.3"
  },
  {
    "path": "compose/docker-compose.yml",
    "content": "version: \"3.9\"\n\nservices:\n  daps:\n    container_name: daps\n    image: ghcr.io/drazzilb08/daps:latest\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - /path/to/config:/config\n      - /path/to/kometa/assets/:/kometa\n      - /path/to/posters:/posters\n      - /path/to/media:/media\n    environment:\n      - PUID=${PUID}\n      - PGID=${PGID}\n      - TZ=${TIMEZONE}\n    restart: unless-stopped\n"
  },
  {
    "path": "jokes.txt",
    "content": " I'm reading a book about anti-gravity. It's impossible to put down.\n I burned 2000 calories today I left my food in the oven for too long.\nI startled my next-door neighbor with my new electric power tool. I had to calm him down by saying “Don’t worry, this is just a drill!”\nI broke my arm in two places. My doctor told me to stop going to those places.\nI quit my job at the coffee shop the other day. It was just the same old grind over and over.\nI never buy anything that has Velcro with it... it’s a total rip-off.\nI used to work at a soft drink can crushing company... it was soda pressing.\nI wondered why the frisbee kept on getting bigger. Then it hit me.\nI was going to tell you a fighting joke... but I forgot the punch line.\nWhat is the most groundbreaking invention of all time? The shovel. \nI’m starting my new job at a restaurant next week. I can’t wait.\nI visited a weight loss website... they told me I have to have cookies disabled.\nDid you hear about the famous Italian chef that recently died? He pasta way.\nBroken guitar for sale no strings attached.\nI could never be a plumber it’s too hard watching your life’s work go down the drain.\nI cut my finger slicing cheese the other day... but I think I may have grater problems than that.\nWhat time did you go to the dentist yesterday? Tooth-hurty.\nWhat kind of music do astronauts listen to? Neptunes.\nRest in peace, boiled water. You will be mist.\nWhat is the only concert in the world that costs 45 cents? 50 Cent, featuring Nickelback.\nIt’s not a dad bod it’s a father figure.\nMy wife recently went on a tropical food diet and now our house is full of this stuff. It’s enough to make a mango crazy.\nWhat do you call Santa’s little helpers? Subordinate clauses.\nWant to hear a construction joke? Sorry, I’m still working on it.\nWhat’s the difference between a hippo and a zippo? One is extremely big and heavy, and the other is a little lighter.\nI burnt my Hawaiian pizza today in the oven, I should have cooked it on aloha temperature.\nAnyone can be buried when they die but if you want to be cremated then you have to urn it.\nWhere did Captain Hook get his hook? From the second-hand store.\nI am such a good singer that people always ask me to sing solo solo that they can’t hear me. \nI am such a good singer that people ask me to sing tenor tenor twelve miles away.\nOccasionally to relax I just like to tuck my knees into my chest and lean forward. That’s just how I roll.\nWhat did the glass of wine say to the glass of beer? Nothing. They barley knew each other.\nI’ve never trusted stairs. They are always up to something.\nWhy did Shakespeare’s wife leave him? She got sick of all the drama.\nI just bought a dictionary but all of the pages are blank. I have no words to describe how mad I am.\nIf you want to get a job at the moisturizer factory... you’re going to have to apply daily.\nI don’t know what’s going to happen next year. It’s probably because I don’t have 2020 vision.\nWant to hear a joke about going to the bathroom? Urine for a treat.\nI couldn’t figure out how to use the seat belt. Then it just clicked.\nI got an email the other day teaching me how to read maps backwards turns out it was just spam.\nI'm reading a book about anti-gravity. It's impossible to put down!\nYou're American when you go into the bathroom, and you're American when you come out, but do you know what you are while you're in there? European.\nDid you know the first French fries weren't actually cooked in France? They were cooked in Greece.\nWant to hear a joke about a piece of paper? Never mind... it's tearable.\nI just watched a documentary about beavers. It was the best dam show I ever saw!\nIf you see a robbery at an Apple Store what re you? An iWitness?\nSpring is here! I got so excited I wet my plants!\nWhy did the Clydesdale give the pony a glass of water? Because he was a little horse!\nCASHIER: \"Would you like the milk in a bag, sir?\" DAD: \"No, just leave it in the carton!’”\nDid you hear about the guy who invented Lifesavers? They say he made a mint.\nI bought some shoes from a drug dealer. I don't know what he laced them with, but I was tripping all day!\nWhy do chicken coops only have two doors? Because if they had four, they would be chicken sedans!\nHow do you make a Kleenex dance? Put a little boogie in it!\nWhy did the invisible man turn down the job offer? He couldn't see himself doing it.\nI used to have a job at a calendar factory but I got the sack because I took a couple of days off.\nA woman is on trial for beating her husband to death with his guitar collection. Judge says, \"First offender?\" She says, \"No, first a Gibson! Then a Fender!”\nI had a dream that I was a muffler last night. I woke up exhausted!\nDid you hear about the circus fire? It was in tents!\nDon't trust atoms. They make up everything!\nHow many tickles does it take to make an octopus laugh? Ten-tickles.\nI’m only familiar with 25 letters in the English language. I don’t know why.\nWhy did the cow in the pasture get promoted at work? Because he is OUT-STANDING in his field!\nWhat do prisoners use to call each other? Cell phones.\nWhy couldn't the bike standup by itself? It was two tired.\nWho was the fattest knight at King Arthur’s round table? Sir Cumference. \nDid you see they made round bails of hay illegal in Wisconsin? It’s because the cows weren’t getting a square meal.\nYou know what the loudest pet you can get is? A trumpet.\nWhat do you get when you cross a snowman with a vampire? Frostbite.\nWhat do you call a deer with no eyes? No idea!\nCan February March? No, but April May!\nWhat do you call a lonely cheese? Provolone.\nWhy can't you hear a pterodactyl go to the bathroom? Because the pee is silent.\nWhat did the buffalo say to his son when he dropped him off at school? Bison.\nWhat do you call someone with no body and no nose? Nobody knows.\nYou heard of that new band 1023MB? They're good but they haven't got a gig yet.\nWhy did the crab never share? Because he's shellfish.\nHow do you get a squirrel to like you? Act like a nut.\nWhy don't eggs tell jokes? They'd crack each other up.\nWhy can't a nose be 12 inches long? Because then it would be a foot.\nDid you hear the rumor about butter? Well, I'm not going to spread it!\nI made a pencil with two erasers. It was pointless.\nI used to hate facial hair... but then it grew on me.\nI decided to sell my vacuum cleaner— it was just gathering dust!\nI had a neck brace fitted years ago and I've never looked back since.\nYou know, people say they pick their nose, but I feel like I was just born with mine.\nWhat do you call an elephant that doesn't matter? An irrelephant.\nWhat do you get from a pampered cow? Spoiled milk.\nIt's inappropriate to make a 'dad joke' if you're not a dad. It's a faux pa.\nHow do lawyers say goodbye? Sue ya later!\nWanna hear a joke about paper? Never mind—it's tearable.\nWhat's the best way to watch a fly fishing tournament? Live stream.\nI could tell a joke about pizza, but it's a little cheesy.\nWhen does a joke become a dad joke? When it becomes apparent.\nWhat’s an astronaut’s favorite part of a computer? The space bar.\nWhat did the shy pebble wish for? That she was a little boulder.\nWhy didn’t the skeleton cross the road? Because he had no guts.\nWhat did one nut say as he chased another nut?  I'm a cashew!\nChances are if you' ve seen one shopping center... you've seen a mall.\nI knew I shouldn't steal a mixer from work... but it was a whisk I was willing to take.\nHow come the stadium got hot after the game? Because all of the fans left.\nWhy was it called the dark ages? Because of all the knights. \nWhy did the tomato blush? Because it saw the salad dressing.\nDid you hear the joke about the wandering nun? She was a roman catholic.\nWhat creature is smarter than a talking parrot? A spelling bee.\nI'll tell you what often gets over looked... garden fences.\nWhy did the kid cross the playground? To get to the other slide.\nWhy do birds fly south for the winter? Because it's too far to walk.\nWhat is a centipedes's favorite Beatle song?  I want to hold your hand, hand, hand, hand...\nMy first time using an elevator was an uplifting experience. The second time let me down.\nTo be Frank... I'd have to change my name.\nSlept like a log last night … woke up in the fireplace.\nHow many South Americans does it take to change a lightbulb? A Brazilian\nWhat is the difference between ignorance and apathy? I don't know and I don't care.\nI went to a Foo Fighters Concert once... It was Everlong...\nSome people eat light bulbs. They say it's a nice light snack.\nWhat do you get hanging from Apple trees?  Sore arms.\nWhat did Romans use to cut pizza before the rolling cutter was invented? Lil Caesars\nMy pet mouse 'Elvis' died last night. He was caught in a trap..\nNever take advice from electrons. They are always negative.\nWhy are oranges the smartest fruit? Because they are made to concentrate. \nWhat did the beaver say to the tree? It's been nice gnawing you.\nHow do you fix a damaged jack-o-lantern? You use a pumpkin patch.\nWhat did the late tomato say to the early tomato? I’ll ketch up\nI have kleptomania... when it gets bad, I take something for it.\nI used to be addicted to soap... but I'm clean now.\nWhen is a door not a door? When it's ajar.\nI made a belt out of watches once... It was a waist of time.\nThis furniture store keeps emailing me, all I wanted was one night stand!\nHow do you find Will Smith in the snow?  Look for fresh prints.\nIf at first you don't succeed sky diving is not for you!\nWhat kind of music do mummy's like? Rap\nA book just fell on my head. I only have my shelf to blame.\nWhat did the dog say to the two trees? Bark bark.\nIf a child refuses to sleep during nap time... are they guilty of resisting a rest?\nWhy should you never trust a pig with a secret? Because it's bound to squeal.\nWhy are mummys scared of vacation? They're afraid to unwind.\nWhiteboards ... are remarkable.\nWhat kind of dinosaur loves to sleep? A stega-snore-us.\nWhy don't scientists trust atoms? Because they make up everything.\nWhat do you call a dinosaur that is sleeping? A dino-snore.\nWhat do you call a dinosaur that never gives up? Try and try and try and try-ceratops.\nWhat kind of tree fits in your hand? A palm tree!\nI used to be addicted to the hokey pokey but I turned myself around.\nWhat do you call a fake noodle? An impasta.\nWhat do you call a cow with two legs? Lean beef.\nHow many tickles does it take to tickle an octopus? Ten-tickles!\nWhat musical instrument is found in the bathroom? A tuba toothpaste.\nMy boss told me to attach two pieces of wood together... I totally nailed it!\nWhat was the pumpkin’s favorite sport? Squash.\nWhat do you call corn that joins the army? Kernel.\nI've been trying to come up with a dad joke about momentum but I just can't seem to get it going.\nWhy don't sharks eat clowns?  Because they taste funny.\nWhy didn’t the melons get married? Because they cantaloupe.\nWhat’s a computer’s favorite snack? Microchips!\nWhy was the robot so tired after his road trip? He had a hard drive.\nWhy did the computer have no money left? Someone cleaned out its cache!\nI'm not anti-social. I'm just not user friendly.\nWhy did the computer get cold? Because it forgot to close windows.\nWhat is an astronaut's favorite key on a keyboard? The space bar!\nWhat's the difference between a computer salesman and a used-car salesman? The used-car salesman KNOWS when he's lying.\nIf at first you don't succeed... call it version 1.0\nWhy did Microsoft PowerPoint cross the road? To get to the other slide!\nWhat did the computer do at lunchtime? Had a byte!\nWhy did the computer keep sneezing? It had a virus!\nWhat did one toilet say to the other? You look a bit flushed.\nWhy did the picture go to jail? Because it was framed.\nWhat did one wall say to the other wall? I'll meet you at the corner.\nWhat do you call a boy named Lee that no one talks to? Lonely\nWhy do bicycles fall over? Because they are two-tired!\nWhy was the broom late? It over swept!\nWhat part of the car is the laziest? The wheels, because they are always tired!\nWhat's the difference between a TV and a newspaper? Ever tried swatting a fly with a TV?\nWhat did one elevator say to the other elevator? I think I'm coming down with something!\nWhy was the belt arrested? Because it held up some pants!\nWhat makes the calendar seem so popular? Because it has a lot of dates!\nWhy do you go to bed every night? Because the bed won't come to you!\nWhat has four wheels and flies? A garbage truck!\nWhy did the robber take a bath before he stole from the bank? He wanted to make a clean get away!\nJust watched a documentary about beavers. It was the best damn program I’ve ever seen.\nSlept like a log last night woke up in the fireplace.\nWhat’s the difference between an African elephant and an Indian elephant? About 5000 miles\nWhy did the coffee file a police report? It got mugged.\nWhat did the grape do when he got stepped on? He let out a little wine.\nHow many apples grow on a tree? All of them.\nWhat name do you give a person with a rubber toe? Roberto\nWhy do scuba divers fall backwards into the water? Because if they fell forwards they’d still be in the boat.\nHow does a penguin build it’s house? Igloos it together.\nWhat do you call a man with a rubber toe? Roberto\nDid you hear about the restaurant on the moon? Great food, no atmosphere.\nWhy was the belt sent to jail? For holding up a pair of pants!\nDid you hear about the scientist who was lab partners with a pot of boiling water? He had a very esteemed colleague.\nWhat happens when a frogs car dies? He needs a jump. If that doesn't work he has to get it toad.\nWhat did the flowers do when the bride walked down the aisle? They rose.\nWhy did the man fall down the well? Because he couldn’t see that well.\nMy boss told me to have a good day... ...so I went home.\nHow can you tell it’s a dogwood tree? By the bark.\nDid you hear about the kidnapping at school? It’s fine, he woke up.\nWhy is Peter Pan always flying? Because he Neverlands.\nWhich state has the most streets? Rhode Island.\nWhat do you call 26 letters that went for a swim? Alphawetical.\nWhy was the color green notoriously single? It was always so jaded.\nWhy did the coach go to the bank? To get his quarterback.\nHow do celebrities stay cool? They have many fans.\nWhat's the most depressing day of the week? sadder day.\nDogs can’t operate MRI machines But catscan.\nI was going to tell a time-traveling joke but you guys didn’t like it.\nStop looking for the perfect match instead look for a lighter.\nI told my doctor I heard buzzing but he said it’s just a bug going around.\nWhat kind of car does a sheep like to drive? A lamborghini.\nWhat did the accountant say while auditing a document? This is taxing.\nWhat did the two pieces of bread say on their wedding day? It was loaf at first sight.\nWhy do melons have weddings? Because they cantaloupe.\nWhat did the drummer call his twin daughters? Anna One, Anna Two!\nWhat do you call a toothless bear? A gummy bear!\nTwo goldfish are in a tank. One says to the other, “Do you know how to drive this thing?”\nWhat’s Forrest Gump’s password? 1forrest1\nWhat is a child guilty of if they refuse to nap? Resisting a rest.\nI know a lot of jokes about retired people but none of them work.\nWhy are spiders so smart? They can find everything on the web.\nWhat has one head, one foot, and four legs? A bed.\nWhat does a house wear? Address.\nWhat’s red and smells like blue paint? Red paint.\nMy son asked me to put his shoes on but I don’t think they’ll fit me.\nI’ve been bored recently, so I decided to take up fencing. The neighbors keep demanding that I put it back.\nWhat do you call an unpredictable camera? A loose Canon.\nWhich U.S. state is known for its especially small soft drinks? Minnesota.\nWhat do sprinters eat before a race? Nothing—they fast.\nI’m so good at sleeping... I can do it with my eyes closed.\nPeople are usually shocked that I have a Police record. But I love their greatest hits!\nI told my girlfriend she drew on her eyebrows too high. She seemed surprised.\nWhat do you call a fibbing cat? A lion.\nWhy shouldn’t you write with a broken pencil? Because it’s pointless.\nI like telling Dad jokes… sometimes he laughs.\nHow do you weigh a millennial? In Instagrams.\nThe wedding was so beautiful even the cake was in tiers.\nWhat’s the most patriotic sport? Flag football.\nNever trust atoms; they make up everything.\nTwo fish are in a tank. One says, How do you drive this thing?\nMy wife told me to stop impersonating a flamingo. I had to put my foot down.\nI went to buy some camo pants but couldn’t find any.\nI failed math so many times at school, I can’t even count.\nI used to have a handle on life, but then it broke.\nI was wondering why the frisbee kept getting bigger and bigger, but then it hit me.\nI heard there were a bunch of break-ins over at the car park. That is wrong on so many levels.\nI want to die peacefully in my sleep, like my grandfather… Not screaming and yelling like the passengers in his car.\nWhen life gives you melons, you might be dyslexic\nDon’t you hate it when someone answers their own questions? I do.\nIt takes a lot of balls to golf the way I do.\nI told him to be himself; that was pretty mean, I guess. \nI know they say that money talks, but all mine says is ‘Goodbye.’ \nMy father has schizophrenia, but he’s good people. \nThe problem with kleptomaniacs is that they always take things literally. \nI can’t believe I got fired from the calendar factory. All I did was take a day off.\nMost people are shocked when they find out how bad I am as an electrician. \nNever trust atoms; they make up everything. \nMy wife just found out I replaced our bed with a trampoline. She hit the ceiling! \nI was addicted to the hokey pokey, but then I turned myself around. \nI used to think I was indecisive. But now I’m not so sure.\nRussian dolls are so full of themselves. \nThe easiest time to add insult to injury is when you’re signing someone’s cast. \nLight travels faster than sound, which is the reason that some people appear bright before you hear them speak. \nMy therapist says I have a preoccupation for revenge. We’ll see about that. \nA termite walks into the bar and asks, ‘Is the bar tender here?’ \nA told my girlfriend she drew her eyebrows too high. She seemed surprised. \nPeople who use selfie sticks really need to have a good, long look at themselves. \nTwo fish are in a tank. One says, ‘How do you drive this thing?’ \nI always take life with a grain of salt. And a slice of lemon. And a shot of tequila.\nJust burned 2,000 calories. That’s the last time I leave brownies in the oven while I nap.\nAlways borrow money from a pessimist. They’ll never expect it back. \nBuild a man a fire and he’ll be warm for a day. Set a man on fire and he’ll be warm for the rest of his life.\nI don’t suffer from insanity—I enjoy every minute of it. \nThe last thing I want to do is hurt you; but it’s still on the list. \nThe problem isn’t that obesity runs in your family. It’s that no one runs in your family. \nToday a man knocked on my door and asked for a small donation toward the local swimming pool. I gave him a glass of water.\nI’m reading a book about anti-gravity. It’s impossible to put down. \n‘Doctor, there’s a patient on line one that says he’s invisible.’ ‘Well, tell him I can’t see him right now.’ \nAtheism is a non-prophet organization. \nA recent study has found that women who carry a little extra weight live longer than the men who mention it.\nThe future, the present, and the past walk into a bar. Things got a little tense. \nBefore you criticize someone, walk a mile in their shoes. That way, when you do criticize them, you’re a mile away and you have their shoes. \nLast night my girlfriend was complaining that I never listen to her… or something like that.\nMaybe if we start telling people their brain is an app, they’ll want to use it. \nIf a parsley farmer gets sued, can they garnish his wages? \nI got a new pair of gloves today, but they’re both ‘lefts,’ which on the one hand is great, but on the other, it’s just not right. \nI didn’t think orthopedic shoes would help, but I stand corrected.\nI was riding a donkey the other day when someone threw a rock at me and I fell off. I guess I was stoned off my ass. \nPeople who take care of chickens are literally chicken tenders. \nIt was an emotional wedding. Even the cake was in tiers.\nI just got kicked out of a secret cooking society. I spilled the beans. \nWhat’s a frog’s favorite type of shoes? Open toad sandals. \nBlunt pencils are really pointless. \n6:30 is the best time on a clock, hands down. \nTwo wifi engineers got married. The reception was fantastic.\nJust got fired from my job as a set designer. I left without making a scene. \nWhat’s the difference between ignorance and apathy? I don’t know and I don’t care. \nI buy all my guns from a guy called T-Rex. He's a small arms dealer.\nOne of the cows didn’t produce milk today. It was an udder failure. \nAdam & Eve were the first ones to ignore the Apple terms and conditions.\nRefusing to go to the gym is a form of resistance training.\nIf attacked by a mob of clowns, go for the juggler. \nThe man who invented Velcro has died. RIP. \nDespite the high cost of living, it remains popular.\nA dung beetle walks into a bar and asks, ‘Is this stool taken?’ \nI can tell when people are being judgmental just by looking at them. \nThe rotation of Earth really makes my day. \nWell, to be Frank with you, I’d have to change my name. \nMy friend was explaining electricity to me, but I was like, ‘Watt?’\nWhat if there were no hypothetical questions? \nAre people born with photographic memories, or does it take time to develop?\nThe world champion tongue twister got arrested. I hear they’re going to give him a tough sentence. \nPollen is what happens when flowers can’t keep it in their plants. \nA book fell on my head the other day. I only have my shelf to blame though. \nCommunist jokes aren’t funny unless everyone gets them. \nGeology rocks, but geography’s where it’s at. \nI buy all my guns from a guy called T-Rex. He’s a small arms dealer.\nMy friend’s bakery burned down last night. Now his business is toast. \nFour fonts walk into a bar. The bartender says, ‘Hey! We don’t want your type in here!’ \nIf you don’t pay your exorcist, do you get repossessed? \nWhen the cannibal showed up late to the buffet, they gave him the cold shoulder.\nA Mexican magician tells the audience he will disappear on the count of three. He says, ‘Uno, dos…” and poof! He disappeared without a tres. \nFighting for peace is like screwing for virginity.\nA ghost walked into a bar and ordered a shot of vodka. The bartender said, ‘Sorry, we don’t serve spirits here.’ \nThe man who invented knock-knock jokes should get a no bell prize. \nI bought the world’s worst thesaurus yesterday. Not only is it terrible, it’s also terrible. \nA blind man walked into a bar… and a table… and a chair…\nA Freudian slip is when you mean one thing and mean your mother. \nI went to a seafood disco last week, but ended up pulling a mussel. \nThe first time I got a universal remote control, I thought to myself, ‘This changes everything.’ \nHow do you make holy water? You boil the hell out of it.\nI saw a sign the other day that said, ‘Watch for children,’ and I thought, ‘That sounds like a fair trade.’ \nWhiteboards are remarkable. \nI threw a boomerang a couple years ago; I know live in constant fear. \nI put my grandma on speed dial the other day. I call it insta-gram. \nI have a few jokes about unemployed people, but none of them work.\n‘I have a split personality,’ said Tom, being Frank. \nMy teachers told me I'd never amount to much because I procrastinate so much. I told them, \"Just you wait!\" \nWill glass coffins be a success? Remains to be seen. \nDid you hear about the guy whose whole left side got amputated? He’s all right now. \nThe man who survived both mustard gas and pepper spray is a seasoned veteran now. \nHave you heard about the new restaurant called ‘Karma?’ There’s no menu—you get what you deserve.\nWhat did one pirate say to the other when he beat him at chess?<>Checkmatey.\nI burned 2000 calories today<>I left my food in the oven for too long.\nI startled my next-door neighbor with my new electric power tool. <>I had to calm him down by saying “Don’t worry, this is just a drill!”\nI broke my arm in two places. <>My doctor told me to stop going to those places.\nI quit my job at the coffee shop the other day. <>It was just the same old grind over and over.\nI never buy anything that has Velcro with it...<>it’s a total rip-off.\nI used to work at a soft drink can crushing company...<>it was soda pressing.\nI wondered why the frisbee kept on getting bigger. <>Then it hit me.\nI was going to tell you a fighting joke...<>but I forgot the punch line.\nWhat is the most groundbreaking invention of all time? <>The shovel. \nI’m starting my new job at a restaurant next week. <>I can’t wait.\nI visited a weight loss website...<>they told me I have to have cookies disabled.\nDid you hear about the famous Italian chef that recently died? <>He pasta way.\nBroken guitar for sale<>no strings attached.\nI could never be a plumber<>it’s too hard watching your life’s work go down the drain.\nI cut my finger slicing cheese the other day...<>but I think I may have grater problems than that.\nWhat time did you go to the dentist yesterday?<>Tooth-hurty.\nWhat kind of music do astronauts listen to?<>Neptunes.\nRest in peace, boiled water. <>You will be mist.\nWhat is the only concert in the world that costs 45 cents? <>50 Cent, featuring Nickelback.\nIt’s not a dad bod<> it’s a father figure.\nMy wife recently went on a tropical food diet and now our house is full of this stuff. <>It’s enough to make a mango crazy.\nWhat do you call Santa’s little helpers? <>Subordinate clauses.\nWant to hear a construction joke? <>Sorry, I’m still working on it.\nWhat’s the difference between a hippo and a zippo? <>One is extremely big and heavy, and the other is a little lighter.\nI burnt my Hawaiian pizza today in the oven, <>I should have cooked it on aloha temperature.\nAnyone can be buried when they die<>but if you want to be cremated then you have to urn it.\nWhere did Captain Hook get his hook? <>From the second-hand store.\nI am such a good singer that people always ask me to sing solo<>solo that they can’t hear me. \nI am such a good singer that people ask me to sing tenor<>tenor twelve miles away.\nOccasionally to relax I just like to tuck my knees into my chest and lean forward.<> That’s just how I roll.\nWhat did the glass of wine say to the glass of beer? Nothing. <>They barley knew each other.\nI’ve never trusted stairs. <>They are always up to something.\nWhy did Shakespeare’s wife leave him? <>She got sick of all the drama.\nI just bought a dictionary but all of the pages are blank. <>I have no words to describe how mad I am.\nIf you want to get a job at the moisturizer factory... <>you’re going to have to apply daily.\nI don’t know what’s going to happen next year. <>It’s probably because I don’t have 2020 vision.\nWant to hear a joke about going to the bathroom? <>Urine for a treat.\nI couldn’t figure out how to use the seat belt. <>Then it just clicked.\nI got an email the other day teaching me how to read maps backwards<>turns out it was just spam.\nI'm reading a book about anti-gravity.<> It's impossible to put down!\nYou're American when you go into the bathroom, and you're American when you come out, but do you know what you are while you're in there?<> European.\nDid you know the first French fries weren't actually cooked in France?<> They were cooked in Greece.\nWant to hear a joke about a piece of paper? Never mind... <>it's tearable.\nI just watched a documentary about beavers. <>It was the best dam show I ever saw!\nIf you see a robbery at an Apple Store what re you?<> An iWitness?\nSpring is here! <>I got so excited I wet my plants!\nWhat’s Forrest Gump’s password?<> 1forrest1\nWhy did the Clydesdale give the pony a glass of water? <>Because he was a little horse!\nCASHIER: \"Would you like the milk in a bag, sir?\" <>DAD: \"No, just leave it in the carton!’”\nDid you hear about the guy who invented Lifesavers? <>They say he made a mint.\nI bought some shoes from a drug dealer.<> I don't know what he laced them with, but I was tripping all day!\nWhy do chicken coops only have two doors?<> Because if they had four, they would be chicken sedans!\nHow do you make a Kleenex dance? <>Put a little boogie in it!\nA termite walks into a bar and asks<>\"Is the bar tender here?\"\nWhy did the invisible man turn down the job offer?<> He couldn't see himself doing it.\nI used to have a job at a calendar factory <>but I got the sack because I took a couple of days off.\nA woman is on trial for beating her husband to death with his guitar collection. Judge says, \"First offender?\" <>She says, \"No, first a Gibson! Then a Fender!”\nHow do you make holy water?<> You boil the hell out of it.\nI had a dream that I was a muffler last night.<> I woke up exhausted!\nDid you hear about the circus fire?<> It was in tents!\nDon't trust atoms.<> They make up everything!\nHow many tickles does it take to make an octopus laugh? <>Ten-tickles.\nI’m only familiar with 25 letters in the English language.<> I don’t know why.\nWhy did the cow in the pasture get promoted at work?<> Because he is OUT-STANDING in his field!\nWhat do prisoners use to call each other?<> Cell phones.\nWhy couldn't the bike standup by itself? <>It was two tired.\nWho was the fattest knight at King Arthur’s round table?<> Sir Cumference. \nDid you see they made round bails of hay illegal in Wisconsin? <>It’s because the cows weren’t getting a square meal.\nYou know what the loudest pet you can get is?<> A trumpet.\nWhat do you get when you cross a snowman with a vampire?<> Frostbite.\nWhat do you call a deer with no eyes?<> No idea!\nCan February March? <>No, but April May!\nWhat do you call a lonely cheese? <>Provolone.\nWhy can't you hear a pterodactyl go to the bathroom?<> Because the pee is silent.\nWhat did the buffalo say to his son when he dropped him off at school?<> Bison.\nWhat do you call someone with no body and no nose? <>Nobody knows.\nYou heard of that new band 1023MB? <>They're good but they haven't got a gig yet.\nWhy did the crab never share?<> Because he's shellfish.\nHow do you get a squirrel to like you? <>Act like a nut.\nWhy don't eggs tell jokes? <>They'd crack each other up.\nWhy can't a nose be 12 inches long? <>Because then it would be a foot.\nDid you hear the rumor about butter? <>Well, I'm not going to spread it!\nI made a pencil with two erasers. <>It was pointless.\nI used to hate facial hair...<>but then it grew on me.\nI decided to sell my vacuum cleaner—<>it was just gathering dust!\nI had a neck brace fitted years ago<> and I've never looked back since.\nYou know, people say they pick their nose,<> but I feel like I was just born with mine.\nWhat do you call an elephant that doesn't matter?<> An irrelephant.\nWhat do you get from a pampered cow? <>Spoiled milk.\nIt's inappropriate to make a 'dad joke' if you're not a dad.<> It's a faux pa.\nHow do lawyers say goodbye? <>Sue ya later!\nWanna hear a joke about paper? <>Never mind—it's tearable.\nWhat's the best way to watch a fly fishing tournament? <>Live stream.\nI could tell a joke about pizza,<> but it's a little cheesy.\nWhen does a joke become a dad joke?<> When it becomes apparent.\nWhat’s an astronaut’s favorite part of a computer? <>The space bar.\nWhat did the shy pebble wish for?<>That she was a little boulder.\nI'm tired of following my dreams. <>I'm just going to ask them where they are going and meet up with them later.\nDid you hear about the guy whose whole left side was cut off? <>He's all right now.\nWhy didn’t the skeleton cross the road? <>Because he had no guts.\nWhat did one nut say as he chased another nut? <> I'm a cashew!\nChances are if you' ve seen one shopping center...<> you've seen a mall.\nI knew I shouldn't steal a mixer from work...<>but it was a whisk I was willing to take.\nHow come the stadium got hot after the game? <>Because all of the fans left.\nWhy was it called the dark ages? <>Because of all the knights. \nWhy did the tomato blush? <>Because it saw the salad dressing.\nDid you hear the joke about the wandering nun? <>She was a roman catholic.\nWhat creature is smarter than a talking parrot? <>A spelling bee.\nI'll tell you what often gets over looked...<> garden fences.\nWhy did the kid cross the playground? <>To get to the other slide.\nWhy do birds fly south for the winter?<> Because it's too far to walk.\nWhat is a centipedes's favorite Beatle song? <> I want to hold your hand, hand, hand, hand...\nMy first time using an elevator was an uplifting experience. <>The second time let me down.\nTo be Frank...<> I'd have to change my name.\nSlept like a log last night … <>woke up in the fireplace.\nWhy does a Moon-rock taste better than an Earth-rock? <>Because it's a little meteor.\nHow many South Americans does it take to change a lightbulb?<> A Brazilian\nI don't trust stairs.<> They're always up to something.\nA police officer caught two kids playing with a firework and a car battery.<> He charged one and let the other one off.\nWhat is the difference between ignorance and apathy?<>I don't know and I don't care.\nI went to a Foo Fighters Concert once... <>It was Everlong...\nSome people eat light bulbs. <>They say it's a nice light snack.\nWhat do you get hanging from Apple trees? <> Sore arms.\nLast night me and my girlfriend watched three DVDs back to back.<> Luckily I was the one facing the TV.\nI got a reversible jacket for Christmas,<> I can't wait to see how it turns out.\nWhat did Romans use to cut pizza before the rolling cutter was invented? <>Lil Caesars\nMy pet mouse 'Elvis' died last night. <>He was caught in a trap..\nNever take advice from electrons. <>They are always negative.\nWhy are oranges the smartest fruit? <>Because they are made to concentrate. \nWhat did the beaver say to the tree? <>It's been nice gnawing you.\nHow do you fix a damaged jack-o-lantern?<> You use a pumpkin patch.\nWhat did the late tomato say to the early tomato? <>I’ll ketch up\nI have kleptomania...<>when it gets bad, I take something for it.\nI used to be addicted to soap...<> but I'm clean now.\nWhen is a door not a door?<> When it's ajar.\nI made a belt out of watches once...<> It was a waist of time.\nThis furniture store keeps emailing me,<> all I wanted was one night stand!\nHow do you find Will Smith in the snow?<>  Look for fresh prints.\nI just read a book about Stockholm syndrome.<> It was pretty bad at first, but by the end I liked it.\nWhy do trees seem suspicious on sunny days? <>Dunno, they're just a bit shady.\nIf at first you don't succeed<> sky diving is not for you!\nWhat kind of music do mummy's like?<>Rap\nA book just fell on my head. <>I only have my shelf to blame.\nWhat did the dog say to the two trees? <>Bark bark.\nIf a child refuses to sleep during nap time...<> are they guilty of resisting a rest?\nHave you ever heard of a music group called Cellophane?<> They mostly wrap.\nWhat did the mountain climber name his son?<>Cliff.\nWhy should you never trust a pig with a secret?<> Because it's bound to squeal.\nWhy are mummys scared of vacation?<> They're afraid to unwind.\nWhiteboards ...<> are remarkable.\nWhat kind of dinosaur loves to sleep?<>A stega-snore-us.\nWhat kind of tree fits in your hand?<> A palm tree!\nI used to be addicted to the hokey pokey<> but I turned myself around.\nHow many tickles does it take to tickle an octopus?<> Ten-tickles!\nWhat musical instrument is found in the bathroom?<> A tuba toothpaste.\nMy boss told me to attach two pieces of wood together... <>I totally nailed it!\nWhat was the pumpkin’s favorite sport?<>Squash.\nWhat do you call corn that joins the army?<> Kernel.\nI've been trying to come up with a dad joke about momentum <>but I just can't seem to get it going.\nWhy don't sharks eat clowns? <> Because they taste funny.\nJust read a few facts about frogs.<> They were ribbiting.\nWhy didn’t the melons get married?<>Because they cantaloupe.\nWhat’s a computer’s favorite snack?<>Microchips!\nWhy was the robot so tired after his road trip?<>He had a hard drive.\nWhy did the computer have no money left?<>Someone cleaned out its cache!\nI'm not anti-social. <>I'm just not user friendly.\nWhy did the computer get cold?<>Because it forgot to close windows.\nWhat is an astronaut's favorite key on a keyboard?<>The space bar!\nWhat's the difference between a computer salesman and a used-car salesman?<>The used-car salesman KNOWS when he's lying.\nIf at first you don't succeed...<> call it version 1.0\nWhy did Microsoft PowerPoint cross the road?<>To get to the other slide!\nWhat did the computer do at lunchtime?<>Had a byte!\nWhy did the computer keep sneezing?<>It had a virus!\nWhat did one toilet say to the other?<>You look a bit flushed.\nWhy did the picture go to jail?<>Because it was framed.\nWhat did one wall say to the other wall?<>I'll meet you at the corner.\nWhat do you call a boy named Lee that no one talks to?<>Lonely\nWhy do bicycles fall over?<>Because they are two-tired!\nWhy was the broom late?<>It over swept!\nWhat part of the car is the laziest?<>The wheels, because they are always tired!\nWhat's the difference between a TV and a newspaper?<>Ever tried swatting a fly with a TV?\nWhat did one elevator say to the other elevator?<>I think I'm coming down with something!\nWhy was the belt arrested?<>Because it held up some pants!\nWhat makes the calendar seem so popular?<>Because it has a lot of dates!\nWhy did Mickey Mouse take a trip into space?He wanted to find Pluto!\nWhy do you go to bed every night?<>Because the bed won't come to you!\nWhat has four wheels and flies?<>A garbage truck!\nWhy did the robber take a bath before he stole from the bank?<>He wanted to make a clean get away!\nJust watched a documentary about beavers.<>It was the best damn program I’ve ever seen.\nSlept like a log last night<>woke up in the fireplace.\nWhy did the scarecrow win an award?<>Because he was outstanding in his field.\nWhy does a chicken coop only have two doors? <>Because if it had four doors it would be a chicken sedan.\nWhat’s the difference between an African elephant and an Indian elephant? <>About 5000 miles\nWhy did the coffee file a police report? <>It got mugged.\nWhat did the grape do when he got stepped on? <>He let out a little wine.\nHow many apples grow on a tree? <>All of them.\nWhat name do you give a person with a rubber toe? <>Roberto\nDid you hear about the kidnapping at school? <>It’s fine, he woke up.\nWhy do scuba divers fall backwards into the water? <>Because if they fell forwards they’d still be in the boat.\nHow does a penguin build it’s house? <>Igloos it together.\nWhat do you call a man with a rubber toe?<>Roberto\nDid you hear about the restaurant on the moon?<>Great food, no atmosphere.\nWhy was the belt sent to jail?<>For holding up a pair of pants!\nDid you hear about the scientist who was lab partners with a pot of boiling water?<>He had a very esteemed colleague.\nWhat happens when a frogs car dies?<>He needs a jump. If that doesn't work he has to get it toad.\nWhat did the flowers do when the bride walked down the aisle?<>They rose.\nWhy did the man fall down the well?<>Because he couldn’t see that well.\nMy boss told me to have a good day...<>...so I went home.\nHow can you tell it’s a dogwood tree?<>By the bark.\nDid you hear about the kidnapping at school?<>It’s fine, he woke up.\nWhy is Peter Pan always flying?<>Because he Neverlands.\nWhich state has the most streets?<>Rhode Island.\nWhat do you call 26 letters that went for a swim?<>Alphawetical.\nWhy was the color green notoriously single?<>It was always so jaded.\nWhy did the coach go to the bank?<>To get his quarterback.\nHow do celebrities stay cool?<>They have many fans.\nWhat's the most depressing day of the week?<>sadder day.\nDogs can’t operate MRI machines<>But catscan.\nI was going to tell a time-traveling joke<>but you guys didn’t like it.\nStop looking for the perfect match<>instead look for a lighter.\nI told my doctor I heard buzzing<>but he said it’s just a bug going around.\nWhat kind of car does a sheep like to drive?<>A lamborghini.\nWhat did the accountant say while auditing a document?<>This is taxing.\nWhat did the two pieces of bread say on their wedding day?<>It was loaf at first sight.\nWhy do melons have weddings?<>Because they cantaloupe.\nWhat did the drummer call his twin daughters?<>Anna One, Anna Two!\nWhat do you call a toothless bear?<> A gummy bear!\nTwo goldfish are in a tank. <>One says to the other, “Do you know how to drive this thing?”\nWhat’s Forrest Gump’s password?<>1forrest1\nWhat is a child guilty of if they refuse to nap?<> Resisting a rest.\nI know a lot of jokes about retired people<>but none of them work.\nWhy are spiders so smart?<>They can find everything on the web.\nWhat has one head, one foot, and four legs?<> A bed.\nWhat does a house wear?<> Address.\nWhat’s red and smells like blue paint?<>Red paint.\nMy son asked me to put his shoes on<> but I don’t think they’ll fit me.\nI’ve been bored recently, so I decided to take up fencing.<> The neighbors keep demanding that I put it back.\nWhat do you call an unpredictable camera?<>A loose Canon.\nWhich U.S. state is known for its especially small soft drinks?<>Minnesota.\nWhat do sprinters eat before a race?<> Nothing—they fast.\nI’m so good at sleeping...<>I can do it with my eyes closed.\nPeople are usually shocked that I have a Police record.<>But I love their greatest hits!\nI told my girlfriend she drew on her eyebrows too high.<> She seemed surprised.\nWhat do you call a fibbing cat?<> A lion.\nWhy shouldn’t you write with a broken pencil?<> Because it’s pointless.\nI like telling Dad jokes…<>sometimes he laughs.\nHow do you weigh a millennial?<> In Instagrams.\nThe wedding was so beautiful<>even the cake was in tiers.\nWhat’s the most patriotic sport?<> Flag football.\nHow do you know when you are going to drown in milk? When its past your eyes!\nMilk is also the fastest liquid on earth – its pasteurized before you even see it\nA steak pun is a rare medium well done.\nDid you hear that the police have a warrant out on a midget psychic ripping people off? It reads \"Small medium at large.\"\nA panda walks into a bar and says to the bartender \"I'll have a Scotch and . . . . . . . . . . . . . . Coke thank you\".\n\"Sure thing\" the bartender replies and asks \"but what's with the big pause?\"\nThe panda holds up his hands and says \"I was born with them\"\nA man was caught stealing in a supermarket today while balanced on the shoulders of a couple of vampires. He was charged with shoplifting on two counts.\nI heard there was a new store called Moderation. They have everything there\nOur wedding was so beautiful, even the cake was in tiers.\nDid you hear about the new restaurant on the moon? The food is great, but there's just no atmosphere.\nI went to a book store and asked the saleswoman where the Self Help section was, she said if she told me it would defeat the purpose.\nWhat did the mountain climber name his son? Cliff.\n\"What's ET short for? Because he's only got little legs.\"\nWhat do you call an Argentinian with a rubber toe? Roberto\nWhat do you call a Mexican man leaving the hospital? Manuel\nToday a girl said she recognized me from vegetarian club, but I'm sure I've never met herbivore.\nI dreamed about drowning in an ocean made out of orange soda last night. It took me a while to work out it was just a Fanta sea.\nI needed a password eight characters long so I picked Snow White and the Seven Dwarfs.\nLast night me and my girlfriend watched three DVDs back to back. Luckily I was the one facing the TV.\nHow do you organize a space party? You planet.\nBreaking news! Energizer Bunny arrested – charged with battery.\nConjunctivitis.com – now that's a site for sore eyes.\nA Sandwich walks into a bar, the bartender says \"Sorry, we don't serve food here\"\nThey laughed when I said I wanted to be a comedian – they're not laughing now.\nI'm reading a book on the history of glue – can't put it down.\nWhere does Napoleon keep his armies? In his sleevies.\nI went to the zoo the other day, there was only one dog in it. It was a shitzu.\nWhy can't you hear a pterodactyl go to the bathroom? The p is silent.\nQ: What's 50 Cent's name in Zimbabwe? A: 400 Million Dollars.\n\"My Dog has no nose.\" \"How does he smell?\" \"Awful\"\nWhat do you call a cow with no legs? Ground beef.\nWhat did the Buffalo say to his little boy when he dropped him off at school? Bison.\nSo a duck walks into a pharmacy and says \"Give me some chap-stick... and put it on my bill\"\nWhy did the scarecrow win an award? Because he was outstanding in his field.\nWhy did the girl smear peanut butter on the road? To go with the traffic jam.\nWhy does a chicken coop only have two doors? Because if it had four doors it would be a chicken sedan.\nWhy don't seagulls fly over the bay? Because then they'd be bay-gulls!\nWhat do you call a fly without wings? A walk.\nWhat do you do when a blonde throws a grenade at you? Pull the pin and throw it back.\nWhat's brown and sounds like a bell? Dung!\nHow do you make a hankie dance? Put a little boogie in it.\nWhere does batman go to the bathroom? The batroom.\nWhat's the difference between an African elephant and an Indian elephant? About 5000 miles.\nTwo muffins were sitting in an oven, and the first looks over to the second, and says, \"man, it's really hot in here\". The second looks over at the first with a surprised look, and answers, \"WHOA, a talking muffin!\"\nA man walks into a bar and orders helicopter flavor chips. The barman replies \"sorry mate we only do plain\"\n Sgt.: Commissar! Commissar! The troops are revolting! Commissar: Well, you're pretty repulsive yourself.\nWhat do you call a sheep with no legs? A cloud.\nI knew i shouldn't have ate that seafood. Because now i'm feeling a little... Eel\nWhat did the late tomato say to the early tomato? I'll ketch up\nWhat did the 0 say to the 8? Nice belt.\nWhy didn't the skeleton cross the road? Because he had no guts.\nWhy don't skeletons ever go trick or treating? Because they have nobody to go with.\nWhy do scuba divers fall backwards into the water? Because if they fell forwards they'd still be in the boat.\nHave you ever heard of a music group called Cellophane? They mostly wrap.\nWhat kind of magic do cows believe in? MOODOO.\nWife: Honey I'm pregnant. Me: Well.... what do we do now? Wife: Well, I guess we should go to a baby doctor. Me: Hm.. I think I'd be a lot more comfortable going to an adult doctor.\nAt what time does the soldier go to the dentist? 1430.\n\"Hold on, I have something in my shoe\"  \"I'm pretty sure it's a foot\"\nWhy does it take longer to get from 1st to 2nd base, than it does to get from 2nd to 3rd base? Because there's a Shortstop in between!\nDad I'm hungry' ... Hi hungry I'm dad\nWhen phone ringing Dad says 'If it's for me don't answer it.'\nPut the cat out ... I didn't realize it was on fire\nWhere's the bin? Dad: I haven't been anywhere!\nCan I watch the TV? Dad: Yes, but don't turn it on.\nWhen Dad drops a pea off of his plate 'oh dear I've pee'd on the table!'\nI've been addicted to cold turkey for 2 years. I keep telling people I'm trying to quit cold turkey but nobody is taking me seriously.\nOld yachtsmen don't die... They just keel over.\n3.14% of sailors are pi-rates.\nBad at golf? Join the club.\nI just ate a frozen apple. Hardcore.\nHave you met my friend Annette? She's married to a fisherman.\nWhy is Irish whiskey triple distilled? To be sure, to be sure, to be sure.\nI just read a book about Stockholm syndrome. It was pretty bad at first, but by the end I liked it.\nRIP boiled water. You will be mist.\nArchaeology really is a career in ruins...\nI don't trust stairs. They're always up to something.\nIf you want a job in the moisturiser industry, the best advice I can give is to apply daily.\nA big cat escaped it's cage at the zoo yesterday. If I saw that I'd puma pants.\nMy Czech mate is surprisingly bad at chess.\nWhy are Lada's so bad? Because the keep Stalin.\nWhat do you get hanging off banana trees? Sore arms.\nI made my wife a cocktail with fairy liquid in it.... She was foaming at the mouth when she tasted it.\nWhat do you call a fat psychic? A four-chin teller.\nFound out I was colour blind the other day... That one came right out the purple.\nI hate perforated lines, they're tearable.\nA man tried to sell me a coffin today... I told him that's the last thing I need.\nWhenever I want to start eating healthy, a chocolate bar looks at me and Snickers.\nDon't kiss your wife with a runny nose. You might think it's funny, but it's snot.\nMy friend keeps telling me I'm in the closet. I just say it's Narnia business. @WillFerreI\nI burnt my Hawaiian pizza last night... I should've put it on aloha setting.\nDad: Where can I get a potato clock? Son: Why a potato clock?!? Dad: I've got a new job and my boss said I need to get-a-potato-clock\nMy son asked me to stop singing oasis songs in public. I said maybe.\nWhen my wife told me to stop impersonating a flamingo I had to put my foot down.\nWhat's the difference between a hippo and a zippo? One is really heavy, the other is a little lighter.\nTo the man in the wheelchair that stole my camouflage jacket... You can hide but you can't run.\nThey don't watch the flintstones in Dubai. But Abu Dhabi do.\nLone Ranger sees Tonto riding with a dustbin. LR: \"Where are you going Tonto?\" T: \"to-the-dump-to-the dump-to-the-dump-dump dump...\"\nWhy can't you hear a pterodactyl using the bathroom? Because the P is silent\nHappy Father's Day! Did you hear about the crazy Mexican train thief? He had loco motives\nSinging in the shower is all fun and games until you get shampoo in your mouth.... Then it's a soap opera\nThe rotation of earth really makes my day.\nYou can't run through a camp site. You can only ran, because it's past tents.\n\"Does this uniform make me look fat\" - insecurity guard\nHow do you tell the difference between a crocodile and an alligator? You will see one later and one in a while.\nI told my wife she drew her eyebrows too high. She seemed surprised.\nWhy do trees seem suspicious on sunny days? Dunno, they're just a bit shady.\nYou know what they say about cliffhangers...\nWant to hear a joke about construction? Nah, I'm still working on it.\nEver noticed that glass tastes like blood?\nA classic from who's line is it anyway.\nYou heard the rumor going around about butter? Nevermind, I shouldn't spread it.\nI have the heart of a lion and a lifetime ban from London zoo. @zsllondonzoo\nWhat did the Buddhist ask the hot dog vendor? \"Make me one with everything.\"\nHow does the moon cut his hair? Eclipse it!\nWhat do you call an elephant that doesn't matter? An irrelephant\nWhat happened to the cow that jumped over the barbed wire fence? Udder destruction.\nI thought about going on an all-almond diet..... But that's just nuts\nWhat's a duck's favourite dip? Quackamole\nWhat do you call a fake noodle? An Impasta\nI hate it when people ask me what I will be doing in 5 years time. Come on, I don't have 2020 vision.\nSteak puns... They're a rare medium, well done\nThe shovel was a ground-breaking invention.\nPast, present, and future walked into a bar.... It was tense.\nComedians who tell one too many lightbulb jokes soon burn out.\nLook! I'm wearing a Thai.\nWhat do you call a Mexican who has lost his car? Carlos.\nHow does a penguin build it's house? Igloos it together.\nKnock knock. Who's there? To. To Who? To whom.\nWhy do you never see elephants hiding in trees? Because they're so good at it.\nI went out with a girl called Simile, I don't know what I metaphor.\nI went on a two week holiday to the south of France. It was Toulon.\nA pirate walks into a bar with a ship's wheel on his belt buckle. Bartender: What's that on your belt? Pirate: Arrr, It's drivin' me nuts!\nPlateaus are the highest form of flattery.\nMe: Doctor you've got to help me, I'm addicted to Twitter. Doctor: I don't follow you.\nThere's no I in denial.\nMy computer sings, it's a Dell.\nIt's time to rock around the Christmas tree.\nI got a reversible jacket for Christmas, I can't wait to see how it turns out.\nI ate a clock yesterday, it was so time consuming.\nI'm tired of following my dreams. I'm just going to ask them where they are going and meet up with them later.\nWhat's brown and sticky? A stick.\nHow do you find Will Smith in the snow? You look for the fresh prints.\nDid you hear about the kidnapping at school? Its ok, he woke up.\nWhat's the best thing about elevator jokes? They work on so many levels.\nHow do you make antifreeze? Steal her blanket.\nWhat's the difference between beer nuts and deer nuts? Beer nuts are about 49cents and deer nuts are just under a buck.\nDid you hear about the guy who jumped off a bridge in Paris? He was in Seine.\nThere are only two types of people in the world, those who can extrapolate from incomplete data...\nWhat did the buffalo say to his son as he left for college? Bison\nA truck of Terrapins crashed into a truck of tortoises. It was a turtle disaster.\nWhat does a house wear? A dress.\nI asked a Frenchman if he played video games. He said \"wii\".\nFull Meal Jacket\nA furniture store keeps calling me. But all I wanted was one night stand.\nWhat did the dog say after a long day at work? \"Today was Ruff\"\nWhere are average things built? In the satisfactory.\nI've eaten too much Middle Eastern food. Now I falafel.\nA pet store had a bird contest. No perches necessary.\nWhat's the worst thing about ancient history class? The teachers tend to Babylon.\nYesterday a clown held a door open for me. I thought it was a nice jester.\nHow many optometrists does it take to change a light bulb?... 1 or 2? 1... or 2?\nMy son asked me to take him to the hospital because he had a large red mark on his face. I said \"Let's not make any rash decisions.\"\nJust read a few facts about frogs. They were ribbiting.\nSean Connery famously said he would leave The Bahamas and return to Scotland, if it ever gained independence. He must be shitting himself.\nI used to work in a shoe recycling shop. It was sole destroying.\nThe universe implodes. No matter.\nI can give you the cause of an anaphylactic shock in a nutshell.\nI just swapped our bed for a trampoline. My wife hit the roof.\nI heard a rumour that Cadbury is bringing out an oriental chocolate bar. Could be a Chinese Wispa.\nMy dog Minton ate a shuttlecock... Bad Minton.\nAstronomers got tired of watching the moon go round the earth for 24 hours. So the decided to call it a day.\nI've got an addiction to water, I think I'm an aquaholic.\nWhat did the hungry clock do? Went back four seconds!\nMy sea sickness comes in waves.\nI play triangle for a reggae band. It's pretty casual. I just stand at the back and ting.\nI'm afraid I've caught poetry. Don't worry, I used to suffer from short stories. Really?When? Once upon a time\nI asked the checkout girl for a date. She said \"They're in the fruit aisle next to the bananas.\"\nWhat did the chicken say about the scrambled egg? There goes my crazy, mixed up kid.\nWhy do so many people with laser hair want to get it removed?\nWhat's the difference between a well dressed man on a a bicycle and a poorly dressed man on a tricycle? Attire!\nWhy does Peter pan always fly?Because he neverlands!\nFor all American Dads, this is all you need today.\nWhat did the pirate say on his 80th birthday? Aye matey\nI jumped into the sea today. My friends pier pressured me into it.\nWhat do you call a sketchy Italian neighbourhood? The Spaghetto.\nI have kleptomania, but when it gets bad, I take something for it.\nWhy can't you have a nose 12 inches long? Because then it would be a foot.\nWhy do bears have hairy coats? Fur protection.\nSomeone said my clothes were gay. I said \"Yeah, they came out of the closet this morning.\"\nI just misspelt Armageddon, it's not the end of the world.\nVolunteering in America is absurd, it just makes no cents.\nJonny Wilkinson is announcing his retirement from rugby. You can't say he didn't try.\nWhy don't you want to taco bout it? 'Cause i'm nacho friend anymore.\nDoorbells, don't knock 'em.\nI'm back from holiday in the South Pacific. I wish I had Samoa time off.\n\"I'm on a whiskey diet, I've lost 4 days already.\" Tommy Cooper What's your favorite Cooperism?\nMy wife is on a tropical food diet, the house is full of the stuff. It's enough to make a mango crazy.\nWhiteboards are remarkable.\nSweet dreams are made of cheese, who am I to dis a Brie.\nHappy Easter! What's your best egg yolk? Mine is: A boiled egg is hard to beat.\nWhat do you call an Alligator wearing a vest? An investigator.\nDid you hear about the magic tractor? It turned into a field.\nCan February march? No, but April May.\nFull credit to the whoever made this for Putin in the effort.\nWhat does a grape say when it is stepped on? Nothing, it just lets out a little wine.\nWhat do you call a dinosaur with an extensive vocabulary? A thesaurus.\nI swallowed some Tippex last night. I woke up this morning with a massive correction.\nJust got a text from Snoop Dogg. No biggy.\nWhat do you get when you cross a rhetorical question with a joke?\nPink Panthers to do list: To do To do To do, to do, to do To do, to doooo\nWhat did the bra say to the hat? You go on ahead, I'll give these two a lift.\nWhat did one eye say to the other? Something smells between us.\nTwo elephants fall off a cliff... Boom boom!\nHow many tickles does it take to make an octopus laugh? Tentacles.\nI don't like atoms, they're liars. They make up everything.\nIf you want to set up a company and run it, then that's your own business.\nMy friend is going on holiday to the Middle East. Oman, that sounds fun...\nWhoever invented the door knocker deserves a no-bell prize!\nWhy did the elf push his bed into the fireplace? He wanted to sleep like a log.\nI can't stand Russian dolls.... They're so full of themselves.\nI remember the first time I saw a universal remote controller. I thought to myself \"well, this changes everything...\"\nI got this extra electron I didn't want. My friend said \"don't be so negative.\"\nA boat builder is showing his son one of his forests. He turns to him and says, \"Son, one day this will all be oars\"\nMolestation is a touchy subject.\nI’ve decided to put up a marquee in my garden with some funky music and flashing lights. Now is the winter of my disco tent.\nI was thinking about moving to Moscow but there is no point Russian into things.\nI met a Dutch girl with inflatable shoes last week, I phoned her up for a date but she'd popped her clogs.\nMy New Years resolution is to stop leaving things so late.\nDid you hear about the man who gave up making haggis? He didn't have the guts for it anymore.\nRetrospective baddadjoke: Why are there no pain killers in the jungle? Because parrots-eat-em-all\nSometimes I squat on the floor, put my arms around my legs and lean forward. That's how I roll.\nGot lost in a corn field today, it was a-maize-ing.\nI needed a password eight characters long so I picked Snow White and the Seven Dwarves.\nJust out buying some new chairs for the house, sofa so good.\nMy wife told me I was average, I think she's mean.\nI was going to tell a dairy joke, but it was too cheesy.\nJust had my first round of golf. I'm not very good, in fact I've got a fairway to go.\nMy daughter just lost her mood ring, really don't know how she feels about it.\nI told a friend I was off to California this summer. He told me to be more pacific... so I went to Hawaii instead...\nI gave all my dead batteries away today... Free of charge.\nWhy is there a long line at the cemetery? Because people are dying to get in.\nWhy did the can crusher quit his job? Because it was soda pressing.\nI'm starting a band called 1023mb We'll never get a gig.\nWhat's Forest Gump's Facebook password? 1forest1\nA photon checks into a hotel. Receptionist: \"May I take your bags sir?\" Photon: \"I don't have any bags, I'm travelling light.\"\nMelon 1: \"Let's run away and get married.\" Melon 2: \"Sorry but I Cantaloupe.\"\nDid you hear about the Italian chef who died? He pasta way.\nI lost my job last week. Unemployment is not working for me.\nA termite walks into a bar and asks \"Is the bar tender here?\"\nHitler was surprised by the Invasion of Normandy. He did nazi that coming.\nA Freudian slip is when you say one thing but mean your mother.\nSo, I asked my North Korean mate how his life was going? He said \"can't complain\"\nJust quit my job at Starbucks because day after day it was the same old grind.\nI went to the zoo the other day, there was only one dog in it, it was a shitzu.\nWhy is Saudi Arabia free of mental illness? Because No-mad people live there.\nWithout geometry life is pointless.\nI broke my guitar string last night. Don't fret, I had another.\nHad a new beaver curry last night. It's like a normal curry, just a bit 'otter.\nWent to the corner shop today... Bought four corners.\nHave you heard the conspiracy about Russian allotments. It's all just a communist plot.\nMy uncle works with Digital radios. You could say he’s a DAB hand.\nI dreamt about drowning in an ocean made out of orange soda last night. It took me a while to work out it was just a Fanta sea.\nMy cat was just sick on the carpet, I don't think it's feline well.\nWhy do the French only put one egg in an omelette? Because one egg is un oeuf.\nThe other day someone left plasticine in my house. I didn't know what to make of it.\nWhat happens when you tell an egg a joke? It cracks up.\nHow do you make holy water? Boil the hell out of it.\nSorry I've been away for a while, I was at the fabric shop looking for new material.\nI've just been to a very emotional wedding. Even the cake was in tiers.\nWhen you have a bladder infection, urine trouble.\nI stayed up all night to find out where the sun went, then it dawned on me...\nI went to the doctor today and he told me I had type A blood but it was a type O.\nToday a girl said she recognised me from vegetarian club, but I'm sure I've never met herbivore.\nJokes about German sausages are the wurst.\nI tried to throw a ball at a cloud. I mist.\nI woke up with a face full of rice. I must've fallen asleep as soon as my head hit the pilau.\nI couldn't pay for my coffee because my wallet was in my other pair of moccachinos. I got it for free. Thanks a latté @lashingsbristol!\nI cut my finger chopping cheese, but I think that I may have grater problems.\nFirst rule of Thesaurus Club. You don't talk, converse, discuss, speak, chat, deliberate, confer, gab, gossip or natter about Thesaurus Club\nDon't have a Findus lasagne before bed. You'll have a nightMARE.\nHow does a muppet die? Apparently, it kermits suicide.\nWhat did the Mexican say to his chicken? Oh-lay!\nA pet shop was ransacked last week... ...there are currently no leads.\nHow do you drown a hipster? In the mainstream.\nSleeping comes naturally to me. I can do it with my eyes closed.\nI ate some rotten chicken last night. Now I feel fowl.\nThere is a new disease found in margarine... Apparently it spreading very easily.\nWhat do you call an Italian with a rubber toe? Roberto\nWhy do crabs never give to charity? Because they're shellfish.\nWhat is Santa's favourite pizza? One that's deep pan, crisp and even.\nPeople are making apocalypse jokes like there's no tomorrow.\nSomeone called me pretentious the other day... I almost choked on my latte.\nMy mate dug a hole in the garden and filled it with water....I think he meant well.\nWhat's your favourite Christmas Cracker Joke? Here's one of mine: \"What's ET short for? Because he's only got little legs.\"\nIf you're struggling to think of what to get someone for Christmas. Get them a fridge and watch their face light up when they open it.\nA mate of mine has admitted to being addicted to break fluid. I'm worried but he says he can stop whenever he wants.\nStart a new job in Seoul next week. I thought it was a good Korea move.\nSoya Milk. Looked in your fridge.\nA book just fell on my head. I've only got myshelf to blame.\nBloody thespians, always making a scene.\nMy dad fought in the war and survived mustard gas and pepper spray. He is now classed as a seasoned veteran.\nTea is for mugs.\nThis thesaurus isn't just terrible, it is also terrible.\nI am terrified of elevators. I'm going to start taking steps to avoid them.\nNeed an ark to save two of every animal? I Noah guy.\nWhat did the father say to the son who was going fishing? Let minnow when you get there.\nI am delighted with the corn crop this year. It's A-maize-ing.\nHow does Moses make his tea? Hebrews it.\nI think rowing is oarsome.\nWhat's the advantage of living in Switzerland? Well, the flag is a big plus.\nNostalgia isn't what it used to be.\nWhy do accountants look so good in heels? Because they never lose their balance.\nI'll stop at nothing to avoid using negative numbers.\nWind turbines. I'm a big fan!\nWhat's the definition o a good farmer? A man outstanding in his field.\nWhy did the octopus beat the shark in a fight? Because it was well armed.\nWhy does a Moon-rock taste better than an Earth-rock? Because it's a little meteor.\nI fired my masseuse today. She rubbed me up the wrong way.\nA red and a blue ship have just collided in the Caribbean. Apparently the survivors are marooned.\nBreaking news! A hurricane has just hit the the main cheese factory in France. All that's left is de-Brie.\nI'm glad I know sign language, it's pretty handy.\nI like sea food. I often just have it for the halibut.\nA girl walks into a bar and asked for a double entendre. So the barman gave her one.\nI took the shell off of my racing snail to see if it went any faster. If anything though, it just made it more sluggish.\nI've deleted the phone numbers of all the Germans I know from my mobile phone. Now it's Hans free.\nWas kept awake last night by someone flashing a light in my face. It was torch-ure.\nMy wife said to me \"Your lack of originality is pathetic.\"I said \"Yeah, well your lack of originality is pathetic.\"\nIt was really hard overcoming my addiction to the hokey cokey. But I turned myself around and that’s what it’s all about.\n\"I saw a documentary on how ships are kept together. Riveting!\" Stewart Francis\nLast night me and my girlfriend watched three DVDs back to back. Luckily I was the one facing the telly.\nMy wife just split up with me because I've got a pasta fetish. I'm feeling cannelloni right now.\nI'm thinking about getting a new haircut... I'm going to mullet over.\nHad a bowl of scotch broth for lunch today... It was souper hot.\nI got really quick service at the fish and chip shop. It was very e-fish-ent\nHow do you organise a space party? You planet.\nWhat did one bird say to the other cheating parrot? Toucan play at that game.\nWhat's wrong with the Southern French's trousers? They're Toulouse.\nHow much does a hipster weigh? An instagram.\nA photon enters a hotel. Porter: 'Need any help with your luggage?' Photon: 'No thanks, I'm travelling light'\nGive me ambiguity or give me something else.\nA banker came home from work today worried about his job. He said its in the balance.\nsorry \"a *pod* of killer whales\"\nWhat do you call a group of killer whales playing instruments? An Orca-stra.\nThe only thing that can survive a\nA man has taken @British_Airways to court after they misplaced his luggage. He lost his case.\nDid you hear about the guy whose whole left side was cut off? He's all right now.\nWhy was the big cat disqualified from the race? Because it was a cheetah.\nWhat do you call a man with rabbits living in his bum? Warren\nJust been fishing... It was reely good.\nA man walked in to a bar with some asphalt on his arm. He said \"Two beers please, one for me and one for the road.\"\nIt's so hard to think of another chemistry joke... All the good ones Argon.\nWhy do people dislike mushrooms? Because they're made from Toads Stools...\nThere was so much fighting on our Easter camping trip... it was in-tents.\nIt's easter already?!\nI'm off to Nairobi in the Summer. Kenya believe it?\nMy first girlfriend's name was Ivy... she was all over me.\nI've just voted for Charlie's odyssey by Charlie Denholm as the funniest film\nHelvetica walks into a bar. The barman says \"We don't serve your type around here.\"\nArgon walks into a bar. The barman says \"Get the hell out!\" Argon doesn't react.\nJust watched a documentary about beavers... It was the best damn program I've ever seen.\nLast night it was raining cats and dogs... I stepped in a poodle.\nI thought about being a juggler, but I didn't have the balls.\nMy mate got a job as a lion's hairdresser at the zoo today. He is literally the mane man.\nI'm not as think as you drunk I am.\nI'm thinking about moving to France... I've got nothing Toulouse.\nWent surfing the other day, it was swell.\nWatershed joke: A baker was caught bonking his bread loaves. They say he was inbread.\nThe only thing that can survive a double dip is a hobnob. Osborne, call McVities.\nI enjoy using the comedy technique of self-deprecation – but I’m not very good at it.\nMy wife... its difficult to say what she does... She sells seashells on the seashore.\nA poker player loses his arm in a nasty accident. He's now got a prosthetic replacement. He just can't deal with it.\nA girl invited me back to her place last night for champagne... It turned out it was real pain.\nTheres a new type of pillow made from corduroy... Its making headlines.\nWhat did the father say to his crying son at his Indian themed birthday party? It's chapatti and you can cry if you want to.\nBreaking news! Energizer Bunny arrested - charged with battery.\nWow who saw that coming? Harry Potter and News of the World two of the Biggest selling modern fiction publications ending in the same week.\nI went in to a pet shop. I said 'Can I buy a goldfish?' The guy said, 'Do you want an aquarium?' I said 'I don't care what star sign it is.'\nA man was found today vacuum cleaning the top of nelsons column without any safety equipment. Police say he was Dyson with death.\nA man went to A&E at the weekend who swallowed 12 plastic horses. Don't worry the doctors describe his condition as stable.\nConjunctivitis.com - now that's a site for sore eyes.\nA guy walks into the psychiatrist wearing only clingfilm for shorts. The shrink says, \"Well, I can clearly see you're nuts.\"\nWIMBLEDON SPECIAL Why should you never fall in love with a tennis player? To them, \"Love\" means nothing.\nI went to the doctor the other day I said 'have you got anything for wind' so he gave me a kite.\nA sandwich walks into a bar. The barman says \"we don't serve food here.\"\nThe recruitment consultant asked me \"What do you think of voluntary work?\" I said \"I wouldn't do it if you paid me.\"\nAn ice cream man was found lying on the floor of his van covered with hundreds and thousands. Police say that he topped himself.\n\"Doctor, I've broken my arm in several places\" Doctor \"Well don't go to those places.\"\nA boiled egg in the morning is hard to beat.\nI'm on a whiskey diet. I've lost three days already.\nWhat do you do with chemists when they die? We barium.\nPretty appropriate. Seven days without a pun makes one weak.\nHand me my Mondeo, my semidetached house, my unloved wife, my unfulfilling job, my xbox kids. Twitter, I am your dad and I tell bad jokes.\nWhat type of onion is the best painkiller? A-sprin' onion...\nJust passed a manicurist and a dentist quarreling in the street- they were fighting tooth and nail.\nI fear for the calendar, it's days are numbered.\nWhat's the definition of 'A Will'? (I'll give you a clue, it's a dead giveaway.)\nI buy a different brand of cling flim every time I go to the shops. Just to keep things fresh.\nThe advantages of origami are twofold.\nThere's a new type of broom out, it's sweeping the nation.\nAtheism is a non-prophet organisation.\nI went to a seafood disco last night and pulled a muscle.\nMy friend drowned in a bowl of muesli. A strong currant pulled him in.\nSometimes I drink my whiskey neat. Other times I take off my tie and untuck my shirt.\nI don't want to sound big headed but I wear extra large hats.\nMy friend said \"You remind me of a ketchup bottle\", I said \"I'll take that as a condiment\".\nSlept like a log last night ... woke up in the fireplace.\nExit signs - they're on the way out aren't they.\nWhat did the fish say when it swam into a wall? Damn!\nA cat hijacked a plane, stuck a pistol to the pilots ribs and said \"TAKE ME TO THE CANARIES!\"\nThey laughed when I said I wanted to be a comedian - they're not laughing now.\nOne arm butlers - they can take it, but they can't dish it out.\nA shark will only attack you if you're wet\nBeware of alphabet grenades, they might spell disaster.\nWhat cheese can never be yours? Nacho cheese.\nLast night I saw this guy chatting up a cheetah at the bar. I thought 'he's trying to pull a fast one.'\nMy housemate opened the fridge last night and threw a block of cheese at me. I said \"That's mature.\"\nA police officer caught two kids playing with a firework and a car battery. He charged one and let the other one off.\nI used to be indecisive, but now I'm not quite sure.\nWhy are there no pain killers in the jungle? Because parrots-eat-em-all\nI'm reading a book on the history of glue - can't put it down.\nAlbinos - can't say fairer than that.\nVelcro... What a rip-off.\nA man walks into a butcher. The butcher bets him £5 he can't guess a shelf of meat's weight. Man replies \"I cant, the steaks are too high.\"\nTwo aerials meet on a roof, fall in love and get married... The ceremony was rubbish but the reception was excellent.\nBlack beauty... He's a dark horse."
  },
  {
    "path": "main.py",
    "content": "import argparse\nimport importlib\nimport json\nimport multiprocessing\nimport os\nimport sys\nimport time\n\nfrom prettytable import PrettyTable\nfrom watchdog.events import FileSystemEventHandler\nfrom watchdog.observers import Observer\n\nfrom util.config import Config, manage_config\nfrom util.logger import Logger\nfrom util.notification import ErrorNotifyHandler\nfrom util.scheduler import check_schedule\nfrom util.utility import create_bar\nfrom util.version import get_version, start_version_check\n\nlist_of_python_modules = [\n    \"border_replacerr\",\n    \"health_checkarr\",\n    \"labelarr\",\n    \"nohl\",\n    \"poster_cleanarr\",\n    \"poster_renamerr\",\n    \"renameinatorr\",\n    \"sync_gdrive\",\n    \"upgradinatorr\",\n    \"unmatched_assets\",\n    \"jduparr\",\n]\n\n\nclass ScheduleFileHandler(FileSystemEventHandler):\n    def __init__(self, callback, debounce_interval=1):\n        super().__init__()\n        self.callback = callback\n        self.last_modified = 0\n        self.debounce_interval = debounce_interval\n\n    def on_modified(self, event):\n        sys.stderr.write(f\"[WATCHDOG] Detected change in: {event.src_path}\\n\")\n        if event.src_path.endswith(\"config.yml\"):\n            now = time.time()\n            if now - self.last_modified > self.debounce_interval:\n                self.last_modified = now\n                self.callback()\n\n\ndef start_schedule_watcher(callback):\n    observer = Observer()\n    observer.daemon = True\n    handler = ScheduleFileHandler(callback)\n    # Determine config directory and ensure it exists\n    config_path = os.environ.get(\"CONFIG_DIR\", \"./config\")\n    if not os.path.isdir(config_path):\n        os.makedirs(config_path, exist_ok=True)\n    observer.schedule(handler, path=config_path, recursive=False)\n    observer.start()\n    return observer\n\n\nclass ModuleManager:\n    def __init__(self, logger):\n        self.running_modules = {}\n        self.module_start_times = {}\n        self.logger = logger\n        self.last_run_times = {}\n\n    def run(self, module_name, run_module):\n        process = run_module(module_name, self.logger)\n        if process:\n            self.running_modules[module_name] = process\n            self.module_start_times[module_name] = time.time()\n\n    def run_if_due(self, module_name, schedule_time, check_schedule_func, run_module):\n        if check_schedule_func(module_name, schedule_time, self.logger):\n            import time\n\n            now = time.time()\n            last_run = self.last_run_times.get(module_name, 0)\n            # Prevent multiple runs within the same schedule window (60 seconds)\n            if now - last_run >= 60:\n                self.logger.info(\n                    f\"[SCHEDULE] Running module: {module_name} at {schedule_time}\"\n                )\n                self.last_run_times[module_name] = now\n                self.run(module_name, run_module)\n\n    def is_already_running(self, module_name):\n        return module_name in self.running_modules\n\n    def cleanup(self):\n        processes_to_remove = []\n        for module_name, process in self.running_modules.items():\n            if process and not process.is_alive():\n                duration = time.time() - self.module_start_times.pop(module_name)\n                self.logger.info(\n                    f\"[SCHEDULE] module: {module_name.upper()} has finished in {duration:.2f} seconds\"\n                )\n                processes_to_remove.append(module_name)\n\n        for module_name in processes_to_remove:\n            del self.running_modules[module_name]\n\n    def has_running_modules(self):\n        return bool(self.running_modules)\n\n\ndef load_schedule():\n    # Do not merge defaults here; only read existing config\n    config = Config(\"main\")\n    schedule = config.scheduler\n    return schedule\n\n\ndef run_module(module_to_run, output=False, logger=None):\n    config = Config(module_to_run).module_config\n\n    def run_python_module(module_to_run):\n        config.instances_config = Config(module_to_run).instances_config\n        module = importlib.import_module(f\"modules.{module_to_run}\")\n        process = multiprocessing.Process(target=module.main, args=(config,))\n        process.start()\n        return process\n\n    if module_to_run in list_of_python_modules:\n        return run_python_module(module_to_run)\n\n\ndef print_schedule(logger, modules_schedules):\n    logger.info(create_bar(\"SCHEDULE\"))\n    table = PrettyTable([\"module\", \"Schedule\"])\n    table.align = \"l\"\n    table.padding_width = 1\n    for module_name, schedule_time in modules_schedules.items():\n        table.add_row([module_name, schedule_time])\n    logger.info(f\"{table}\")\n    logger.info(create_bar(\"SCHEDULE\"))\n\n\ndef main():\n    # CLI argument parsing\n    parser = argparse.ArgumentParser(description=\"Run DAPS modules or start web UI.\")\n    parser.add_argument(\n        \"modules\", nargs=\"*\", help=\"Module names to run once (cli mode).\"\n    )\n    parser.add_argument(\n        \"--version\",\n        action=\"version\",\n        version=f\"%(prog)s {get_version()}\",\n        help=\"Show the DAPS version and exit.\",\n    )\n\n    args = parser.parse_args()\n\n    # Set console logging: modules only when explicitly requested via CLI,\n    if args.modules:\n        os.environ[\"LOG_TO_CONSOLE\"] = \"true\"\n    else:\n        os.environ[\"LOG_TO_CONSOLE\"] = \"false\"\n\n    if args.modules:\n        # CLI mode: run specified modules and exit\n        for name in args.modules:\n            if name in list_of_python_modules:\n                run_module(name, output=True)\n            else:\n                print(f\"Error: module '{name}' not found.\")\n                sys.exit(1)\n        return\n\n    try:\n        main_config = Config(\"main\").module_config\n    except Exception as e:\n        print(f\"Error loading main config for logger: {e}\")\n        sys.exit(1)\n    logger = Logger(main_config.log_level, \"main\")\n\n    main_logger = logger._logger if hasattr(logger, \"_logger\") else logger\n    error_notify_handler = ErrorNotifyHandler(\n        main_config, module_name=\"main\", logger=main_logger\n    )\n    main_logger.addHandler(error_notify_handler)\n\n    manage_config(logger)\n    # Web mode: no modules passed\n    initial_run = True\n    waiting_message_shown = False\n\n    # Load main config once and reuse for scheduling reloads\n    main_cfg = Config(\"main\")\n\n    def on_schedule_change():\n        try:\n            main_cfg.load_config()\n            new_schedule = main_cfg.scheduler\n        except Exception as e:\n            logger.error(f\"[MAIN] Error reloading config: {e}\", exc_info=True)\n            return\n        nonlocal current_schedule\n        if new_schedule != current_schedule:\n            current_schedule = new_schedule\n            schedule_changed.set()\n\n    try:\n        current_schedule = main_cfg.scheduler\n    except Exception as e:\n        logger.error(f\"Error loading schedule: {e}\", exc_info=True)\n        sys.exit(1)\n    if not isinstance(current_schedule, dict):\n        print(f\"❌ Schedule is not a dictionary: {current_schedule}\")\n        sys.exit(1)\n\n    import atexit\n    import threading\n\n    schedule_changed = threading.Event()\n    observer = start_schedule_watcher(on_schedule_change)\n    atexit.register(observer.stop)\n    # Give the observer up to 2 seconds to finish\n    atexit.register(lambda: observer.join(timeout=2))\n    try:\n        from web.server import start_web_server\n\n        if main_config.update_notifications:\n            start_version_check(main_config, logger, interval=3600)\n        start_web_server(logger)\n\n        manager = ModuleManager(logger)\n        # Expose the ModuleManager to the web server for status/cancel of scheduled tasks\n        import web.server\n\n        web.server.app.state.manager = manager\n\n        while True:\n            if initial_run or schedule_changed.is_set():\n                print_schedule(logger, current_schedule)\n                logger.debug(\n                    f\"📋 Current schedule contents:\\n{json.dumps(current_schedule, indent=4)}\"\n                )\n                schedule_changed.clear()\n                initial_run = False\n                waiting_message_shown = False\n\n            if not waiting_message_shown:\n                logger.info(\"[SCHEDULE] Waiting for scheduled modules...\")\n                waiting_message_shown = True\n\n            for module_name, schedule_time in current_schedule.items():\n\n                if manager.is_already_running(module_name) or not schedule_time:\n                    continue\n\n                if module_name in list_of_python_modules:\n                    manager.run_if_due(\n                        module_name, schedule_time, check_schedule, run_module\n                    )\n\n            manager.cleanup()\n\n            time.sleep(5)\n\n    except KeyboardInterrupt:\n        logger.info(\"Keyboard Interrupt detected. Shutting DAPS down...\")\n        sys.exit()\n\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "modules/__init__.py",
    "content": ""
  },
  {
    "path": "modules/border_replacerr.py",
    "content": "import filecmp\nimport logging\nimport os\nimport shutil\nimport sys\nfrom datetime import datetime\nfrom types import SimpleNamespace\nfrom typing import Any, Dict, List, Optional, Tuple, Union\n\nfrom util.assets import get_assets_files\nfrom util.logger import Logger\nfrom util.utility import create_table, get_log_dir, print_json, print_settings, progress\n\ntry:\n    from PIL import Image, UnidentifiedImageError\nexcept ImportError as e:\n    print(f\"ImportError: {e}\")\n    print(\"Please install the required modules with 'pip install -r requirements.txt'\")\n    exit(1)\n\nlogging.getLogger(\"PIL\").setLevel(logging.WARNING)\n\n\ndef load_last_run(log_dir: str, logger: Logger = None) -> Optional[datetime]:\n    \"\"\"\n    Load the last run timestamp from the .last_run file in the log directory.\n    \"\"\"\n    try:\n        last_run_file = os.path.join(log_dir, \".last_run\")\n\n        if os.path.exists(last_run_file):\n            with open(last_run_file, \"r\") as f:\n                ts = f.read().strip()\n                try:\n                    return datetime.fromisoformat(ts)\n                except Exception:\n                    pass\n    except Exception as e:\n        logger.error(f\"Failed to read file: {e}\")\n        return None\n\n\ndef save_last_run(log_dir: str, dt: Optional[datetime] = None, logger: Logger = None) -> None:\n    \"\"\"\n    Save the current timestamp (or provided datetime) to the .last_run file in the log directory.\n    \"\"\"\n    try:\n        last_run_file = os.path.join(log_dir, \".last_run\")\n        now = dt or datetime.now()\n        with open(last_run_file, \"w\") as f:\n            f.write(now.isoformat())\n    except Exception as e:\n        logger.error(f\"Failed to write file: {e}\")\n\n\ndef check_holiday(\n    config: SimpleNamespace, logger: Logger, last_run: Optional[datetime] = None\n) -> Tuple[bool, Optional[List[str]], Dict[str, bool]]:\n    \"\"\"\n    Determines if today falls within a holiday schedule and returns applicable border colors and switch flags.\n    Now supports modules run less than daily by checking if a holiday started or ended since the last run.\n\n    Args:\n        config (SimpleNamespace): Configuration object containing holidays.\n        logger (Logger): Logger instance for logging messages.\n        last_run (Optional[datetime]): Timestamp of last module run.\n\n    Returns:\n        Tuple[bool, Optional[List[str]], Dict[str, bool]]:\n            - True if today is a holiday, else False.\n            - List of border colors if a holiday is active, else None.\n            - Dictionary indicating if a holiday started or ended since last run.\n    \"\"\"\n    holiday_switch: Dict[str, bool] = {\n        \"start_since_last_run\": False,\n        \"end_since_last_run\": False,\n    }\n    now = datetime.now()\n    for holiday, schedule_color in config.holidays.items():\n        schedule = schedule_color.get(\"schedule\")\n        if not schedule or not schedule.startswith(\"range(\"):\n            continue\n        inside = schedule[len(\"range(\") : -1]\n        start_str, end_str = inside.split(\"-\", 1)\n        sm, sd = map(int, start_str.split(\"/\"))\n        em, ed = map(int, end_str.split(\"/\"))\n        year = now.year\n        # Handle ranges that cross the year boundary\n        start_date = datetime(year, sm, sd)\n        end_date = datetime(year, em, ed)\n        if end_date < start_date:\n            # Range crosses new year\n            if now.month < sm:\n                start_date = start_date.replace(year=year - 1)\n            else:\n                end_date = end_date.replace(year=year + 1)\n        # Check if holiday started or ended since last run\n        if last_run:\n            if start_date > last_run and start_date <= now:\n                holiday_switch[\"start_since_last_run\"] = True\n            if end_date > last_run and end_date <= now:\n                holiday_switch[\"end_since_last_run\"] = True\n        else:\n            # On first run, treat as start if within holiday\n            if start_date <= now <= end_date:\n                holiday_switch[\"start_since_last_run\"] = True\n        # Is today inside the holiday range?\n        if start_date <= now <= end_date:\n            holiday_colors = schedule_color.get(\"color\", config.border_colors)\n            if isinstance(holiday_colors, str):\n                holiday_colors = [holiday_colors]\n            logger.info(create_table([[f\"Running {holiday.capitalize()} Schedule\"]]))\n            logger.info(\n                f\"Schedule: {holiday.capitalize()} | Using {', '.join(holiday_colors)} border colors.\"\n            )\n            return True, holiday_colors, holiday_switch\n    return False, None, holiday_switch\n\n\ndef convert_to_rgb(hex_color: str, logger: Logger) -> Tuple[int, int, int]:\n    \"\"\"\n    Converts a hexadecimal color string to an RGB tuple.\n\n    Args:\n        hex_color (str): Hexadecimal color string.\n        logger (Logger): Logger instance for logging errors.\n\n    Returns:\n        Tuple[int, int, int]: RGB color tuple.\n    \"\"\"\n    hex_color = hex_color.strip(\"#\")\n    if len(hex_color) == 3:\n        hex_color = hex_color * 2\n    try:\n        color_code = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))\n    except ValueError:\n        logger.error(\n            f\"Error: {hex_color} is not a valid hexadecimal color code.\\nDefaulting to white.\"\n        )\n        return (255, 255, 255)\n    return color_code\n\n\ndef fix_borders(\n    assets_dict: Union[List[Dict[str, Any]], Dict[str, List[Dict[str, Any]]]],\n    config: SimpleNamespace,\n    border_colors: Optional[List[str]],\n    destination_dir: str,\n    dry_run: bool,\n    logger: Logger,\n    exclusion_list: Optional[List[str]],\n) -> List[str]:\n    \"\"\"\n    Processes image assets and applies or removes borders based on configuration.\n\n    Args:\n        assets_dict (Dict[str, List[Dict[str, Any]]]): Dictionary of assets categorized by type.\n        config (SimpleNamespace): Module configuration.\n        border_colors (Optional[List[str]]): List of border colors to use.\n        destination_dir (str): Target output directory.\n        dry_run (bool): If True, simulate changes without saving.\n        logger (Logger): Logger instance for logging messages.\n        exclusion_list (Optional[List[str]]): List of items to exclude from processing.\n\n    Returns:\n        List[str]: Status messages for each processed asset.\n    \"\"\"\n    # Support flat list or grouped dict by type\n    if isinstance(assets_dict, list):\n        grouped: Dict[str, List[Dict[str, Any]]] = {}\n        for asset in assets_dict:\n            asset_type = asset.get(\"type\")\n            grouped.setdefault(asset_type, []).append(asset)\n        assets_dict = grouped\n    rgb_border_colors: List[Tuple[int, int, int]] = []\n    if border_colors:\n        for color in border_colors:\n            rgb_color = convert_to_rgb(color, logger)\n            rgb_border_colors.append(rgb_color)\n    if not border_colors:\n        action = \"Removed border\"\n        banner = \"Removing Borders\"\n    else:\n        action = \"Replaced border\"\n        banner = \"Replacing Borders\"\n    if action:\n        table = [\n            [f\"{banner}\"],\n        ]\n        logger.info(create_table(table))\n    messages: List[str] = []\n    for key, items in assets_dict.items():\n        current_index = 0\n        if not items:\n            logger.info(f\"No {key} found in the input directory\")\n            continue\n        with progress(\n            items,\n            desc=f\"Processing {key.capitalize()}\",\n            total=len(items),\n            unit=\" items\",\n            logger=logger,\n            leave=True,\n        ) as pbar:\n            for data in pbar:\n                files = data.get(\"files\", None)\n                year = data.get(\"year\", None)\n                folder = data.get(\"folder\", None)\n                if year:\n                    year_str = f\"({year})\"\n                else:\n                    year_str = \"\"\n                excluded = False\n                if exclusion_list and f\"{data['title']} {year_str}\" in exclusion_list:\n                    excluded = True\n                    logger.debug(f\"Excluding {data['title']} {year_str}\")\n                # Prepare output directory for saving processed files\n                output_path = destination_dir\n                for input_file in files:\n                    file_name, extension = os.path.splitext(input_file)\n                    if extension not in [\n                        \".jpg\",\n                        \".png\",\n                        \".jpeg\",\n                        \".JPG\",\n                        \".PNG\",\n                        \".JPEG\",\n                    ]:\n                        logger.warning(\n                            f\"Skipping {input_file} as it is not a jpg or png file.\"\n                        )\n                        continue\n                    file_name = os.path.basename(input_file)\n                    if rgb_border_colors:\n                        rgb_border_color = rgb_border_colors[current_index]\n                    else:\n                        rgb_border_color = None\n                    if not dry_run:\n                        if rgb_border_color:\n                            results = replace_borders(\n                                input_file,\n                                output_path,\n                                rgb_border_color,\n                                config.border_width,\n                                folder,\n                                logger,\n                            )\n                        else:\n                            results = remove_borders(\n                                input_file,\n                                output_path,\n                                config.border_width,\n                                logger,\n                                excluded,\n                                folder,\n                            )\n                        if results:\n                            messages.append(f\"{action} on {file_name}\")\n                    else:\n                        messages.append(f\"Would have {action} on {file_name}\")\n                    if rgb_border_colors:\n                        current_index = (current_index + 1) % len(rgb_border_colors)\n            pbar.update(1)\n    return messages\n\n\ndef replace_borders(\n    input_file: str,\n    output_path: str,\n    border_colors: Tuple[int, int, int],\n    border_width: int,\n    folder: Optional[str],\n    logger: Logger,\n) -> bool:\n    \"\"\"\n    Removes the existing border and applies a new one with the specified color.\n\n    Args:\n        input_file (str): Path to the input image file.\n        output_path (str): Path to save the processed image.\n        border_colors (Tuple[int, int, int]): RGB color for the new border.\n        border_width (int): Width of the border to apply.\n        folder (Optional[str]): Subfolder to organize output files.\n        logger (Logger): Logger instance for logging messages.\n\n    Returns:\n        bool: True if the file was saved or updated; False otherwise.\n    \"\"\"\n    try:\n        with Image.open(input_file) as image:\n            width, height = image.size\n            # Remove border by cropping\n            cropped_image = image.crop(\n                (\n                    border_width,\n                    border_width,\n                    width - border_width,\n                    height - border_width,\n                )\n            )\n            # Add border by expanding the canvas\n            new_width = cropped_image.width + 2 * border_width\n            new_height = cropped_image.height + 2 * border_width\n            final_image = Image.new(\"RGB\", (new_width, new_height), border_colors)\n            final_image.paste(cropped_image, (border_width, border_width))\n            file_name = os.path.basename(input_file)\n            if folder:\n                final_path = f\"{output_path}/{folder}/{file_name}\"\n            else:\n                final_path = f\"{output_path}/{file_name}\"\n            final_image = final_image.resize((1000, 1500)).convert(\"RGB\")\n            if os.path.isfile(final_path):\n                # Only save if the file is different to avoid unnecessary overwrites\n                tmp_path = f\"/tmp/{file_name}\"\n                final_image.save(tmp_path)\n                if not filecmp.cmp(final_path, tmp_path):\n                    final_image.save(final_path)\n                    os.remove(tmp_path)\n                    return True\n                else:\n                    os.remove(tmp_path)\n                    return False\n            else:\n                if not os.path.exists(os.path.dirname(final_path)):\n                    os.makedirs(os.path.dirname(final_path), exist_ok=True)\n                final_image.save(final_path)\n                return True\n    except UnidentifiedImageError as e:\n        logger.error(f\"Error: {e}\")\n        logger.error(f\"Error processing {input_file}\")\n        return False\n    except Exception as e:\n        logger.error(f\"Error: {e}\")\n        logger.error(f\"Error processing {input_file}\")\n        return False\n\n\ndef remove_borders(\n    input_file: str,\n    output_path: str,\n    border_width: int,\n    logger: Logger,\n    exclude: bool,\n    folder: Optional[str],\n) -> bool:\n    \"\"\"\n    Crops an image to remove its borders and optionally adds a black bottom border.\n\n    Args:\n        input_file (str): Path to the input image file.\n        output_path (str): Path to save the processed image.\n        border_width (int): Width of the border to remove.\n        logger (Logger): Logger instance for logging messages.\n        exclude (bool): If True, remove all borders; if False, add black bottom border.\n        folder (Optional[str]): Subfolder to organize output files.\n\n    Returns:\n        bool: True if the file was saved or updated; False otherwise.\n    \"\"\"\n    try:\n        with Image.open(input_file) as image:\n            width, height = image.size\n            if not exclude:\n                # Remove top, left, right borders, add black bottom border\n                final_image = image.crop(\n                    (border_width, border_width, width - border_width, height)\n                )\n                bottom_border = Image.new(\n                    \"RGB\", (width - 2 * border_width, border_width), color=\"black\"\n                )\n                bottom_border_position = (0, height - border_width - border_width)\n                final_image.paste(bottom_border, bottom_border_position)\n            else:\n                # Remove all borders\n                final_image = image.crop(\n                    (\n                        border_width,\n                        border_width,\n                        width - border_width,\n                        height - border_width,\n                    )\n                )\n            final_image = final_image.resize((1000, 1500)).convert(\"RGB\")\n            file_name = os.path.basename(input_file)\n            if folder:\n                final_path = f\"{output_path}/{folder}/{file_name}\"\n            else:\n                final_path = f\"{output_path}/{file_name}\"\n            if os.path.isfile(final_path):\n                tmp_path = f\"/tmp/{file_name}\"\n                final_image.save(tmp_path)\n                if not filecmp.cmp(final_path, tmp_path):\n                    final_image.save(final_path)\n                    os.remove(tmp_path)\n                    return True\n                else:\n                    os.remove(tmp_path)\n                    return False\n            else:\n                if not os.path.exists(os.path.dirname(final_path)):\n                    os.makedirs(os.path.dirname(final_path), exist_ok=True)\n                final_image.save(final_path)\n                return True\n    except UnidentifiedImageError as e:\n        logger.error(f\"Error: {e}\")\n        logger.error(f\"Error processing {input_file}\")\n        return False\n    except Exception as e:\n        logger.error(f\"Error: {e}\")\n        logger.error(f\"Error processing {input_file}\")\n        return False\n\n\ndef copy_files(\n    assets_dict: Dict[str, List[Dict[str, Any]]],\n    destination_dir: str,\n    dry_run: bool,\n    logger: Logger,\n) -> List[str]:\n    \"\"\"\n    Copies asset files from the input to the output directory with change detection.\n\n    Args:\n        assets_dict (Dict[str, List[Dict[str, Any]]]): Dictionary of asset data.\n        destination_dir (str): Path to the output directory.\n        dry_run (bool): Whether to simulate copying without actual file write.\n        logger (Logger): Logger instance for logging.\n\n    Returns:\n        List[str]: A list of copy operations performed or simulated.\n    \"\"\"\n    messages: List[str] = []\n    if destination_dir.endswith(\"/\"):\n        destination_dir = destination_dir.rstrip(\"/\")\n    asset_types = [\"movies\", \"series\", \"collections\"]\n    for asset_type in asset_types:\n        if asset_type in assets_dict:\n            items = assets_dict[asset_type]\n            with progress(\n                items,\n                desc=f\"Processing {asset_type.capitalize()}\",\n                total=len(items),\n                unit=\" items\",\n                logger=logger,\n                leave=True,\n            ) as pbar:\n                for data in pbar:\n                    files = data.get(\"files\", None)\n                    year = data.get(\"year\", None)\n                    if year:\n                        year_str = f\"({year})\"\n                    else:\n                        year_str = \"\"\n                    output_path = destination_dir\n                    for input_file in files:\n                        file_name, extension = os.path.splitext(input_file)\n                        if extension not in [\n                            \".jpg\",\n                            \".png\",\n                            \".jpeg\",\n                            \".JPG\",\n                            \".PNG\",\n                            \".JPEG\",\n                        ]:\n                            logger.warning(\n                                f\"Skipping {input_file} as it is not a jpg or png file.\"\n                            )\n                            continue\n                        file_name = os.path.basename(input_file)\n                        final_path = f\"{output_path}/{file_name}\"\n                        output_basename = os.path.basename(output_path)\n                        if not dry_run:\n                            if os.path.isfile(final_path):\n                                if not filecmp.cmp(final_path, input_file):\n                                    try:\n                                        shutil.copy(input_file, final_path)\n                                    except shutil.SameFileError:\n                                        logger.debug(\n                                            f\"Input file {input_file} is the same as {final_path}, skipping\"\n                                        )\n                                    logger.debug(\n                                        f\"Input file {input_file} is different from {final_path}, copying to {output_basename}\"\n                                    )\n                                    messages.append(\n                                        f\"Copied {data['title']}{year_str} - {file_name} to {output_basename}\"\n                                    )\n                            else:\n                                try:\n                                    shutil.copy(input_file, final_path)\n                                except shutil.SameFileError:\n                                    logger.debug(\n                                        f\"Input file {input_file} is the same as {final_path}, skipping\"\n                                    )\n                                logger.debug(\n                                    f\"Input file {input_file} does not exist in {output_path}, copying to {output_basename}\"\n                                )\n                                messages.append(\n                                    f\"Copied {data['title']}{year_str} - {file_name} to {output_basename}\"\n                                )\n                        else:\n                            messages.append(\n                                f\"Would have copied {data['title']}{year_str} - {file_name} to {output_basename}\"\n                            )\n                pbar.update(1)\n    return messages\n\n\ndef process_files(\n    source_dirs: str,\n    config: SimpleNamespace,\n    logger: Optional[Logger] = None,\n    renamerr_config: Optional[SimpleNamespace] = None,\n    renamed_assets: Optional[Dict[str, Any]] = None,\n    incremental_run: Optional[bool] = False,\n) -> None:\n    \"\"\"Main processor for applying or removing borders to media assets.\"\"\"\n    logger = Logger(config.log_level, config.module_name)\n\n    def no_assets_exit():\n        logger.info(\"\\nNo assets found in the input directory\")\n        logger.info(\"Please check the input directory and try again.\")\n        logger.info(\"Exiting...\")\n        return\n\n    log_dir = get_log_dir(config.module_name)\n    last_run = load_last_run(log_dir, logger=logger)\n    if config.log_level.lower() == \"debug\":\n        print_settings(logger, config)\n\n    # Set key variables from config or renamerr_config\n    merged = renamerr_config if renamerr_config else config\n    dry_run = getattr(merged, \"dry_run\", False)\n    destination_dir = getattr(merged, \"destination_dir\", None)\n    if not os.path.exists(destination_dir):\n        logger.error(f\"Output directory {destination_dir} does not exist.\")\n        return\n\n    # Get border colors and schedule\n    border_colors = None\n    run_holiday = False\n    switch = {\"start_since_last_run\": False, \"end_since_last_run\": False}\n    if getattr(config, \"holidays\", None):\n        run_holiday, border_colors, switch = check_holiday(\n            config, logger, last_run=last_run\n        )\n    if not border_colors:\n        border_colors = getattr(config, \"border_colors\", None)\n\n    # Get and group assets\n    def group_assets(assets):\n        if isinstance(assets, list):\n            grouped = {}\n            for asset in assets:\n                asset_type = asset.get(\"type\")\n                grouped.setdefault(asset_type, []).append(asset)\n            return grouped\n        return assets\n\n    if (\n        renamed_assets is None\n        or switch[\"start_since_last_run\"]\n        or switch[\"end_since_last_run\"]\n    ):\n        assets_dict, _ = get_assets_files(source_dirs, logger)\n        assets_dict = group_assets(assets_dict)\n    elif renamed_assets and incremental_run:\n        assets_dict = group_assets(renamed_assets)\n    else:\n        assets_dict = None\n\n    if not assets_dict or not any(assets_dict.values()):\n        return no_assets_exit()\n\n    # Just copy files if not scheduled to run\n    if not run_holiday and getattr(config, \"skip\", False):\n        messages = copy_files(assets_dict, destination_dir, dry_run, logger)\n        logger.info(\n            f\"Skipping {config.module_name} as it is not scheduled to run today.\"\n        )\n        if messages:\n            logger.info(create_table([[\"Processed Files\", f\"{len(messages)}\"]]))\n            for message in messages:\n                logger.info(message)\n        return\n\n    if destination_dir.endswith(\"/\"):\n        destination_dir = destination_dir[:-1]\n\n    # Border logic\n    if not border_colors:\n        border_args = None\n        action_label = \"removed border\"\n        logger.info(\"No border colors set, removing borders from assets.\")\n        logger.info(\"Executing border removal mode (removing all borders from assets).\")\n    else:\n        border_args = border_colors\n        action_label = \"replaced border\"\n        logger.info(f\"Using {', '.join(border_colors)} border color(s).\")\n        logger.info(\n            \"Executing border replacement mode (replacing borders with configured colors).\"\n        )\n\n    messages = fix_borders(\n        assets_dict,\n        config,\n        border_args,\n        destination_dir,\n        dry_run,\n        logger,\n        getattr(config, \"exclusion_list\", None),\n    )\n\n    if messages:\n        logger.info(create_table([[\"Processed Files\", f\"{len(messages)}\"]]))\n        logger.info(print_output(assets_dict, action_label, dry_run))\n    else:\n        logger.info(\"\\nNo files processed\")\n    if config.log_level == \"debug\":\n        print_json(assets_dict, logger, config.module_name, \"assets_dict\")\n        print_json(messages, logger, config.module_name, \"messages\")\n    save_last_run(log_dir, logger=logger)\n\n\n# --- Formatting function for border actions output ---\ndef print_output(\n    assets_dict: Dict[str, List[Dict[str, Any]]], action: str, dry_run: bool\n) -> str:\n    \"\"\"\n    Groups output by asset title and lists all processed files per asset.\n\n    Args:\n        assets_dict: Dictionary grouped by type, each with a list of asset dicts.\n        action: 'Removed border' or 'Replaced border'.\n        dry_run: If True, prefix with 'Would have'.\n\n    Returns:\n        str: Formatted output for logging or CLI.\n    \"\"\"\n    output_lines = []\n    for asset_type, assets in assets_dict.items():\n        for asset in assets:\n            title = asset.get(\"title\")\n            year = asset.get(\"year\")\n            if year:\n                display = f\"{title} ({year})\"\n            else:\n                display = f\"{title}\"\n            prefix = f\"Would have {action} on\" if dry_run else f\"{action} on\"\n            output_lines.append(f\"{prefix} '{display}'\")\n            files = asset.get(\"files\", [])\n            for file_path in files:\n                file_name = os.path.basename(file_path)\n                output_lines.append(f\"    {file_name}\")\n    return \"\\n\".join(output_lines)\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"\n    Entry point for running the border replacer module.\n\n    Args:\n        config (SimpleNamespace): Main configuration object.\n    \"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    try:\n        process_files(\n            config.source_dirs,\n            config,\n            logger=logger,\n            renamed_assets=None,\n            renamerr_config=None,\n        )\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/health_checkarr.py",
    "content": "import json\nimport re\nimport sys\nfrom types import SimpleNamespace\nfrom typing import Any, Dict, List, Optional\n\nfrom tqdm import tqdm\n\nfrom util.arrpy import create_arr_client\nfrom util.constants import tmdb_id_regex, tvdb_id_regex\nfrom util.logger import Logger\nfrom util.notification import send_notification\nfrom util.utility import create_table, print_settings, progress\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"\n    Process Radarr and Sonarr instances to identify and delete media items flagged by health checks\n    as removed from TMDB or TVDB. Supports dry run mode and logs all actions.\n\n    Args:\n        config (SimpleNamespace): Configuration object containing:\n            - log_level (str): Logging verbosity level.\n            - module_name (str): Name of the module for logging context.\n            - dry_run (bool): If True, no deletions are performed.\n            - instances_config (Dict[str, Dict[str, Dict[str, str]]]): Configuration for each instance type and instance.\n            - instances (List[str]): List of instance names to process.\n\n    Behavior:\n        - Iterates over configured Radarr and Sonarr instances.\n        - Retrieves health check data and media libraries.\n        - Identifies media items flagged as removed.\n        - Deletes flagged media items unless dry run is enabled.\n        - Sends notifications about deleted items.\n        - Logs all key steps and errors.\n    \"\"\"\n    logger: Logger = Logger(config.log_level, config.module_name)\n\n    try:\n        # Display configuration settings if in debug mode\n        if config.log_level.lower() == \"debug\":\n            print_settings(logger, config)\n\n        # Print dry run notice if enabled\n        if config.dry_run:\n            table: List[List[str]] = [[\"Dry Run\"], [\"NO CHANGES WILL BE MADE\"]]\n            logger.info(create_table(table))\n            logger.info(\"\")\n\n        # Iterate over each instance type (radarr/sonarr)\n        for instance_type, instance_data in config.instances_config.items():\n            # Iterate over each configured instance for this type\n            for instance in config.instances:\n                if instance in instance_data:\n                    # Create client for the current instance\n                    app: Optional[Any] = create_arr_client(\n                        instance_data[instance][\"url\"],\n                        instance_data[instance][\"api\"],\n                        logger,\n                    )\n                    if app and app.connect_status:\n                        # Retrieve health check warnings\n                        health: Optional[List[Dict[str, Any]]] = app.get_health()\n\n                        # Retrieve current media library without episode details\n                        media_dict: List[Dict[str, Any]] = app.get_parsed_media(\n                            include_episode=False\n                        )\n\n                        id_list: List[int] = []\n\n                        # Parse health check messages for removed media IDs\n                        if health:\n                            for health_item in health:\n                                if (\n                                    health_item[\"source\"] == \"RemovedMovieCheck\"\n                                    or health_item[\"source\"] == \"RemovedSeriesCheck\"\n                                ):\n                                    if instance_type == \"radarr\":\n                                        for m in re.finditer(\n                                            tmdb_id_regex, health_item[\"message\"]\n                                        ):\n                                            id_list.append(int(m.group(1)))\n                                    if instance_type == \"sonarr\":\n                                        for m in re.finditer(\n                                            tvdb_id_regex, health_item[\"message\"]\n                                        ):\n                                            id_list.append(int(m.group(1)))\n\n                            logger.debug(f\"id_list:\\n{json.dumps(id_list, indent=4)}\")\n\n                            output: List[Dict[str, Any]] = []\n\n                            # Match health-check IDs with media library entries\n                            with progress(\n                                media_dict,\n                                desc=f\"Processing {instance_type}\",\n                                unit=\"items\",\n                                logger=logger,\n                                leave=True,\n                            ) as pbar:\n                                for item in pbar:\n                                    if (\n                                        instance_type == \"radarr\"\n                                        and item[\"tmdb_id\"] in id_list\n                                    ) or (\n                                        instance_type == \"sonarr\"\n                                        and item[\"tvdb_id\"] in id_list\n                                    ):\n                                        db_id = (\n                                            item[\"tmdb_id\"]\n                                            if instance_type == \"radarr\"\n                                            else item[\"tvdb_id\"]\n                                        )\n                                        logger.debug(\n                                            f\"Found {item['title']} with: {db_id}\"\n                                        )\n                                        output.append(item)\n\n                            logger.debug(f\"output:\\n{json.dumps(output, indent=4)}\")\n\n                            if output:\n                                logger.info(\n                                    f\"Deleting {len(output)} {instance_type} items from {app.instance_name}\"\n                                )\n\n                                # Delete each matched item unless dry run is enabled\n                                for item in tqdm(\n                                    output,\n                                    desc=f\"Deleting {instance_type} items\",\n                                    unit=\"items\",\n                                    disable=None,\n                                    total=len(output),\n                                ):\n                                    if not config.dry_run:\n                                        logger.info(\n                                            f\"{item['title']} deleted with id: {item['media_id']} and tvdb/tmdb id: {item['db_id']}\"\n                                        )\n                                        app.delete_media(item[\"media_id\"])\n                                    else:\n                                        logger.info(\n                                            f\"{item['title']} would have been deleted with id: {item['media_id']}\"\n                                        )\n\n                                # Send notification with deleted items\n                                send_notification(\n                                    logger=logger,\n                                    module_name=config.module_name,\n                                    config=config,\n                                    output=output,\n                                )\n                        else:\n                            logger.info(\n                                f\"No health data returned for {app.instance_name}, this is fine if there was nothing to delete. Skipping deletion checks.\"\n                            )\n\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/jduparr.py",
    "content": "import os\nimport subprocess\nimport sys\nfrom types import SimpleNamespace\n\nfrom util.logger import Logger\nfrom util.notification import send_notification\nfrom util.utility import create_table, print_settings\n\n\ndef print_output(output: list[dict], logger: Logger) -> None:\n    \"\"\"\n    Print the results of the duplicate file search and linking process.\n\n    Args:\n        output (list[dict]): List of dictionaries containing path, message, files, and counts.\n        logger (Logger): Logger instance to output messages.\n    \"\"\"\n    count = 0\n    for item in output:\n        path = item.get(\"source_dir\")\n        field_message = item.get(\"field_message\")\n        files = item.get(\"output\")\n        sub_count = item.get(\"sub_count\")\n\n        logger.info(f\"Findings for path: {path}\")\n        logger.info(f\"\\t{field_message}\")\n        for i in files:\n            count += 1\n            logger.info(f\"\\t\\t{i}\")\n        count += sub_count\n        logger.info(\n            f\"\\tTotal items for '{os.path.basename(os.path.normpath(path))}': {sub_count}\"\n        )\n    logger.info(f\"Total items relinked: {count}\")\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"\n    Main execution function for identifying and hardlinking duplicate media files using jdupes.\n\n    Args:\n        config (SimpleNamespace): Configuration object containing source directories, logging, and other settings.\n\n    Returns:\n        None\n    \"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    results = None\n    try:\n        # If dry run, display a notice table\n        if config.dry_run:\n            table = [[\"Dry Run\"], [\"NO CHANGES WILL BE MADE\"]]\n            logger.info(create_table(table))\n\n        output = []\n\n        # Iterate over each source directory to find duplicates\n        if not config.source_dirs:\n            logger.error(\n                f\"No source directories provided in config: {config.source_dirs}\"\n            )\n            return\n        for path in config.source_dirs:\n            if config.log_level.lower() == \"debug\":\n                print_settings(logger, config)\n\n            if not os.path.isdir(path):\n                logger.error(f\"ERROR: path does not exist: {path}\")\n                return\n\n            # Run jdupes to find duplicate media files with specified extensions\n            result = subprocess.getoutput(\n                f\"jdupes -r -M -X onlyext:mp4,mkv,avi '{path}' 2>/dev/null\"\n            )\n\n            # If not dry run and duplicates found, hardlink duplicates\n            if not config.dry_run:\n                if \"No duplicates found.\" not in result:\n                    subprocess.run(\n                        f\"jdupes -r -L -X onlyext:mp4,mkv,avi '{path}' 2>/dev/null\",\n                        shell=True,\n                    )\n\n            # Parse filenames from jdupes output\n            parsed_files = sorted(\n                set(line.split(\"/\")[-1] for line in result.splitlines() if \"/\" in line)\n            )\n            field_message = (\n                \"✅ No unlinked files discovered...\"\n                if not parsed_files\n                else \"❌ Unlinked files discovered...\"\n            )\n            sub_count = len(parsed_files)\n\n            output_data = {\n                \"source_dir\": path,\n                \"field_message\": field_message,\n                \"output\": parsed_files,\n                \"sub_count\": sub_count,\n            }\n            output.append(output_data)\n        if results:\n            logger.debug(f\"jdupes output: {result}\")\n            logger.debug(f\"Parsed log: {parsed_files}\")\n\n        # Print summarized output and send notification\n        print_output(output, logger)\n        send_notification(logger, config.module_name, config, output)\n\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"An error occurred:\", exc_info=True)\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/labelarr.py",
    "content": "import sys\nfrom collections import defaultdict\nfrom types import SimpleNamespace\nfrom typing import Dict, List, Optional\n\nfrom util.arrpy import BaseARRClient, create_arr_client\nfrom util.logger import Logger\nfrom util.normalization import normalize_titles\nfrom util.notification import send_notification\nfrom util.utility import create_table, print_settings, progress\n\ntry:\n    from plexapi.exceptions import BadRequest\n    from plexapi.server import PlexServer\nexcept ImportError as e:\n    print(f\"ImportError: {e}\")\n    print(\"Please install the required modules with 'pip install -r requirements.txt'\")\n    exit(1)\n\n\ndef sync_to_plex(\n    plex: PlexServer,\n    labels: List[str],\n    media_dict: List[Dict],\n    app: BaseARRClient,\n    logger: Logger,\n    library_names: List[str],\n    config: SimpleNamespace,\n) -> List[Dict]:\n    \"\"\"\n    Synchronize label metadata between an ARR client and Plex libraries.\n\n    Args:\n        plex (PlexServer): Plex server instance.\n        labels (List[str]): List of label names to sync.\n        media_dict (List[Dict]): List of media entries from ARR.\n        app (BaseARRClient): ARR client instance (Radarr or Sonarr).\n        logger (Logger): Logger instance.\n        library_names (List[str]): Names of Plex libraries to process.\n        config (SimpleNamespace): Configuration object.\n\n    Returns:\n        List[Dict]: List of label changes applied or identified.\n    \"\"\"\n    tag_ids: Dict[str, Optional[int]] = {}\n    for label in labels:\n        tag_id = app.get_tag_id_from_name(label)\n        if tag_id:\n            tag_ids[label] = tag_id\n\n    # Create lookups\n    tmdb_imdb_lookup = {\n        (media.get(\"tmdb_id\"), media.get(\"imdb_id\")): media\n        for media in media_dict\n        if media.get(\"tmdb_id\") is not None and media.get(\"imdb_id\")\n    }\n    tvdb_imdb_lookup = {\n        (media.get(\"tvdb_id\"), media.get(\"imdb_id\")): media\n        for media in media_dict\n        if media.get(\"tvdb_id\") is not None and media.get(\"imdb_id\")\n    }\n    tmdb_lookup = {\n        media[\"tmdb_id\"]: media\n        for media in media_dict\n        if media.get(\"tmdb_id\") is not None\n    }\n    tvdb_lookup = {\n        media[\"tvdb_id\"]: media\n        for media in media_dict\n        if media.get(\"tvdb_id\") is not None\n    }\n    imdb_lookup = {\n        media[\"imdb_id\"]: media\n        for media in media_dict\n        if media.get(\"imdb_id\") is not None\n    }\n    fallback_lookup = {\n        (media[\"normalized_title\"], media[\"year\"]): media\n        for media in media_dict\n        if \"normalized_title\" in media and \"year\" in media\n    }\n\n    data_dict: List[Dict] = []\n\n    with progress(\n        library_names,\n        desc=\"Processing Libraries\",\n        unit=\"items\",\n        logger=logger,\n        leave=True,\n    ) as outer_pbar:\n        for library in outer_pbar:\n            library_data = plex.library.section(library).all()\n\n            with progress(\n                library_data,\n                desc=f\"Syncing labels between {app.instance_name.capitalize()} and {library}\",\n                unit=\"items\",\n                logger=logger,\n                leave=True,\n            ) as inner_pbar:\n                for library_item in inner_pbar:\n                    try:\n                        plex_item_labels = [\n                            label.tag.lower() for label in library_item.labels\n                        ]\n                    except AttributeError:\n                        logger.error(\n                            f\"Error fetching labels for {getattr(library_item, 'title', str(library_item))} (no labels)\"\n                        )\n                        continue\n\n                    # Safely extract IDs\n                    ids: Dict[str, Optional[str]] = {\n                        \"tmdb\": None,\n                        \"tvdb\": None,\n                        \"imdb\": None,\n                    }\n                    for guid in getattr(library_item, \"guids\", []):\n                        guid_str = getattr(guid, \"id\", \"\")\n                        if guid_str.startswith(\"tmdb://\"):\n                            ids[\"tmdb\"] = guid_str.split(\"tmdb://\")[1]\n                        elif guid_str.startswith(\"tvdb://\"):\n                            ids[\"tvdb\"] = guid_str.split(\"tvdb://\")[1]\n                        elif guid_str.startswith(\"imdb://\"):\n                            ids[\"imdb\"] = guid_str.split(\"imdb://\")[1]\n\n                    media_item: Optional[Dict] = None\n                    match_type: str = \"unknown\"\n\n                    # 1. Prefer TMDB+IMDB or TVDB+IMDB\n                    tmdb_id = ids.get(\"tmdb\")\n                    imdb_id = ids.get(\"imdb\")\n                    tvdb_id = ids.get(\"tvdb\")\n\n                    # Prefer TMDB+IMDB\n                    if tmdb_id and tmdb_id.isdigit() and imdb_id:\n                        key = (int(tmdb_id), imdb_id)\n                        media_item = tmdb_imdb_lookup.get(key)\n                        if media_item:\n                            match_type = f\"TMDB+IMDB MATCH: TMDB {tmdb_id} & IMDB {imdb_id}\"\n\n                    # Next try TVDB+IMDB\n                    if not media_item and tvdb_id and tvdb_id.isdigit() and imdb_id:\n                        key = (int(tvdb_id), imdb_id)\n                        media_item = tvdb_imdb_lookup.get(key)\n                        if media_item:\n                            match_type = f\"TVDB+IMDB MATCH: TVDB {tvdb_id} & IMDB {imdb_id}\"\n\n                    # 2. Fallback to just TMDB, TVDB, IMDB\n                    if not media_item and tmdb_id and tmdb_id.isdigit():\n                        media_item = tmdb_lookup.get(int(tmdb_id))\n                        if media_item:\n                            match_type = f\"TMDB MATCH: {tmdb_id}\"\n\n                    if not media_item and tvdb_id and tvdb_id.isdigit():\n                        media_item = tvdb_lookup.get(int(tvdb_id))\n                        if media_item:\n                            match_type = f\"TVDB MATCH: {tvdb_id}\"\n\n                    if not media_item and imdb_id:\n                        media_item = imdb_lookup.get(imdb_id)\n                        if media_item:\n                            match_type = f\"IMDB MATCH: {imdb_id}\"\n\n                    # 3. Final fallback to normalized title and year\n                    if not media_item:\n                        norm_title = normalize_titles(getattr(library_item, \"title\", \"\"))\n                        item_year = getattr(library_item, \"year\", None)\n                        if item_year is not None:\n                            key = (norm_title, item_year)\n                            media_item = fallback_lookup.get(key)\n                            if media_item:\n                                match_type = \"TITLE/YEAR MATCH\"\n                        else:\n                            logger.debug(\n                                f\"Skipping fallback match for '{getattr(library_item, 'title', str(library_item))}' as no 'year' attribute is present (likely not a movie/show item)\"\n                            )\n\n                    # 4. Only proceed if a real match was found\n                    if media_item:\n                        logger.debug(\n                            f\"Matched '{getattr(library_item, 'title', str(library_item))}' ({getattr(library_item, 'year', '-')}) using {match_type} to '{media_item.get('title', '-')}' ({media_item.get('year', '-')})\"\n                        )\n                        add_remove: Dict[str, str] = {}\n                        # Decide add/remove per label\n                        for tag, id in tag_ids.items():\n                            if tag not in plex_item_labels and id in media_item[\"tags\"]:\n                                add_remove[tag] = \"add\"\n                                if not config.dry_run:\n                                    library_item.addLabel(tag)\n                            elif (\n                                tag in plex_item_labels and id not in media_item[\"tags\"]\n                            ):\n                                add_remove[tag] = \"remove\"\n                                if not config.dry_run:\n                                    library_item.removeLabel(tag)\n\n                        if add_remove:\n                            data_dict.append(\n                                {\n                                    \"title\": getattr(library_item, \"title\", str(library_item)),\n                                    \"year\": getattr(library_item, \"year\", None),\n                                    \"add_remove\": add_remove,\n                                }\n                            )\n\n    return data_dict\n\n\ndef handle_messages(data_dict: List[Dict], logger: Logger) -> None:\n    \"\"\"\n    Log label changes from sync results in a grouped, readable format.\n\n    Args:\n        data_dict (List[Dict]): List of dictionaries containing sync results.\n        logger (Logger): Logger instance for output.\n    \"\"\"\n\n    table: List[List[str]] = [[\"Results\"]]\n    logger.info(create_table(table))\n\n    label_changes: Dict[tuple, List[str]] = defaultdict(list)\n\n    # Group media titles by label and action (add/remove)\n    for item in data_dict:\n        for label, action in item[\"add_remove\"].items():\n            key = (label, action)\n            label_changes[key].append(f\"{item['title']} ({item['year']})\")\n\n    # Log grouped label changes\n    for (label, action), items in label_changes.items():\n        verb = \"added to\" if action == \"add\" else \"removed from\"\n        logger.info(f\"\\nLabel: {label} has been {verb}:\")\n        for entry in items:\n            logger.info(f\"  - {entry}\")\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"\n    Main function to sync labels between Plex and Radarr/Sonarr based on configuration.\n\n    Args:\n        config (SimpleNamespace): Configuration object loaded from user settings.\n    \"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    try:\n        # Print detailed settings if debug logging is enabled\n        if config.log_level.lower() == \"debug\":\n            print_settings(logger, config)\n\n        # Notify user if running in dry run mode (no actual changes will be made)\n        if config.dry_run:\n            table = [[\"Dry Run\"], [\"NO CHANGES WILL BE MADE\"]]\n            logger.info(create_table(table))\n\n        output: List[Dict] = []\n\n        # Iterate over each mapping configured for syncing\n        for mapping in config.mappings:\n            app_type: str = mapping[\"app_type\"]\n            app_instance: Optional[str] = mapping.get(\"app_instance\")\n            labels: List[str] = mapping[\"labels\"]\n            plex_instances: List[Dict] = mapping[\"plex_instances\"]\n            app: Optional[BaseARRClient] = None\n            media_dict: List[Dict] = []\n\n            # Connect to the ARR client (Radarr or Sonarr) if specified\n            if app_type in [\"radarr\", \"sonarr\"] and app_instance:\n                app_config: Optional[Dict] = config.instances_config[app_type].get(\n                    app_instance\n                )\n                if not app_config:\n                    logger.error(\n                        f\"No config found for {app_type} instance '{app_instance}'\"\n                    )\n                    continue\n\n                app = create_arr_client(app_config[\"url\"], app_config[\"api\"], logger)\n                if not app or not app.connect_status:\n                    logger.error(\n                        f\"Failed to connect to {app_type} instance {app_instance}\"\n                    )\n                    continue\n\n                # Fetch parsed media list from ARR client, excluding episodes for Sonarr\n                if (\n                    hasattr(app, \"instance_type\")\n                    and app.instance_type.lower() == \"sonarr\"\n                ):\n                    media_dict = app.get_parsed_media(include_episode=False)\n                else:\n                    media_dict = app.get_parsed_media()\n\n                if not media_dict:\n                    logger.info(f\"No media found for {app_instance}\")\n                    continue\n\n            # Process each Plex instance and library associated with the mapping\n            if plex_instances:\n                for mapping_block in plex_instances:\n                    plex_instance: Optional[str] = mapping_block.get(\"instance\")\n                    library_names: List[str] = mapping_block.get(\"library_names\", [])\n\n                    if plex_instance not in config.instances_config.get(\"plex\", {}):\n                        logger.error(\n                            f\"No Plex instance found for {plex_instance}. Skipping...\\n\"\n                        )\n                        continue\n\n                    try:\n                        plex = PlexServer(\n                            config.instances_config[\"plex\"][plex_instance][\"url\"],\n                            config.instances_config[\"plex\"][plex_instance][\"api\"],\n                            timeout=180,\n                        )\n                        logger.info(f\"Connected to Plex instance '{plex.friendlyName}'\")\n\n                    except BadRequest:\n                        logger.error(\n                            f\"Error connecting to Plex instance: {plex_instance}\"\n                        )\n                        continue\n\n                    if library_names:\n                        label_str = \", \".join(labels)\n                        logger.info(\n                            f\"Syncing labels [{label_str}] from {app_type.capitalize()} instance '{app_instance}' to Plex instance '{plex_instance}'\"\n                        )\n                        # Collect changes from sync_to_plex and accumulate in output list\n                        data_dict = sync_to_plex(\n                            plex, labels, media_dict, app, logger, library_names, config\n                        )\n                        output.extend(data_dict)\n                    else:\n                        logger.error(\n                            f\"No library names provided for {plex_instance}. Skipping...\"\n                        )\n                        continue\n\n        # Log and send notifications if any label changes were found\n        if output:\n            handle_messages(output, logger)\n            # Only send notifications if not in dry run mode\n            send_notification(\n                logger=logger,\n                module_name=config.module_name,\n                config=config,\n                output=output,\n            )\n        else:\n            logger.info(\"No labels to sync to Plex\")\n\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/nohl.py",
    "content": "import os\nimport re\nimport sys\nfrom typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple\n\nfrom util.arrpy import create_arr_client\nfrom util.constants import (\n    episode_regex,\n    season_regex,\n    year_regex,\n)\nfrom util.logger import Logger\nfrom util.notification import send_notification\nfrom util.utility import (\n    create_table,\n    normalize_titles,\n    print_json,\n    print_settings,\n    progress,\n)\n\nVIDEO_EXTS = (\".mkv\", \".mp4\")\n\n\nif TYPE_CHECKING:\n    from util.arrpy import BaseARRClient\n\n\ndef find_nohl_files(\n    path: str, logger: Logger\n) -> Optional[Dict[str, List[Dict[str, Any]]]]:\n    \"\"\"\n    Find all video files in a directory tree that are not hardlinked.\n    Args:\n        path: Root directory to scan.\n        logger: Logger instance for debug output.\n    Returns:\n        Dictionary with non-hardlinked movies and series details.\n    \"\"\"\n    path_basename = os.path.basename(path.rstrip(\"/\"))\n    nohl_data: Dict[str, List[Dict[str, Any]]] = {\"movies\": [], \"series\": []}\n    logger.debug(f\"Scanning directory: {path}\")\n    try:\n        entries = [i for i in os.listdir(path) if os.path.isdir(os.path.join(path, i))]\n    except FileNotFoundError as e:\n        logger.error(f\"Error: {e}\")\n        return None\n    for item in progress(\n        entries,\n        desc=f\"Searching '{path_basename}'\",\n        unit=\"item\",\n        total=len(entries),\n        logger=logger,\n    ):\n        if item.startswith(\".\"):\n            continue\n        # Remove year from directory name for title\n        title = re.sub(year_regex, \"\", item)\n        try:\n            year = int(year_regex.search(item).group(1))\n        except AttributeError:\n            year = 0\n        asset_list: Dict[str, Any] = {\n            \"title\": title,\n            \"year\": year,\n            \"normalized_title\": normalize_titles(title),\n            \"root_path\": os.path.join(*path.rstrip(os.sep).split(os.sep)[-2:]),\n            \"path\": os.path.join(path, item),\n        }\n        item_path = os.path.join(path, item)\n        # Detect if this is a series (has subfolders) or a movie (just files)\n        if os.path.isdir(item_path) and any(\n            os.path.isdir(os.path.join(item_path, sub_folder))\n            for sub_folder in os.listdir(item_path)\n        ):\n            sub_folders = [\n                sub_folder\n                for sub_folder in os.listdir(item_path)\n                if os.path.isdir(os.path.join(item_path, sub_folder))\n                and not sub_folder.startswith(\".\")\n            ]\n            asset_list[\"season_info\"] = []\n            for sub_folder in sub_folders:\n                sub_folder_path = os.path.join(item_path, sub_folder)\n                sub_folder_files = [\n                    file\n                    for file in os.listdir(sub_folder_path)\n                    if os.path.isfile(os.path.join(sub_folder_path, file))\n                    and not file.startswith(\".\")\n                ]\n                season = re.search(season_regex, sub_folder)\n                try:\n                    season_number = int(season.group(1))\n                except AttributeError:\n                    season_number = 0\n                nohl_files = []\n                # Non-hardlink detection for each file in season folder\n                for file in sub_folder_files:\n                    if not file.endswith(VIDEO_EXTS):\n                        continue\n                    file_path = os.path.join(sub_folder_path, file)\n                    try:\n                        st = os.stat(file_path)\n                        if st.st_nlink == 1:\n                            nohl_files.append(file_path)\n                    except Exception:\n                        continue\n                if nohl_files:\n                    logger.debug(\n                        f\"Found {len(nohl_files)} non-hardlinked files in '{sub_folder_path}'\"\n                    )\n                episodes = []\n                # Extract episode numbers from non-hardlinked files\n                for file in nohl_files:\n                    try:\n                        episode_match = re.search(episode_regex, file)\n                        if episode_match is not None:\n                            episode = int(episode_match.group(1))\n                            episodes.append(episode)\n                    except Exception as e:\n                        logger.error(f\"{e}\")\n                        logger.error(f\"Error processing file: {file}.\")\n                        continue\n                season_list = {\n                    \"season_number\": season_number,\n                    \"episodes\": episodes,\n                    \"nohl\": nohl_files,\n                }\n                if nohl_files:\n                    asset_list[\"season_info\"].append(season_list)\n            # Only add if there are any non-hardlinked episodes in any season\n            if asset_list.get(\"season_info\") and any(\n                season[\"nohl\"] for season in asset_list[\"season_info\"]\n            ):\n                nohl_data[\"series\"].append(asset_list)\n        else:\n            files_path = item_path\n            files = [\n                file\n                for file in os.listdir(files_path)\n                if os.path.isfile(os.path.join(files_path, file))\n                and not file.startswith(\".\")\n            ]\n            nohl_files = []\n            # Non-hardlink detection for movie files\n            for file in files:\n                if not file.endswith(VIDEO_EXTS):\n                    continue\n                file_path = os.path.join(files_path, file)\n                try:\n                    st = os.stat(file_path)\n                    if st.st_nlink == 1:\n                        nohl_files.append(file_path)\n                except Exception:\n                    continue\n            if nohl_files:\n                logger.debug(\n                    f\"Found {len(nohl_files)} non-hardlinked files in '{item_path}'\"\n                )\n            asset_list[\"nohl\"] = nohl_files\n            if nohl_files:\n                nohl_data[\"movies\"].append(asset_list)\n    # Sort seasons and episodes numerically for each series\n    for series in nohl_data[\"series\"]:\n        if \"season_info\" in series:\n            series[\"season_info\"].sort(key=lambda s: int(s[\"season_number\"]))\n            for season in series[\"season_info\"]:\n                if \"episodes\" in season:\n                    season[\"episodes\"].sort(key=int)\n    return nohl_data\n\n\ndef handle_searches(\n    app: \"BaseARRClient\",\n    search_list: List[Dict[str, Any]],\n    instance_type: str,\n    logger: Logger,\n    config,\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Perform search and deletion actions for Radarr or Sonarr items.\n    Args:\n        app: ARR API client.\n        search_list: List of media dicts to search.\n        instance_type: \"radarr\" or \"sonarr\".\n        logger: Logger instance.\n        config: Config object.\n    Returns:\n        List of items that were searched.\n    \"\"\"\n    logger.debug(f\"Initiating search for {len(search_list)} items in {instance_type}\")\n    print(\"Searching for files... this may take a while.\")\n    searched_for: List[Dict[str, Any]] = []\n    searches = 0\n    for item in progress(\n        search_list,\n        desc=\"Searching...\",\n        unit=\"item\",\n        total=len(search_list),\n        logger=logger,\n    ):\n        if instance_type == \"radarr\":\n            # Radarr: delete file(s) and trigger search for the movie\n            if config.dry_run:\n                logger.info(\n                    f\"[Dry Run] Would search: {item['title']} ({item['year']}) and delete file IDs: {item['file_ids']}\"\n                )\n                searched_for.append(item)\n                searches += 1\n            else:\n                app.delete_movie_file(item[\"file_ids\"])\n                results = app.refresh_items(item[\"media_id\"])\n                ready = app.wait_for_command(results[\"id\"])\n                if ready:\n                    logger.debug(\n                        f\"Performing a Search for {item['media_id']} ({item['year']})\"\n                    )\n                    app.search_media(item[\"media_id\"])\n                    searched_for.append(item)\n                    searches += 1\n            logger.debug(f\"Searched: {item['title']} ({item['year']})\")\n        elif instance_type == \"sonarr\":\n            # Sonarr: for each season, trigger episode or season pack search\n            seasons = item.get(\"seasons\", [])\n            if seasons:\n                for season in seasons:\n                    season_pack = season[\"season_pack\"]\n                    file_ids = list(\n                        set(\n                            [\n                                episode[\"episode_file_id\"]\n                                for episode in season[\"episode_data\"]\n                            ]\n                        )\n                    )\n                    episode_ids = [\n                        episode[\"episode_id\"] for episode in season[\"episode_data\"]\n                    ]\n                    if season_pack:\n                        if config.dry_run:\n                            logger.info(\n                                f\"[Dry Run] Would search season: {season['season_number']} of {item['title']} ({item['year']})\"\n                            )\n                        else:\n                            app.delete_episode_files(file_ids)\n                            results = app.refresh_items(item[\"media_id\"])\n                            ready = app.wait_for_command(results[\"id\"])\n                            if ready:\n                                logger.debug(\n                                    f\"Performing a season search for {item['media_id']} ({item['year']}) Season Number: {season['season_number']}\"\n                                )\n                                app.search_season(\n                                    item[\"media_id\"], season[\"season_number\"]\n                                )\n                    else:\n                        if config.dry_run:\n                            episode_numbers = [\n                                ep[\"episode_number\"] for ep in season[\"episode_data\"]\n                            ]\n                            logger.info(\n                                f\"[Dry Run] Would search episodes: {episode_numbers} of {item['title']} ({item['year']})\"\n                            )\n                        else:\n                            app.delete_episode_files(file_ids)\n                            results = app.refresh_items(item[\"media_id\"])\n                            ready = app.wait_for_command(results[\"id\"])\n                            if ready:\n                                logger.debug(\n                                    f\"Performing an episode search for {item['title']} ({item['year']}), Episodes IDs: {episode_ids}\"\n                                )\n                                app.search_episodes(episode_ids)\n                searched_for.append(item)\n            logger.debug(f\"Searched: {item['title']} ({item['year']})\")\n    print(f\"Searches performed: {searches}\")\n    return searched_for\n\n\ndef filter_media(\n    app: \"BaseARRClient\",\n    media_dict: List[Dict[str, Any]],\n    nohl_data: List[Dict[str, Any]],\n    instance_type: str,\n    config,\n    logger: Logger,\n) -> Dict[str, List[Dict[str, Any]]]:\n    \"\"\"\n    Filter media to exclude items based on monitoring, exclusion lists, and quality profiles.\n    Args:\n        app: ARR client for Radarr/Sonarr.\n        media_dict: List of media items from ARR.\n        nohl_data: List of non-hardlinked media items.\n        instance_type: \"radarr\" or \"sonarr\".\n        config: Script configuration.\n        logger: Logger instance.\n    Returns:\n        Dict with 'search_media' and 'filtered_media' lists.\n    \"\"\"\n    logger.debug(\n        f\"Filtering {len(nohl_data)} nohl items against {len(media_dict)} media items from {instance_type}\"\n    )\n    quality_profiles = app.get_quality_profile_names()\n    exclude_profile_ids = []\n    if config.exclude_profiles:\n        for profile in config.exclude_profiles:\n            if profile in quality_profiles:\n                exclude_profile_ids.append(quality_profiles[profile])\n\n    def build_season_filtering(\n        media_season: Dict[str, Any], file_season: Dict[str, Any]\n    ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:\n        \"\"\"\n        Split a season into filtered (excluded) and search-needed episodes based on monitoring and matches.\n        \"\"\"\n        season_data: List[Dict[str, Any]] = []\n        filtered_seasons: List[Dict[str, Any]] = []\n        if not media_season[\"monitored\"]:\n            # Unmonitored season, add to filtered\n            filtered_seasons.append(\n                {\n                    \"season_number\": media_season[\"season_number\"],\n                    \"monitored\": False,\n                }\n            )\n        else:\n            if media_season[\"season_pack\"]:\n                # Monitored season pack, add all to search\n                season_data.append(\n                    {\n                        \"season_number\": media_season[\"season_number\"],\n                        \"season_pack\": True,\n                        \"episode_data\": media_season[\"episode_data\"],\n                    }\n                )\n            else:\n                # For non-season-pack, filter out unmonitored and select monitored matching episodes\n                episode_set = set(file_season[\"episodes\"])\n                filtered_episodes = []\n                episode_data = []\n                for episode in media_season[\"episode_data\"]:\n                    if not episode[\"monitored\"]:\n                        # Unmonitored episode, add to filtered\n                        filtered_episodes.append(episode)\n                    elif episode[\"episode_number\"] in episode_set:\n                        # Monitored and present in file_season, add to search\n                        episode_data.append(episode)\n                if filtered_episodes:\n                    # Unmonitored chunk\n                    filtered_seasons.append(\n                        {\n                            \"season_number\": media_season[\"season_number\"],\n                            \"monitored\": True,\n                            \"episodes\": filtered_episodes,\n                        }\n                    )\n                if episode_data:\n                    # Monitored chunk that needs searching\n                    season_data.append(\n                        {\n                            \"season_number\": media_season[\"season_number\"],\n                            \"season_pack\": False,\n                            \"episode_data\": episode_data,\n                        }\n                    )\n        return season_data, filtered_seasons\n\n    data_list: Dict[str, List[Dict[str, Any]]] = {\n        \"search_media\": [],\n        \"filtered_media\": [],\n    }\n    for nohl_item in progress(\n        nohl_data,\n        desc=\"Filtering media...\",\n        unit=\"item\",\n        total=len(nohl_data),\n        logger=logger,\n    ):\n        for media_item in media_dict:\n            # ARR resolution: match normalized title and year\n            if (\n                media_item[\"normalized_title\"] == nohl_item[\"normalized_title\"]\n                and media_item[\"year\"] == nohl_item[\"year\"]\n            ):\n                # Only match root_path if not dry_run\n                if (\n                    nohl_item[\"root_path\"] not in media_item[\"root_folder\"]\n                    and not config.dry_run\n                ):\n                    logger.debug(\n                        f\"Skipping {media_item['title']} ({media_item['year']}), root folder mismatch.\"\n                    )\n                    continue\n                # Exclusion checks: monitored, exclusion lists, quality profile\n                if (\n                    media_item[\"monitored\"] is False\n                    or (\n                        instance_type == \"radarr\"\n                        and config.exclude_movies\n                        and media_item[\"title\"] in config.exclude_movies\n                    )\n                    or (\n                        instance_type == \"sonarr\"\n                        and config.exclude_series\n                        and media_item[\"title\"] in config.exclude_series\n                    )\n                    or media_item[\"quality_profile\"] in exclude_profile_ids\n                ):\n                    data_list[\"filtered_media\"].append(\n                        {\n                            \"title\": media_item[\"title\"],\n                            \"year\": media_item[\"year\"],\n                            \"monitored\": media_item[\"monitored\"],\n                            \"excluded\": (\n                                (\n                                    instance_type == \"radarr\"\n                                    and config.exclude_movies\n                                    and media_item[\"title\"] in config.exclude_movies\n                                )\n                                or (\n                                    instance_type == \"sonarr\"\n                                    and config.exclude_series\n                                    and media_item[\"title\"] in config.exclude_series\n                                )\n                            ),\n                            \"quality_profile\": (\n                                quality_profiles.get(media_item[\"quality_profile\"])\n                                if media_item[\"quality_profile\"] in exclude_profile_ids\n                                else None\n                            ),\n                        }\n                    )\n                    logger.debug(\n                        f\"Filtered out: {media_item['title']} ({media_item['year']}), reason(s): \"\n                        f\"{'not monitored' if media_item['monitored'] is False else ''}\"\n                        f\"{', excluded' if (instance_type == 'radarr' and config.exclude_movies and media_item['title'] in config.exclude_movies) or (instance_type == 'sonarr' and config.exclude_series and media_item['title'] in config.exclude_series) else ''}\"\n                        f\"{', quality profile' if media_item['quality_profile'] in exclude_profile_ids else ''}\"\n                    )\n                    continue\n                if instance_type == \"radarr\":\n                    # Add movie to search list\n                    file_ids = media_item[\"file_id\"]\n                    data_list[\"search_media\"].append(\n                        {\n                            \"media_id\": media_item[\"media_id\"],\n                            \"title\": media_item[\"title\"],\n                            \"year\": media_item[\"year\"],\n                            \"file_ids\": file_ids,\n                        }\n                    )\n                    logger.debug(\n                        f\"Radarr: Will resolve {media_item['title']} ({media_item['year']}), file_ids={file_ids}\"\n                    )\n                elif instance_type == \"sonarr\":\n                    # Season filtering for Sonarr: build per-season search/exclude lists\n                    media_seasons_info = media_item.get(\"seasons\", {})\n                    file_season_info = nohl_item.get(\"season_info\", [])\n                    season_data = []\n                    filtered_seasons = []\n                    for media_season in media_seasons_info:\n                        for file_season in file_season_info:\n                            if (\n                                media_season[\"season_number\"]\n                                == file_season[\"season_number\"]\n                            ):\n                                sdata, sfiltered = build_season_filtering(\n                                    media_season, file_season\n                                )\n                                season_data.extend(sdata)\n                                filtered_seasons.extend(sfiltered)\n                    if filtered_seasons:\n                        data_list[\"filtered_media\"].append(\n                            {\n                                \"title\": media_item[\"title\"],\n                                \"year\": media_item[\"year\"],\n                                \"seasons\": filtered_seasons,\n                            }\n                        )\n                        logger.debug(\n                            f\"Filtered out: {media_item['title']} ({media_item['year']}), reason(s): \"\n                            f\"{'not monitored' if media_item['monitored'] is False else ''}\"\n                            f\"{', excluded' if (instance_type == 'radarr' and config.exclude_movies and media_item['title'] in config.exclude_movies) or (instance_type == 'sonarr' and config.exclude_series and media_item['title'] in config.exclude_series) else ''}\"\n                            f\"{', quality profile' if media_item['quality_profile'] in exclude_profile_ids else ''}\"\n                        )\n                    if season_data:\n                        logger.debug(\n                            f\"{media_item['title']} ({media_item['year']}): {len(season_data)} seasons selected for search\"\n                        )\n                        data_list[\"search_media\"].append(\n                            {\n                                \"media_id\": media_item[\"media_id\"],\n                                \"title\": media_item[\"title\"],\n                                \"year\": media_item[\"year\"],\n                                \"monitored\": media_item[\"monitored\"],\n                                \"seasons\": season_data,\n                            }\n                        )\n                        logger.debug(\n                            f\"Sonarr: Will resolve {media_item['title']} ({media_item['year']}), seasons: \"\n                            f\"{[s['season_number'] for s in season_data]}\"\n                        )\n    # Limit number of searches if configured\n    if len(data_list[\"search_media\"]) >= config.searches:\n        data_list[\"search_media\"] = data_list[\"search_media\"][: config.searches]\n    return data_list\n\n\ndef handle_messages(output: Dict[str, Any], logger: Logger) -> None:\n    \"\"\"\n    Print a formatted summary of scanned non-hardlinked files and resolved ARR actions.\n    Args:\n        output: Output dictionary containing scan and resolve results.\n        logger: Logger instance.\n    \"\"\"\n    # Output scanned section: show all non-hardlinked movies and series found\n    logger.info(create_table([[\"Scanned Non-Hardlinked Files\"]]))\n    for path, results in output.get(\"scanned\", {}).items():\n        logger.info(f\"Scanning results for: {path}\")\n        for item in results.get(\"movies\", []):\n            logger.info(f\"{item['title']} ({item['year']})\")\n            if item.get(\"nohl\"):\n                for file_path in item[\"nohl\"]:\n                    logger.info(f\"\\t{os.path.basename(file_path)}\")\n            logger.info(\"\")\n        for item in results.get(\"series\", []):\n            logger.info(f\"{item['title']} ({item['year']})\")\n            for season in item.get(\"season_info\", []):\n                if season.get(\"nohl\"):\n                    logger.info(f\"\\tSeason {season['season_number']}\")\n                    for file_path in season[\"nohl\"]:\n                        logger.info(f\"\\t\\t{os.path.basename(file_path)}\")\n            logger.info(\"\")\n    # Output resolved section: show all ARR actions performed or skipped\n    logger.info(create_table([[\"Resolved ARR Actions\"]]))\n    for instance, instance_data in output.get(\"resolved\", {}).items():\n        search_media = instance_data[\"data\"][\"search_media\"]\n        filtered_media = instance_data[\"data\"][\"filtered_media\"]\n        # Output searched ARR media\n        if search_media:\n            for search_item in search_media:\n                if instance_data[\"instance_type\"] == \"radarr\":\n                    logger.info(f\"{search_item['title']} ({search_item['year']})\")\n                    logger.info(\"\\tDeleted and searched.\\n\")\n                else:\n                    logger.info(f\"{search_item['title']} ({search_item['year']})\")\n                    if search_item.get(\"seasons\", None):\n                        for season in search_item[\"seasons\"]:\n                            if season[\"season_pack\"]:\n                                logger.info(\n                                    f\"\\tSeason {season['season_number']}, deleted and searched.\"\n                                )\n                            else:\n                                logger.info(f\"\\tSeason {season['season_number']}\")\n                                for episode in season[\"episode_data\"]:\n                                    logger.info(\n                                        f\"\\t   Episode {episode['episode_number']}, deleted and searched.\"\n                                    )\n                            logger.info(\"\")\n        # Output filtered ARR media (excluded or unmonitored)\n        table = [[\"Filtered Media\"]]\n        if filtered_media:\n            logger.debug(create_table(table))\n            for filtered_item in filtered_media:\n                monitored = filtered_item.get(\"monitored\", None)\n                logger.debug(f\"{filtered_item['title']} ({filtered_item['year']})\")\n                if monitored is False:\n                    logger.debug(\"\\tSkipping, not monitored.\")\n                elif filtered_item.get(\"exclude_media\", None):\n                    logger.debug(\"\\tSkipping, excluded.\")\n                elif filtered_item.get(\"quality_profile\", None):\n                    logger.debug(\n                        f\"\\tSkipping, quality profile: {filtered_item['quality_profile']}\"\n                    )\n                elif filtered_item.get(\"seasons\", None):\n                    for season in filtered_item[\"seasons\"]:\n                        if season[\"monitored\"] is False:\n                            logger.debug(\n                                f\"\\tSeason {season['season_number']}, skipping, not monitored.\"\n                            )\n                        elif season.get(\"episodes\", None):\n                            logger.debug(f\"\\tSeason {season['season_number']}\")\n                            for episode in season[\"episodes\"]:\n                                logger.debug(\n                                    f\"\\t   Episode {episode['episode_number']}, skipping, not monitored.\"\n                                )\n                            logger.debug(\"\")\n        else:\n            logger.debug(f\"No filtered files for {instance_data['server_name']}\")\n        logger.debug(\"\")\n    # Output summary table\n    summary = output.get(\"summary\", {})\n    if not all(value == 0 for value in summary.values()):\n        logger.info(\n            create_table(\n                [\n                    [\"Metric\", \"Count\"],\n                    [\"Total Scanned Movies\", summary.get(\"total_scanned_movies\", 0)],\n                    [\"Total Scanned Episodes\", summary.get(\"total_scanned_series\", 0)],\n                    [\"Total Resolved Movies\", summary.get(\"total_resolved_movies\", 0)],\n                    [\n                        \"Total Resolved Episodes\",\n                        summary.get(\"total_resolved_series\", 0),\n                    ],\n                ]\n            )\n        )\n    else:\n        logger.info(\"\\n\\n\\t\\t✅ Congratulations, there is nothing to report.\\n\\n\")\n\n\ndef main(config) -> None:\n    \"\"\"\n    Entrypoint for nohl.py. Scans for non-hardlinked files and triggers ARR actions.\n    Args:\n        config: Parsed configuration namespace.\n    \"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    try:\n        if config.log_level.lower() == \"debug\":\n            print_settings(logger, config)\n        # Warn if running in dry run mode\n        if config.dry_run:\n            table = [[\"Dry Run\"], [\"NO CHANGES WILL BE MADE\"]]\n            logger.info(create_table(table))\n        logger.debug(\"Logger initialized. Starting main process.\")\n        # Ensure ARR instances are configured\n        if config.instances is None:\n            logger.error(\"No instances set in config file.\")\n            return\n        # Parse source_dirs into entries with path+mode\n        source_entries = []\n        if getattr(config, \"source_dirs\", None):\n            for entry in config.source_dirs:\n                if isinstance(entry, dict):\n                    if \"mode\" not in entry:\n                        logger.warning(f\"No 'mode' set for source_dir path '{entry.get('path')}', defaulting to 'scan'.\")\n                    source_entries.append(\n                        {\n                            \"path\": entry.get(\"path\"),\n                            \"mode\": entry.get(\"mode\", \"scan\"),\n                        }\n                    )\n                else:\n                    source_entries.append({\"path\": entry, \"mode\": \"resolve\"})\n        # Separate scan vs resolve entries\n        scan_entries = [e for e in source_entries if e[\"mode\"] == \"scan\"]\n        resolve_entries = [e for e in source_entries if e[\"mode\"] == \"resolve\"]\n        # Scan-only: gather all non-hardlinked files for reporting\n        scanned_results: Dict[str, Any] = {}\n        for entry in scan_entries:\n            path = entry[\"path\"]\n            results = find_nohl_files(path, logger)\n            scanned_results[path] = results or {\"movies\": [], \"series\": []}\n        # Resolve-only: aggregate all nohl results for ARR resolution\n        nohl_list: Dict[str, List[Dict[str, Any]]] = {\"movies\": [], \"series\": []}\n        for entry in resolve_entries:\n            path = entry[\"path\"]\n            results = find_nohl_files(path, logger) or {\"movies\": [], \"series\": []}\n            if results and (results.get(\"movies\") or results.get(\"series\")):\n                nohl_list[\"movies\"].extend(results.get(\"movies\", []))\n                nohl_list[\"series\"].extend(results.get(\"series\", []))\n            else:\n                logger.warning(\n                    f\"No non-hardlinked files found in {path}, skipping resolution for this path\"\n                )\n                continue\n        # Compute summary statistics for output reporting\n        total_movies = sum(\n            len(movie.get(\"nohl\", []))\n            for results in scanned_results.values()\n            for movie in results.get(\"movies\", [])\n        )\n        total_series = sum(\n            sum(len(season.get(\"nohl\", [])) for season in series.get(\"season_info\", []))\n            for results in scanned_results.values()\n            for series in results.get(\"series\", [])\n        )\n        total_nohl_movies = sum(\n            len(movie.get(\"nohl\", [])) for movie in nohl_list[\"movies\"]\n        )\n        total_nohl_series = sum(\n            sum(len(season.get(\"nohl\", [])) for season in series.get(\"season_info\", []))\n            for series in nohl_list[\"series\"]\n        )\n        total_scanned_movies = sum(\n            len(movie.get(\"nohl\", []))\n            for path, results in scanned_results.items()\n            for movie in results.get(\"movies\", [])\n        )\n        total_scanned_series = sum(\n            sum(len(season.get(\"nohl\", [])) for season in series.get(\"season_info\", []))\n            for path, results in scanned_results.items()\n            for series in results.get(\"series\", [])\n        )\n        logger.debug(f\"Total scanned movie files: {total_movies}\")\n        logger.debug(f\"Total scanned series files: {total_series}\")\n        logger.debug(f\"Total non-hardlinked movie files: {total_nohl_movies}\")\n        logger.debug(f\"Total non-hardlinked series files: {total_nohl_series}\")\n        logger.debug(f\"Total scanned results - movies: {total_scanned_movies}\")\n        logger.debug(f\"Total scanned results - series: {total_scanned_series}\")\n        output_dict: Dict[str, Any] = {}\n        data_list: Dict[str, Any] = {}\n        media_dict: Any = {}\n        nohl_data: Any = {}\n        # ARR resolution: for each instance, filter and trigger searches\n        for instance_type, instance_data in config.instances_config.items():\n            for instance in config.instances:\n                if isinstance(instance, dict):\n                    instance_name = next(iter(instance.keys()))\n                else:\n                    instance_name = instance\n                if instance_name in instance_data:\n                    data_list = {\"search_media\": [], \"filtered_media\": []}\n                    instance_settings = instance_data.get(instance, None)\n                    app = create_arr_client(\n                        instance_settings[\"url\"], instance_settings[\"api\"], logger\n                    )\n                    if app and app.connect_status:\n                        server_name = app.get_instance_name()\n                        table = [[f\"{server_name}\"]]\n                        logger.info(create_table(table))\n                        if (instance_type == \"radarr\" and not nohl_list[\"movies\"]) or (\n                            instance_type == \"sonarr\" and not nohl_list[\"series\"]\n                        ):\n                            logger.info(\n                                f\"No non-hardlinked files found for server: {server_name}\"\n                            )\n                        nohl_data = (\n                            nohl_list[\"movies\"]\n                            if instance_type == \"radarr\"\n                            else (\n                                nohl_list[\"series\"]\n                                if instance_type == \"sonarr\"\n                                else None\n                            )\n                        )\n                        if nohl_data:\n                            # Pull all media from ARR and filter for resolution\n                            if instance_type == \"sonarr\":\n                                media_dict = app.get_parsed_media(include_episode=True)\n                            else:\n                                media_dict = app.get_parsed_media()\n                            if media_dict:\n                                data_list = filter_media(\n                                    app,\n                                    media_dict,\n                                    nohl_data,\n                                    instance_type,\n                                    config,\n                                    logger,\n                                )\n                            else:\n                                logger.info(f\"No media found for server: {server_name}\")\n                            search_list = data_list.get(\"search_media\", [])\n                            if search_list:\n                                # Conduct searches, with dry run support\n                                search_list = handle_searches(\n                                    app, search_list, instance_type, logger, config\n                                )\n                                data_list[\"search_media\"] = search_list\n                        output_dict[instance] = {\n                            \"server_name\": server_name,\n                            \"instance_type\": instance_type,\n                            \"data\": data_list,\n                        }\n                        logger.debug(\n                            f\"{server_name} processing complete. Search media: {len(data_list['search_media'])}, Filtered: {len(data_list['filtered_media'])}\"\n                        )\n        # Dump debug JSON payloads if needed\n        if config.log_level == \"debug\":\n            print_json(data_list, logger, config.module_name, \"data_list\")\n            print_json(media_dict, logger, config.module_name, \"media_dict\")\n            print_json(nohl_data, logger, config.module_name, \"nohl_data\")\n            print_json(output_dict, logger, config.module_name, \"output_dict\")\n        # Prepare summary for output reporting (only count actual resolved items in search_media)\n        resolved_movies = 0\n        resolved_episodes = 0\n        for instance, instance_data in output_dict.items():\n            search_media = instance_data[\"data\"].get(\"search_media\", [])\n            if instance_data[\"instance_type\"] == \"radarr\":\n                resolved_movies += len(search_media)\n            elif instance_data[\"instance_type\"] == \"sonarr\":\n                for search_item in search_media:\n                    # Only count episodes in search_media (i.e., actually resolved)\n                    if \"seasons\" in search_item:\n                        for season in search_item[\"seasons\"]:\n                            resolved_episodes += len(season.get(\"episode_data\", []))\n        summary = {\n            \"total_scanned_movies\": total_scanned_movies,\n            \"total_scanned_series\": total_scanned_series,\n            \"total_resolved_movies\": resolved_movies,\n            \"total_resolved_series\": resolved_episodes,\n        }\n        # Combine scan and resolve results for reporting and notification\n        final_output = {\n            \"scanned\": scanned_results,\n            \"resolved\": output_dict,\n            \"summary\": summary,\n        }\n        # Output results to console/log\n        handle_messages(final_output, logger)\n        # Send notification with scan+resolve results\n        send_notification(\n            logger=logger,\n            module_name=config.module_name,\n            config=config,\n            output=final_output,\n        )\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/poster_cleanarr.py",
    "content": "import os\nimport shutil\nimport sys\nfrom types import SimpleNamespace\nfrom typing import Dict, List, Optional, Union\n\nfrom util.arrpy import create_arr_client\nfrom util.assets import get_assets_files\nfrom util.index import create_new_empty_index\nfrom util.logger import Logger\nfrom util.match import match_assets_to_media\nfrom util.utility import (\n    create_table,\n    get_plex_data,\n    print_json,\n    print_settings,\n)\n\ntry:\n    from plexapi.server import PlexServer\nexcept ImportError as e:\n    print(f\"ImportError: {e}\")\n    print(\"Please install the required modules with 'pip install -r requirements.txt'\")\n    exit(1)\n\n\ndef remove_assets(\n    unmatched_dict: List[Dict[str, Union[str, int, List[str], None]]],\n    config: SimpleNamespace,\n    logger: Logger,\n) -> List[Dict[str, Union[str, int, List[str]]]]:\n    \"\"\"\n    Remove unmatched assets from disk or simulate removal.\n\n    Args:\n        unmatched_dict: List of unmatched asset dictionaries.\n        config: Configuration namespace.\n        logger: Logger instance.\n\n    Returns:\n        List of dictionaries summarizing removed assets and messages.\n    \"\"\"\n    remove_data: List[Dict[str, Union[str, int, List[str]]]] = []\n    remove_list: List[str] = []\n\n    # If input is a dict by type, flatten to a list\n    if isinstance(unmatched_dict, dict):\n        all_unmatched = []\n        for v in unmatched_dict.values():\n            all_unmatched.extend(v)\n        unmatched_list = all_unmatched\n    else:\n        unmatched_list = unmatched_dict\n\n    for asset_data in unmatched_list:\n        messages: List[str] = []\n\n        if not asset_data[\"files\"] and asset_data.get(\"path\"):\n            # Remove empty folder asset\n            remove_list.append(asset_data[\"path\"])\n            messages.append(\n                f\"Removing empty folder: {os.path.basename(asset_data['path'])}\"\n            )\n        else:\n            # Remove individual files for asset\n            for file in asset_data[\"files\"]:\n                remove_list.append(file)\n                # Compose tmp path\n                asset_dir = os.path.dirname(file)\n                basename = os.path.basename(file)\n                tmp_path = os.path.join(asset_dir, \"tmp\", basename)\n                if os.path.isfile(tmp_path):\n                    remove_list.append(tmp_path)\n                    if config.log_level.lower() == \"debug\":\n                        messages.append(f\"Removing duplicate in tmp: {tmp_path}\")\n                messages.append(f\"Removing file: {basename}\")\n\n        remove_data.append(\n            {\n                \"title\": asset_data[\"title\"],\n                \"year\": asset_data[\"year\"],\n                \"messages\": messages,\n            }\n        )\n\n    if not config.dry_run:\n        for path in remove_list:\n            try:\n                if os.path.isdir(path):\n                    shutil.rmtree(path)\n                else:\n                    os.remove(path)\n                    # Remove parent folder if empty after file removal\n                    folder_path = os.path.dirname(path)\n                    if not os.listdir(folder_path):\n                        os.rmdir(folder_path)\n            except OSError as e:\n                logger.error(f\"Error: {e}\")\n                logger.error(f\"Failed to remove: {path}\")\n                continue\n\n        # Clean up any remaining empty folders in source directories\n        for assets_path in config.source_dirs:\n            for root, dirs, files in os.walk(assets_path, topdown=False):\n                for dir_name in dirs:\n                    dir_path = os.path.join(root, dir_name)\n                    if not os.listdir(dir_path):\n                        try:\n                            logger.info(f\"Removing empty folder: {dir_name}\")\n                            os.rmdir(dir_path)\n                        except OSError as e:\n                            logger.error(f\"Error: {e}\")\n                            logger.error(f\"Failed to remove: {dir_path}\")\n                            continue\n\n    return remove_data\n\n\ndef print_output(\n    remove_data: List[Dict[str, Union[str, int, List[str]]]], logger: Logger\n) -> None:\n    \"\"\"\n    Print summary of removed assets and messages.\n\n    Args:\n        remove_data: List of dictionaries with removal information.\n        logger: Logger instance.\n    \"\"\"\n    # Add a banner/table at the very top\n    table = [[\"Assets Removed Summary\"]]\n    logger.info(create_table(table))\n\n    count: int = 0\n\n    for data in remove_data:\n        title: str = data[\"title\"]\n        year: Optional[int] = data.get(\"year\")\n        if year:\n            logger.info(f\"• {title} ({year})\")\n        else:\n            logger.info(f\"• {title}\")\n\n        asset_messages: List[str] = data[\"messages\"]\n        for message in asset_messages:\n            logger.info(f\"   - {message}\")\n            count += 1\n\n    logger.info(f\"\\nTotal number of assets removed: {count}\")\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"\n    Main function to load media, match assets, and remove unmatched assets.\n\n    Args:\n        config: Configuration namespace.\n    \"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    remove_data = []\n\n    try:\n        if config.log_level.lower() == \"debug\":\n            print_settings(logger, config)\n\n        if config.dry_run:\n            table = [[\"Dry Run\"], [\"NO CHANGES WILL BE MADE\"]]\n            logger.info(create_table(table))\n        # Load assets from source directories\n        prefix_index = create_new_empty_index()\n        assets_dict, prefix_index = get_assets_files(config.source_dirs, logger)\n        if not assets_dict:\n            logger.error(\n                f\"No assets found in the source directories: {config.source_dirs}\"\n            )\n            return\n\n        media_dict = {\"movies\": [], \"series\": [], \"collections\": []}\n\n        if not config.instances:\n            logger.error(\"No instances found. Exiting script.\")\n            return\n\n        for instance in config.instances:\n            if isinstance(instance, dict):\n                instance_name, instance_settings = next(iter(instance.items()))\n            else:\n                instance_name = instance\n                instance_settings = {}\n\n            found = False\n            instance_type = None\n            instance_data = None\n\n            for itype, idata in config.instances_config.items():\n                if instance_name in idata:\n                    found = True\n                    instance_type = itype\n                    instance_data = idata\n                    break\n\n            if not found or instance_type is None or instance_data is None:\n                logger.warning(\n                    f\"Instance '{instance_name}' not found in config.instances_config. Skipping.\"\n                )\n                continue\n\n            if instance_type == \"plex\":\n                url = instance_data[instance_name][\"url\"]\n                api = instance_data[instance_name][\"api\"]\n                try:\n                    app = PlexServer(url, api)\n                except Exception as e:\n                    logger.error(f\"Error connecting to Plex: {e}\")\n                    app = None\n\n                if app:\n                    library_names = instance_settings.get(\"library_names\", [])\n                    if library_names:\n                        logger.info(\"Fetching Plex collections...\")\n                        results = get_plex_data(\n                            app,\n                            library_names,\n                            logger,\n                            include_smart=True,\n                            collections_only=True,\n                        )\n                        media_dict[\"collections\"].extend(results)\n                    else:\n                        logger.warning(\n                            f\"No library names specified for Plex instance '{instance_name}'. Skipping.\"\n                        )\n            else:\n                url = instance_data[instance_name][\"url\"]\n                api = instance_data[instance_name][\"api\"]\n                app = create_arr_client(url, api, logger)\n\n                if app and app.connect_status:\n                    logger.info(f\"Fetching {app.instance_name} data...\")\n                    results = app.get_parsed_media(include_episode=False)\n                    if results:\n                        if instance_type == \"radarr\":\n                            media_dict[\"movies\"].extend(results)\n                        elif instance_type == \"sonarr\":\n                            media_dict[\"series\"].extend(results)\n                    else:\n                        logger.warning(\n                            f\"No {instance_type.capitalize()} data found for instance '{instance_name}'.\"\n                        )\n\n        if not any(media_dict.values()):\n            logger.error(\n                \"No media found. Check 'instances' setting in your config. Exiting.\"\n            )\n            return\n        if media_dict and prefix_index:\n            logger.info(\"Matching assets to media, please wait...\")\n            unmatched_dict = match_assets_to_media(\n                media_dict,\n                prefix_index,\n                logger,\n                return_unmatched_assets=True,\n                config=config,\n                strict_folder_match=True,\n            )\n\n        if any(unmatched_dict.values()):\n            remove_data = remove_assets(unmatched_dict, config, logger)\n            if remove_data:\n                print_output(remove_data, logger)\n        else:\n            logger.info(\"✅ No assets needed to be removed. Everything is in sync!\")\n\n        # Only dump debug JSON if we're in debug mode\n        if config.log_level.lower() == \"debug\":\n            logger.debug(\"Dumping debug data for assets/media/unmatched/remove_data.\")\n            print_json(assets_dict, logger, config.module_name, \"assets_dict\")\n            print_json(media_dict, logger, config.module_name, \"media_dict\")\n            print_json(unmatched_dict, logger, config.module_name, \"unmatched_dict\")\n            print_json(remove_data, logger, config.module_name, \"remove_data\")\n\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        logger.log_outro()\n"
  },
  {
    "path": "modules/poster_renamerr.py",
    "content": "import copy\nimport filecmp\nimport os\nimport re\nimport shutil\nimport sys\nfrom types import SimpleNamespace\nfrom typing import Any, Dict, List, Tuple\n\nfrom util.arrpy import create_arr_client\nfrom util.assets import get_assets_files\nfrom util.constants import year_regex\nfrom util.index import create_new_empty_index\nfrom util.logger import Logger\nfrom util.match import match_assets_to_media\nfrom util.notification import send_notification\nfrom util.utility import (\n    create_table,\n    get_plex_data,\n    print_json,\n    print_settings,\n)\n\ntry:\n    from pathvalidate import is_valid_filename, sanitize_filename\n    from plexapi.server import PlexServer\n\n    from util.utility import progress\nexcept ImportError as e:\n    print(f\"ImportError: {e}\")\n    print(\"Please install the required modules with 'pip install -r requirements.txt'\")\n    exit(1)\n\n\ndef process_file(file: str, new_file_path: str, action_type: str, logger: Any) -> None:\n    \"\"\"\n    Perform a file operation (copy, move, hardlink, or symlink) between paths.\n    Args:\n        file: Original file path.\n        new_file_path: Destination file path.\n        action_type: Operation type: 'copy', 'move', 'hardlink', or 'symlink'.\n        logger: Logger for error reporting.\n    Returns:\n        None\n    \"\"\"\n    try:\n        if action_type == \"copy\":\n            shutil.copy(file, new_file_path)\n        elif action_type == \"move\":\n            shutil.move(file, new_file_path)\n        elif action_type == \"hardlink\":\n            os.link(file, new_file_path)\n        elif action_type == \"symlink\":\n            os.symlink(file, new_file_path)\n    except OSError as e:\n        logger.error(f\"Error {action_type}ing file: {e}\")\n\n\ndef rename_files(\n    matched_assets: Dict[str, List[Dict[str, Any]]],\n    config: SimpleNamespace,\n    logger: Any,\n) -> Tuple[Dict[str, List[Dict[str, Any]]], List[str]]:\n    \"\"\"\n    Rename matched assets to Plex-compatible filenames and handle folder structure.\n    Args:\n        matched_assets: Dictionary of matched poster assets.\n        config: Module configuration.\n        logger: Logger instance.\n    Returns:\n        Tuple of output message dict and renamed assets dict.\n    \"\"\"\n    output: Dict[str, List[Dict[str, Any]]] = {}\n    renamed_files = []\n    # Determine destination based on dry run and border replacer\n    if config.run_border_replacerr:\n        tmp_dir = os.path.join(config.destination_dir, \"tmp\")\n        if not config.dry_run:\n            if not os.path.exists(tmp_dir):\n                os.makedirs(tmp_dir)\n            else:\n                logger.debug(f\"{tmp_dir} already exists\")\n            destination_dir = tmp_dir\n        else:\n            logger.debug(f\"Would create folder {tmp_dir}\")\n            destination_dir = tmp_dir\n    else:\n        destination_dir = config.destination_dir\n\n    asset_types: List[str] = [\"collections\", \"movies\", \"series\"]\n    logger.info(\"Renaming assets please wait...\")\n    for asset_type in asset_types:\n        output[asset_type] = []\n        if matched_assets[asset_type]:\n            with progress(\n                matched_assets[asset_type],\n                desc=f\"Renaming {asset_type}\",\n                total=len(matched_assets[asset_type]),\n                unit=\"item\",\n                logger=logger,\n            ) as pbar:\n                for item in pbar:\n                    messages: List[str] = []\n                    discord_messages: List[str] = []\n                    files = item[\"files\"]\n                    folder = item[\"folder\"]\n                    # Sanitize folder name for collections\n                    if asset_type == \"collections\":\n                        if not is_valid_filename(folder):\n                            folder = sanitize_filename(folder)\n                    # Construct destination folder\n                    if config.asset_folders:\n                        dest_dir = os.path.join(destination_dir, folder)\n                        if not os.path.exists(dest_dir):\n                            if not config.dry_run:\n                                os.makedirs(dest_dir)\n                    else:\n                        dest_dir = destination_dir\n                    # Rename each asset file\n                    for file in files:\n                        file_name = os.path.basename(file)\n                        file_extension = os.path.splitext(file)[1]\n                        if re.search(r\" - Season| - Specials\", file_name):\n                            try:\n                                season_number = (\n                                    re.search(r\"Season (\\d+)\", file_name).group(1)\n                                    if \"Season\" in file_name\n                                    else \"00\"\n                                ).zfill(2)\n                            except AttributeError:\n                                logger.debug(\n                                    f\"Error extracting season number from {file_name}\"\n                                )\n                                continue\n                            if config.asset_folders:\n                                new_file_name = f\"Season{season_number}{file_extension}\"\n                            else:\n                                new_file_name = (\n                                    f\"{folder}_Season{season_number}{file_extension}\"\n                                )\n                            new_file_path = os.path.join(dest_dir, new_file_name)\n                        else:\n                            if config.asset_folders:\n                                new_file_name = f\"poster{file_extension}\"\n                            else:\n                                new_file_name = f\"{folder}{file_extension}\"\n                            new_file_path = os.path.join(dest_dir, new_file_name)\n                        # Check if destination exists and is different\n                        if os.path.lexists(new_file_path):\n                            existing_file = os.path.join(dest_dir, new_file_name)\n                            try:\n                                if not filecmp.cmp(file, existing_file):\n                                    if file_name != new_file_name:\n                                        messages.append(\n                                            f\"{file_name} -renamed-> {new_file_name}\"\n                                        )\n                                        discord_messages.append(f\"{new_file_name}\")\n                                    else:\n                                        if not config.print_only_renames:\n                                            messages.append(\n                                                f\"{file_name} -not-renamed-> {new_file_name}\"\n                                            )\n                                            discord_messages.append(f\"{new_file_name}\")\n                                    if not config.dry_run:\n                                        if config.action_type in [\n                                            \"hardlink\",\n                                            \"symlink\",\n                                        ]:\n                                            os.remove(new_file_path)\n                                        process_file(\n                                            file,\n                                            new_file_path,\n                                            config.action_type,\n                                            logger,\n                                        )\n                                        renamed_files.append(new_file_path)\n                            except FileNotFoundError:\n                                if not config.dry_run:\n                                    os.remove(new_file_path)\n                                    process_file(\n                                        file, new_file_path, config.action_type, logger\n                                    )\n                                    renamed_files.append(new_file_path)\n                        else:\n                            if file_name != new_file_name:\n                                messages.append(\n                                    f\"{file_name} -renamed-> {new_file_name}\"\n                                )\n                                discord_messages.append(f\"{new_file_name}\")\n                            else:\n                                if not config.print_only_renames:\n                                    messages.append(\n                                        f\"{file_name} -not-renamed-> {new_file_name}\"\n                                    )\n                                    discord_messages.append(f\"{new_file_name}\")\n                            if not config.dry_run:\n                                process_file(\n                                    file, new_file_path, config.action_type, logger\n                                )\n                                renamed_files.append(new_file_path)\n                    if messages or discord_messages:\n                        output[asset_type].append(\n                            {\n                                \"title\": item[\"title\"],\n                                \"year\": item[\"year\"],\n                                \"folder\": item[\"folder\"],\n                                \"messages\": messages,\n                                \"discord_messages\": discord_messages,\n                            }\n                        )\n        else:\n            logger.info(f\"No {asset_type} to rename\")\n    return output, renamed_files\n\n\ndef handle_output(\n    output: Dict[str, List[Dict[str, Any]]], config: SimpleNamespace, logger: Any\n) -> None:\n    \"\"\"\n    Print final rename results to the logger by asset type.\n    Args:\n        output: Collected messages by asset type.\n        config: Configuration settings.\n        logger: Logger for printing.\n    Returns:\n        None\n    \"\"\"\n    for asset_type, assets in output.items():\n        if assets:\n            table = [\n                [f\"{asset_type.capitalize()}\"],\n            ]\n            if any(asset[\"messages\"] for asset in assets):\n                logger.info(create_table(table))\n            for asset in assets:\n                title = asset[\"title\"]\n                title = year_regex.sub(\"\", title).strip()\n                year = asset[\"year\"]\n                folder = asset[\"folder\"]\n                messages = asset[\"messages\"]\n                if year:\n                    year = f\" ({year})\"\n                else:\n                    year = \"\"\n                messages.sort()\n                if messages:\n                    logger.info(f\"{title}{year}\")\n                    if config.asset_folders:\n                        if config.dry_run:\n                            logger.info(f\"\\tWould create folder '{folder}'\")\n                        else:\n                            logger.info(f\"\\tCreated folder '{folder}'\")\n                    for message in messages:\n                        logger.info(f\"\\t{message}\")\n                    logger.info(\"\")\n        else:\n            logger.info(f\"No {asset_type} to rename\")\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"\n    Entrypoint for poster_renamerr.py.\n    Loads configuration, fetches media and assets, matches posters, performs renames,\n    and optionally syncs to Google Drive and runs border replacerr if enabled.\n    Args:\n        config: Parsed config from user settings.\n    Returns:\n        None\n    \"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    try:\n        if config.log_level.lower() == \"debug\":\n            print_settings(logger, config)\n        if not os.path.exists(config.destination_dir):\n            logger.info(f\"Creating destination directory: {config.destination_dir}\")\n            os.makedirs(config.destination_dir)\n        else:\n            logger.debug(\n                f\"Destination directory already exists: {config.destination_dir}\"\n            )\n        if config.dry_run:\n            table = [[\"Dry Run\"], [\"NO CHANGES WILL BE MADE\"]]\n            logger.info(create_table(table))\n        if config.sync_posters:\n            logger.info(\"Running sync_gdrive\")\n            from modules.sync_gdrive import main as gdrive_main\n            from util.config import Config\n\n            gdrive_config = Config(\"sync_gdrive\").module_config\n            gdrive_main(gdrive_config)\n            logger.info(\"Finished running sync_gdrive\")\n        else:\n            logger.debug(\"Sync posters is disabled. Skipping...\")\n        prefix_index = create_new_empty_index()\n        logger.info(\"Gathering all the posters, please wait...\")\n        assets_dict, prefix_index = get_assets_files(config.source_dirs, logger)\n        if not assets_dict:\n            logger.error(\"No assets found in the source directories. Exiting module...\")\n            return\n        media_dict: Dict[str, List[Dict[str, Any]]] = {\n            \"movies\": [],\n            \"series\": [],\n            \"collections\": [],\n        }\n\n        if config.instances:\n            for instance in config.instances:\n                if isinstance(instance, dict):\n                    instance_name, instance_settings = next(iter(instance.items()))\n                else:\n                    instance_name = instance\n                    instance_settings = {}\n                found = False\n                for instance_type, instance_data in config.instances_config.items():\n                    if instance_name in instance_data:\n                        found = True\n                        break\n                if not found:\n                    logger.warning(\n                        f\"Instance '{instance_name}' not found in config.instances_config. Skipping.\"\n                    )\n                    continue\n                if instance_type == \"plex\":\n                    url = instance_data[instance_name][\"url\"]\n                    api = instance_data[instance_name][\"api\"]\n                    try:\n                        app = PlexServer(url, api)\n                    except Exception as e:\n                        logger.error(f\"Error connecting to Plex: {e}\")\n                        app = None\n                    if app:\n                        library_names = instance_settings.get(\"library_names\", [])\n                        if library_names:\n                            logger.info(\"Fetching Plex collections...\")\n                            results = get_plex_data(\n                                app,\n                                library_names,\n                                logger,\n                                include_smart=True,\n                                collections_only=True,\n                            )\n                            media_dict[\"collections\"].extend(results)\n                        else:\n                            logger.warning(\n                                \"No library names specified for Plex instance. Skipping Plex.\"\n                            )\n                else:\n                    url = instance_data[instance_name][\"url\"]\n                    api = instance_data[instance_name][\"api\"]\n                    app = create_arr_client(url, api, logger)\n                    if app and app.connect_status:\n                        logger.info(f\"Fetching {app.instance_name} data...\")\n                        results = app.get_parsed_media(include_episode=False)\n                        if results:\n                            if instance_type == \"radarr\":\n                                media_dict[\"movies\"].extend(results)\n                            elif instance_type == \"sonarr\":\n                                media_dict[\"series\"].extend(results)\n                        else:\n                            logger.error(f\"No {instance_type.capitalize()} data found.\")\n        else:\n            logger.error(\"No instances found. Exiting module...\")\n            return\n        if not any(media_dict.values()):\n            logger.error(\n                \"No media found, Check instances setting in your config. Exiting.\"\n            )\n            return\n        renamed_assets = None\n        if media_dict and prefix_index:\n            logger.info(\"Matching assets to media, please wait...\")\n            matched_assets = match_assets_to_media(\n                media_dict,\n                prefix_index,\n                logger,\n                return_unmatched_assets=False,\n                config=config,\n            )\n        if matched_assets and any(matched_assets.values()):\n            # Optionally deep copy to strip heavy keys for debug (example for 'seasons')\n            matched_assets_copy = copy.deepcopy(matched_assets)\n            for media_type, media_list in matched_assets_copy.items():\n                for media in media_list:\n                    if \"seasons\" in media:\n                        del media[\"seasons\"]\n            if config.log_level == \"debug\":\n                print_json(assets_dict, logger, config.module_name, \"assets_dict\")\n                print_json(media_dict, logger, config.module_name, \"media_dict\")\n                print_json(prefix_index, logger, config.module_name, \"prefix_index\")\n                print_json(\n                    matched_assets_copy, logger, config.module_name, \"matched_assets\"\n                )\n            output, renamed_files = rename_files(matched_assets, config, logger)\n            if any(output.values()):\n                handle_output(output, config, logger)\n                send_notification(\n                    logger=logger,\n                    module_name=config.module_name,\n                    config=config,\n                    output=output,\n                )\n            else:\n                logger.info(\"No new posters to rename.\")\n        else:\n            logger.info(\"No assets matched to media.\")\n        if config.run_border_replacerr:\n            tmp_dir = os.path.join(config.destination_dir, \"tmp\")\n            from modules.border_replacerr import process_files\n            from util.config import Config\n            from util.scanner import process_selected_files\n\n            replacerr_config = Config(\"border_replacerr\").module_config\n            # Simplified conditional logic for incremental/full run\n            if config.incremental_border_replacerr:\n                if renamed_files:\n                    renamed_assets = process_selected_files(\n                        renamed_files, logger, asset_folders=config.asset_folders\n                    )\n                    logger.info(\n                        \"\\nDoing an incremental run on only assets that were provided\\nStarting Border Replacerr...\\n\"\n                    )\n                    process_files(\n                        tmp_dir,\n                        config=replacerr_config,\n                        logger=None,\n                        renamerr_config=config,\n                        renamed_assets=renamed_assets,\n                        incremental_run=True,\n                    )\n                    logger.info(\"Finished running border_replacerr\")\n                else:\n                    logger.info(\n                        \"\\nNo new assets to incrementally perform with border_replacerr.\\nSkipping Border Replacerr..\"\n                    )\n            else:\n                logger.info(\n                    \"\\nDoing a full run with Border Replacerr\\nStarting Border Replacerr...\\n\"\n                )\n                process_files(\n                    tmp_dir,\n                    config=replacerr_config,\n                    logger=None,\n                    renamerr_config=config,\n                    renamed_assets=renamed_assets,\n                    incremental_run=False,\n                )\n                logger.info(\"Finished running border_replacerr.py\")\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/renameinatorr.py",
    "content": "import re\nimport sys\nimport time\nfrom collections import defaultdict\nfrom types import SimpleNamespace\nfrom typing import Any, Dict, List\n\nfrom util.arrpy import BaseARRClient, create_arr_client\nfrom util.constants import season_regex\nfrom util.logger import Logger\nfrom util.notification import send_notification\nfrom util.utility import create_table, print_settings, progress\n\n\ndef print_output(output: Dict[str, Dict[str, Any]], logger: Logger) -> None:\n    \"\"\"\n    Print formatted output summarizing rename results for each instance.\n\n    Args:\n        output: Output results per instance.\n        logger: Logger for printing results.\n    \"\"\"\n    for instance, instance_data in output.items():\n        table = [[f\"{instance_data['server_name'].capitalize()} Rename List\"]]\n        logger.info(create_table(table))\n        for item in instance_data[\"data\"]:\n            if item[\"file_info\"] or item[\"new_path_name\"]:\n                logger.info(f\"{item['title']} ({item['year']})\")\n            # Show folder rename if present\n            if item[\"new_path_name\"]:\n                logger.info(\n                    f\"\\tFolder Renamed: {item['path_name']} -> {item['new_path_name']}\"\n                )\n            # Show file renames if present\n            if item[\"file_info\"]:\n                logger.info(\"\\tFiles:\")\n                for existing_path, new_path in item[\"file_info\"].items():\n                    logger.info(f\"\\t\\tOriginal: {existing_path}\\n\\t\\tNew: {new_path}\\n\")\n        logger.info(\"\")\n        total_items = len(instance_data[\"data\"])\n        total_rename_items = len(\n            [v[\"file_info\"] for v in instance_data[\"data\"] if v[\"file_info\"]]\n        )\n        total_folder_rename = len(\n            [v[\"new_path_name\"] for v in instance_data[\"data\"] if v[\"new_path_name\"]]\n        )\n        if any(v[\"file_info\"] or v[\"new_path_name\"] for v in instance_data[\"data\"]):\n            table = [\n                [f\"{instance_data['server_name'].capitalize()} Rename Summary\"],\n                [f\"Total Items: {total_items}\"],\n            ]\n            if any(v[\"file_info\"] for v in instance_data[\"data\"]):\n                table.append([f\"Total Renamed Items: {total_rename_items}\"])\n            if any(v[\"new_path_name\"] for v in instance_data[\"data\"]):\n                table.append([f\"Total Folder Renames: {total_folder_rename}\"])\n            logger.info(create_table(table))\n        else:\n            logger.info(f\"No items renamed in {instance_data['server_name']}.\")\n        logger.info(\"\")\n\n\ndef get_count_for_instance_type(\n    config: SimpleNamespace, instance_type: str, logger: Logger\n) -> int:\n    \"\"\"\n    Get the number of items to process for a given instance type, allowing overrides.\n\n    Args:\n        config: Configuration object.\n        instance_type: 'radarr' or 'sonarr'.\n        logger: Logger instance.\n    Returns:\n        Count limit for the instance.\n    \"\"\"\n    count = config.count\n    if instance_type == \"radarr\" and getattr(config, \"radarr_count\", None):\n        logger.debug(\n            f\"radarr_count found! overriding count from {config.count} to {config.radarr_count}\"\n        )\n        count = config.radarr_count\n    elif instance_type == \"sonarr\" and getattr(config, \"sonarr_count\", None):\n        logger.debug(\n            f\"sonarr_count found! overriding count from {config.count} to {config.sonarr_count}\"\n        )\n        count = config.sonarr_count\n    logger.info(f\"using count= {count} for instance_type= {instance_type}\")\n    return count\n\n\ndef process_instance(\n    app: BaseARRClient, instance_type: str, config: SimpleNamespace, logger: Logger\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Rename media and optionally folders for a single Radarr/Sonarr instance.\n\n    Args:\n        app: ARR API abstraction client.\n        instance_type: Instance type ('radarr' or 'sonarr').\n        config: Configuration settings.\n        logger: Logger instance.\n    Returns:\n        List of processed media items with rename results.\n    \"\"\"\n    table = [[f\"Processing {app.instance_name}\"]]\n    logger.debug(create_table(table))\n    default_batch_size: int = 100\n    instance_start_time: float = time.time()\n    media_dict: List[Dict[str, Any]] = app.get_parsed_media()\n    count: int = get_count_for_instance_type(config, instance_type, logger)\n    tag_id: Any = None\n\n    # Ignore-tag filtering: skip items with the ignore tag, if configured\n    skipped_count = 0\n    if getattr(config, \"ignore_tag\", None):\n        ignore_tag_id = app.get_tag_id_from_name(config.ignore_tag)\n        if ignore_tag_id:\n            before_count = len(media_dict)\n            media_dict = [item for item in media_dict if ignore_tag_id not in item.get(\"tags\", [])]\n            skipped_count = before_count - len(media_dict)\n            if skipped_count > 0:\n                logger.info(f\"Skipped {skipped_count} items due to ignore tag '{config.ignore_tag}'.\")\n    # Tagging logic: filter untagged, clear if all tagged\n    if getattr(config, \"tag_name\", None):\n        tag_id = app.get_tag_id_from_name(config.tag_name)\n        all_items_without_tags = None\n        if tag_id:\n            all_items_without_tags = [\n                item for item in media_dict if tag_id not in item[\"tags\"]\n            ]\n        if not all_items_without_tags:\n            media_ids = [item[\"media_id\"] for item in media_dict]\n            logger.info(\"All media is tagged. Removing tags...\")\n            app.remove_tags(media_ids, tag_id)\n            all_items_without_tags = app.get_parsed_media()\n        media_dict = all_items_without_tags\n    # Chunking behavior: single or batched\n    if not getattr(config, \"enable_batching\", False):\n        if not count:\n            chunks_to_process_this_run: List[List[Dict[str, Any]]] = [media_dict]\n        else:\n            chunks_to_process_this_run = get_chunks_for_run(media_dict, count, logger)\n            chunks_to_process_this_run = (\n                [chunks_to_process_this_run[0]] if chunks_to_process_this_run else []\n            )\n    else:\n        count = count if count else default_batch_size\n        chunks_to_process_this_run = get_chunks_for_run(media_dict, count, logger)\n    logger.info(f\"num_chunks= {len(chunks_to_process_this_run)}\")\n    final_media_dict: List[Dict[str, Any]] = []\n    chunk_progress_bar = progress(\n        chunks_to_process_this_run,\n        desc=f\"Processing batches for '{app.instance_name}'...\",\n        unit=\"items\",\n        logger=logger,\n        leave=True,\n    )\n    for chunk in chunk_progress_bar:\n        chunk_start_time: float = time.time()\n        media_dict = chunk\n        logger.debug(f\"Processing {len(media_dict)} media items in current chunk\")\n        if media_dict:\n            logger.info(\"Processing data... This may take a while.\")\n            progress_bar = progress(\n                media_dict,\n                desc=f\"Processing single batch for '{app.instance_name}'...\",\n                unit=\"items\",\n                logger=logger,\n                leave=True,\n            )\n            grouped_root_folders: Dict[str, List[int]] = defaultdict(list)\n            media_ids: List[int] = []\n            any_renamed: bool = False\n            for item in progress_bar:\n                file_info: Dict[str, str] = {}\n                rename_response = app.get_rename_list(item[\"media_id\"])\n                for items in rename_response:\n                    existing_path = items.get(\"existingPath\")\n                    new_path = items.get(\"newPath\")\n                    # Remove season info from path if present\n                    if existing_path and re.search(season_regex, existing_path):\n                        existing_path = re.sub(season_regex, \"\", existing_path)\n                    if new_path and re.search(season_regex, new_path):\n                        new_path = re.sub(season_regex, \"\", new_path)\n                    # Remove leading slashes\n                    if existing_path:\n                        existing_path = existing_path.lstrip(\"/\")\n                    if new_path:\n                        new_path = new_path.lstrip(\"/\")\n                    file_info[existing_path] = new_path\n                item[\"new_path_name\"] = None\n                item[\"file_info\"] = file_info\n                if file_info:\n                    any_renamed = True\n                media_ids.append(item[\"media_id\"])\n                if getattr(config, \"rename_folders\", False):\n                    grouped_root_folders[item[\"root_folder\"]].append(item[\"media_id\"])\n            if not getattr(config, \"dry_run\", False):\n                # Perform file renaming\n                if media_ids:\n                    app.rename_media(media_ids)\n                    if any_renamed:\n                        logger.info(f\"Refreshing {app.instance_name}...\")\n                        response = app.refresh_items(media_ids)\n                        ready = app.wait_for_command(response[\"id\"])\n                        if ready:\n                            logger.info(f\"Media refreshed on {app.instance_name}...\")\n                else:\n                    logger.info(f\"No media to rename on {app.instance_name}...\")\n                # Tagging after rename\n                if tag_id and getattr(config, \"tag_name\", None):\n                    logger.info(\n                        f\"Adding tag '{config.tag_name}' to items in {app.instance_name}...\"\n                    )\n                    app.add_tags(media_ids, tag_id)\n                # Folder rename steps\n                if getattr(config, \"rename_folders\", False) and grouped_root_folders:\n                    logger.info(f\"Renaming folders in {app.instance_name}...\")\n                    for root_folder, folder_media_ids in grouped_root_folders.items():\n                        logger.debug(f\"renaming root folder {root_folder}\")\n                        app.rename_folders(folder_media_ids, root_folder)\n                    logger.info(f\"Refreshing {app.instance_name}...\")\n                    response = app.refresh_items(media_ids)\n                    logger.info(f\"Waiting for {app.instance_name} to refresh...\")\n                    ready = app.wait_for_command(response[\"id\"])\n                    logger.info(f\"Folders renamed in {app.instance_name}...\")\n                    # Update items with new path names if changed\n                    if ready:\n                        logger.info(f\"Fetching updated data for {app.instance_name}...\")\n                        new_media_dict = app.get_parsed_media()\n                        for new_item in new_media_dict:\n                            for old_item in media_dict:\n                                if new_item[\"media_id\"] == old_item[\"media_id\"]:\n                                    logger.debug(\n                                        f\"Checking if item {new_item['media_id']} changed...\"\n                                    )\n                                    if new_item[\"path_name\"] != old_item[\"path_name\"]:\n                                        logger.debug(\n                                            f\"item {new_item['media_id']} changed from {old_item['path_name']} to {new_item['path_name']}\"\n                                        )\n                                        old_item[\"new_path_name\"] = new_item[\n                                            \"path_name\"\n                                        ]\n            final_media_dict.extend(media_dict)\n            # Output formatting: chunk timing and rename stats\n            total_renamed = sum(\n                len(i[\"file_info\"]) for i in media_dict if i.get(\"file_info\")\n            )\n            total_folder_renamed = sum(bool(i[\"new_path_name\"]) for i in media_dict)\n            logger.info(\n                f\"Chunk completed in {time.time() - chunk_start_time:.2f} seconds | \"\n                f\"Files renamed: {total_renamed} | Folders renamed: {total_folder_renamed}\"\n            )\n    logger.info(\n        f\"Finished processing {app.instance_name} in {time.time() - instance_start_time:.2f} seconds.\"\n    )\n    final_media_dict.sort(key=lambda it: it.get(\"new_path_name\") or it[\"path_name\"])\n    trimmed: List[Dict[str, Any]] = []\n    for item in final_media_dict:\n        raw_info = item.get(\"file_info\", {})\n        sorted_info = {old: raw_info[old] for old in sorted(raw_info.keys())}\n        trimmed.append(\n            {\n                \"title\": item[\"title\"],\n                \"year\": item[\"year\"],\n                \"path_name\": item[\"path_name\"],\n                \"new_path_name\": item.get(\"new_path_name\"),\n                \"file_info\": sorted_info,\n            }\n        )\n    return trimmed\n\n\ndef get_chunks_for_run(\n    media_dict: List[Dict[str, Any]], chunk_size: int, logger: Logger\n) -> List[List[Dict[str, Any]]]:\n    \"\"\"\n    Split media list into chunks of defined size.\n\n    Args:\n        media_dict: Full list of media items.\n        chunk_size: Desired chunk size.\n        logger: Logger instance.\n    Returns:\n        List of chunked lists.\n    \"\"\"\n    chunks: List[List[Dict[str, Any]]] = []\n    for i in range(0, len(media_dict), chunk_size):\n        chunks.append(media_dict[i : i + chunk_size])\n    return chunks\n\n\ndef get_untagged_chunks_for_run(\n    media_dict: List[Dict[str, Any]],\n    tag_id: int,\n    chunk_size: int,\n    all_in_single_run: bool,\n    logger: Logger,\n) -> List[List[Dict[str, Any]]]:\n    \"\"\"\n    Filter untagged media items and split into chunks.\n\n    Args:\n        media_dict: Media items.\n        tag_id: Tag ID to check.\n        chunk_size: Desired chunk size.\n        all_in_single_run: Whether to return a single chunk.\n        logger: Logger instance.\n    Returns:\n        Chunked untagged items.\n    \"\"\"\n    all_items_without_tags = [item for item in media_dict if tag_id not in item[\"tags\"]]\n    return get_chunks_for_run(all_items_without_tags, chunk_size, logger)\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"\n    Entrypoint for renameinatorr. Loads config, processes enabled instances, prints results.\n\n    Args:\n        config: Parsed config for renameinatorr.\n    \"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    try:\n        if getattr(config, \"log_level\", \"\").lower() == \"debug\":\n            print_settings(logger, config)\n        if getattr(config, \"dry_run\", False):\n            table = [[\"Dry Run\"], [\"NO CHANGES WILL BE MADE\"]]\n            logger.info(create_table(table))\n            logger.info(\"\")\n        output: Dict[str, Dict[str, Any]] = {}\n        for instance_type, instance_data in config.instances_config.items():\n            for instance in config.instances:\n                if instance in instance_data:\n                    app = create_arr_client(\n                        instance_data[instance][\"url\"],\n                        instance_data[instance][\"api\"],\n                        logger,\n                    )\n                    if app and app.connect_status:\n                        data = process_instance(app, instance_type, config, logger)\n                        output[instance] = {\n                            \"server_name\": app.instance_name,\n                            \"data\": data,\n                        }\n        if any(value[\"data\"] for value in output.values()):\n            print_output(output, logger)\n            send_notification(\n                logger=logger,\n                module_name=config.module_name,\n                config=config,\n                output=output,\n            )\n        else:\n            logger.info(\"No media items to rename.\")\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/sync_gdrive.py",
    "content": "import json\nimport os\nimport re\nimport shlex\nimport subprocess\nimport sys\nfrom shutil import which\nfrom types import SimpleNamespace\nfrom typing import List, Optional\n\nfrom util.logger import Logger\nfrom util.utility import print_settings\n\n# Load environment variables from .env file if available\ntry:\n    from dotenv import load_dotenv\n\n    load_dotenv(override=True)\nexcept ImportError:\n    pass\n\n\ndef get_rclone_path() -> str:\n    \"\"\"Find the full path to the rclone binary, checking RCLONE_PATH env var first.\"\"\"\n    # Allow override via environment variable\n    env_path = os.getenv(\"RCLONE_PATH\")\n    if env_path:\n        if os.path.isfile(env_path) and os.access(env_path, os.X_OK):\n            return env_path\n        else:\n            raise FileNotFoundError(\n                f\"RCLONE_PATH is set to '{env_path}', but it is not an executable file.\"\n            )\n    # Fallback to searching in PATH\n    rclone_path = which(\"rclone\")\n    if rclone_path is None:\n        raise FileNotFoundError(\n            \"rclone binary not found in PATH. Ensure it is installed and accessible, or set RCLONE_PATH.\"\n        )\n    return rclone_path\n\n\ndef run_rclone(config: SimpleNamespace, logger: Logger) -> None:\n    \"\"\"Run rclone sync for each configured Google Drive folder and log output.\"\"\"\n    sync_list: List[dict] = (\n        config.gdrive_list\n        if isinstance(config.gdrive_list, list)\n        else [config.gdrive_list]\n    )\n    rclone_path = get_rclone_path()\n    logger.debug(f\"Using rclone binary at: {rclone_path}\")\n\n    if config.gdrive_sa_location and not os.path.isfile(config.gdrive_sa_location):\n        logger.warning(\n            f\"\\nGoogle service account file '{config.gdrive_sa_location}' does not exist\\n\"\n            \"Please verify the path or remove it from config\\n\"\n        )\n        config.gdrive_sa_location = None\n\n    # Ensure rclone remote 'posters' exists by creating it if missing\n    try:\n        logger.debug(\"Ensuring rclone remote 'posters' exists\")\n        subprocess.run(\n            [\n                rclone_path,\n                \"config\",\n                \"create\",\n                \"posters\",\n                \"drive\",\n                \"config_is_local=false\",\n            ],\n            check=False,\n        )\n    except Exception as e:\n        logger.error(f\"Error ensuring rclone remote 'posters' exists: {e}\")\n\n    for sync_item in sync_list:\n        sync_location: Optional[str] = sync_item.get(\"location\")\n        sync_id: Optional[str] = sync_item.get(\"id\")\n\n        if not sync_location or not sync_id:\n            logger.error(\"Sync location or GDrive folder ID not provided.\")\n            continue\n\n        # Ensure local sync directory exists\n        try:\n            os.makedirs(sync_location, exist_ok=True)\n            logger.info(f\"Ensured sync location exists: {sync_location}\")\n        except OSError as e:\n            logger.error(f\"Could not create sync location '{sync_location}': {e}\")\n            continue\n\n        # Build rclone command with necessary flags and credentials\n        cmd = [\n            rclone_path,\n            \"sync\",\n            \"--drive-client-id\",\n            config.client_id or \"\",\n            \"--drive-client-secret\",\n            config.client_secret or \"\",\n            \"--drive-token\",\n            json.dumps(config.token) if config.token else \"\",\n            \"--drive-root-folder-id\",\n            sync_id,\n            \"--fast-list\",\n            \"--tpslimit=5\",\n            \"--no-update-modtime\",\n            \"--drive-use-trash=false\",\n            \"--drive-chunk-size=512M\",\n            \"--exclude=**.partial\",\n            \"--check-first\",\n            \"--bwlimit=80M\",\n            \"--size-only\",\n            \"--delete-after\",\n            \"-v\",\n        ]\n\n        if config.gdrive_sa_location:\n            cmd.extend([\"--drive-service-account-file\", config.gdrive_sa_location])\n\n        cmd.extend([\"posters:\", sync_location])\n\n        try:\n            logger.debug(\"Running rclone command:\")\n            logger.debug(\"\\n\" + \" \\\\\\n    \".join(shlex.quote(arg) for arg in cmd))\n            process = subprocess.Popen(\n                cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True\n            )\n            for line in process.stdout:\n                # Clean rclone output by removing timestamp and log level prefixes\n                cleaned_line = re.sub(\n                    r\"^\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2} (INFO|ERROR|DEBUG) *:?\",\n                    \"\",\n                    line,\n                ).strip()\n                if cleaned_line:\n                    logger.info(cleaned_line)\n            process.wait()\n            if process.returncode == 0:\n                logger.info(\"✅ RClone sync completed successfully.\")\n            else:\n                logger.error(\n                    f\"❌ RClone sync failed with return code {process.returncode}\"\n                )\n        except Exception as e:\n            logger.error(f\"Exception occurred while running rclone: {e}\")\n\n\ndef main(config: SimpleNamespace, logger: Optional[Logger] = None) -> None:\n    \"\"\"Initialize logger, optionally print config in debug mode, and run rclone sync.\"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    try:\n        if config.log_level.lower() == \"debug\":\n            print_settings(logger, config)\n        run_rclone(config, logger)\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/unmatched_assets.py",
    "content": "import copy\nimport sys\nfrom types import SimpleNamespace\nfrom typing import Dict, List, Union\n\nfrom util.arrpy import create_arr_client\nfrom util.assets import get_assets_files\nfrom util.index import create_new_empty_index\nfrom util.logger import Logger\nfrom util.match import match_media_to_assets\nfrom util.notification import send_notification\nfrom util.utility import create_table, get_plex_data, print_json, print_settings\n\ntry:\n    from plexapi.server import PlexServer\nexcept ImportError as e:\n    print(f\"ImportError: {e}\")\n    print(\"Please install the required modules with 'pip install -r requirements.txt'\")\n    exit(1)\n\n\ndef print_output(\n    unmatched_dict: Dict[str, List[Dict]],\n    media_dict: Dict[str, List[Dict]],\n    logger: Logger,\n) -> Dict[str, List[List[Union[str, int]]]]:\n    \"\"\"Print unmatched results and statistics, returning a summary table.\"\"\"\n    output = {\"unmatched_dict\": unmatched_dict}\n    asset_types = [\"movies\", \"series\", \"collections\"]\n\n    for asset_type in asset_types:\n        data_set = unmatched_dict.get(asset_type, None)\n        if data_set:\n            table = [[f\"Unmatched {asset_type.capitalize()}\"]]\n            logger.info(create_table(table))\n            if data_set:\n                for idx, item in enumerate(data_set):\n                    if idx % 10 == 0:\n                        logger.info(\n                            f\"\\t*** {asset_type.title()} {idx + 1} - {min(idx + 10, len(data_set))} ***\"\n                        )\n                        logger.info(\"\")\n                    if asset_type == \"series\":\n                        missing_seasons = item.get(\"missing_seasons\", False)\n                        missing_main = item.get(\"missing_main_poster\", False)\n                        title = item[\"title\"]\n                        year = item[\"year\"]\n                        # Combined missing info\n                        if missing_seasons and missing_main:\n                            logger.info(f\"\\t{title} ({year})\")\n                            for season in item.get(\"missing_seasons\", []):\n                                logger.info(f\"\\t\\tSeason: {season}\")\n                        elif missing_seasons:\n                            logger.info(\n                                f\"\\t{title} ({year}) (Seasons listed below have missing posters)\"\n                            )\n                            for season in item[\"missing_seasons\"]:\n                                logger.info(f\"\\t\\tSeason: {season}\")\n                        elif missing_main:\n                            logger.info(\n                                f\"\\t{title} ({year})  Main series poster missing\"\n                            )\n                    else:\n                        year = f\" ({item['year']})\" if item.get(\"year\") else \"\"\n                        logger.info(f\"\\t{item['title']}{year}\")\n                    logger.info(\"\")\n            logger.info(\"\")\n\n    # Calculate statistics for movies\n    unmatched_movies_total = len(unmatched_dict.get(\"movies\", []))\n    total_movies = len(media_dict.get(\"movies\", [])) if media_dict.get(\"movies\") else 0\n    percent_movies_complete = (\n        ((total_movies - unmatched_movies_total) / total_movies * 100)\n        if total_movies\n        else 0\n    )\n    # Calculate statistics for series (count only series with missing main poster)\n    unmatched_series_total = 0\n    for item in unmatched_dict.get(\"series\", []):\n        if item.get(\"missing_main_poster\", False):\n            unmatched_series_total += 1\n\n    total_series = len(media_dict.get(\"series\", [])) if media_dict.get(\"series\") else 0\n    series_percent_complete = (\n        ((total_series - unmatched_series_total) / total_series * 100)\n        if total_series\n        else 0\n    )\n\n    # Calculate unmatched seasons count (sum all missing season posters)\n    unmatched_seasons_total = 0\n    for item in unmatched_dict.get(\"series\", []):\n        missing_seasons = item.get(\"missing_seasons\", [])\n        unmatched_seasons_total += len(missing_seasons)\n\n    # Calculate total seasons with episodes present\n    total_seasons = 0\n    for item in media_dict.get(\"series\", []):\n        seasons = item.get(\"seasons\", None)\n        if seasons:\n            for season in seasons:\n                if season.get(\"season_has_episodes\"):\n                    total_seasons += 1\n\n    season_total_percent_complete = (\n        ((total_seasons - unmatched_seasons_total) / total_seasons * 100)\n        if total_seasons\n        else 0\n    )\n\n    # Calculate statistics for collections\n    unmatched_collections_total = len(unmatched_dict.get(\"collections\", []))\n    total_collections = (\n        len(media_dict.get(\"collections\", [])) if media_dict.get(\"collections\") else 0\n    )\n    collection_percent_complete = (\n        ((total_collections - unmatched_collections_total) / total_collections * 100)\n        if total_collections\n        else 0\n    )\n\n    # Calculate grand totals and percentage complete\n    grand_total = total_movies + total_series + total_seasons + total_collections\n    grand_unmatched_total = (\n        unmatched_movies_total\n        + unmatched_series_total\n        + unmatched_seasons_total\n        + unmatched_collections_total\n    )\n    grand_percent_complete = (\n        ((grand_total - grand_unmatched_total) / grand_total * 100)\n        if grand_total\n        else 0\n    )\n\n    logger.info(\"\")\n    logger.info(create_table([[\"Statistics\"]]))\n    table = [[\"Type\", \"Total\", \"Unmatched\", \"Percent Complete\"]]\n\n    if unmatched_dict.get(\"movies\") or media_dict.get(\"movies\"):\n        table.append(\n            [\n                \"Movies\",\n                total_movies,\n                unmatched_movies_total,\n                f\"{percent_movies_complete:.2f}%\",\n            ]\n        )\n    if unmatched_dict.get(\"series\") or media_dict.get(\"series\"):\n        table.append(\n            [\n                \"Series\",\n                total_series,\n                unmatched_series_total,\n                f\"{series_percent_complete:.2f}%\",\n            ]\n        )\n        table.append(\n            [\n                \"Seasons\",\n                total_seasons,\n                unmatched_seasons_total,\n                f\"{season_total_percent_complete:.2f}%\",\n            ]\n        )\n    if unmatched_dict.get(\"collections\") or media_dict.get(\"collections\"):\n        table.append(\n            [\n                \"Collections\",\n                total_collections,\n                unmatched_collections_total,\n                f\"{collection_percent_complete:.2f}%\",\n            ]\n        )\n\n    table.append(\n        [\n            \"Grand Total\",\n            grand_total,\n            grand_unmatched_total,\n            f\"{grand_percent_complete:.2f}%\",\n        ]\n    )\n    logger.info(create_table(table))\n    output[\"summary\"] = table\n    return output\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"Load media and assets, identify unmatched assets, and log summary statistics.\"\"\"\n    logger = Logger(config.log_level, config.module_name)\n\n    try:\n        if config.log_level.lower() == \"debug\":\n            print_settings(logger, config)\n\n        prefix_index = create_new_empty_index()\n        print(\"Gathering all the posters, please wait...\")\n        assets_dict, prefix_index = get_assets_files(\n            config.source_dirs, logger, merge=False\n        )\n        if not assets_dict:\n            return\n\n        media_dict = {\"movies\": [], \"series\": [], \"collections\": []}\n\n        if config.instances:\n            for instance in config.instances:\n                # Determine instance name and settings\n                if isinstance(instance, dict):\n                    instance_name, instance_settings = next(iter(instance.items()))\n                else:\n                    instance_name = instance\n                    instance_settings = {}\n\n                # Determine instance type and data\n                found = False\n                for instance_type, instance_data in config.instances_config.items():\n                    if instance_name in instance_data:\n                        found = True\n                        break\n                if not found:\n                    logger.warning(\n                        f\"Instance '{instance_name}' not found in config.instances_config. Skipping.\"\n                    )\n                    continue\n\n                if instance_type == \"plex\":\n                    url = instance_data[instance_name][\"url\"]\n                    api = instance_data[instance_name][\"api\"]\n                    try:\n                        app = PlexServer(url, api)\n                    except Exception as e:\n                        logger.error(f\"Error connecting to Plex: {e}\")\n                        app = None\n                    if app:\n                        library_names = instance_settings.get(\"library_names\", [])\n                        if library_names:\n                            logger.info(\"Fetching Plex collections...\")\n                            results = get_plex_data(\n                                app,\n                                library_names,\n                                logger,\n                                include_smart=True,\n                                collections_only=True,\n                            )\n                            media_dict[\"collections\"].extend(results)\n                        else:\n                            logger.warning(\n                                \"No library names specified for Plex instance. Skipping Plex.\"\n                            )\n                else:\n                    # For other instance types (e.g., radarr, sonarr), create client and get media\n                    url = instance_data[instance_name][\"url\"]\n                    api = instance_data[instance_name][\"api\"]\n                    app = create_arr_client(url, api, logger)\n                    if not app:\n                        logger.error(f\"Failed to connect to {instance_name}, skipping.\")\n                        continue\n                    if app.connect_status:\n                        logger.info(f\"Fetching {app.instance_name} data...\")\n                        results = app.get_parsed_media(include_episode=False)\n                        if results:\n                            if instance_type == \"radarr\":\n                                media_dict[\"movies\"].extend(results)\n                            elif instance_type == \"sonarr\":\n                                media_dict[\"series\"].extend(results)\n                        else:\n                            logger.error(f\"No {instance_type.capitalize()} data found.\")\n        else:\n            logger.error(\"No instances found. Exiting script...\")\n            return\n\n        if not any(media_dict.values()):\n            logger.error(\n                \"No media found, Check instances setting in your config. Exiting.\"\n            )\n            return\n\n        # Remove heavy keys for logging clarity\n        media_dict_copy = copy.deepcopy(media_dict)\n        for media_type, media_list in media_dict_copy.items():\n            for media in media_list:\n                if \"seasons\" in media:\n                    del media[\"seasons\"]\n\n        # Match assets and print output\n        if media_dict and prefix_index:\n            logger.info(\"Matching assets to media, please wait...\")\n            unmatched_dict = match_media_to_assets(\n                media_dict, prefix_index, config.ignore_root_folders, logger\n            )\n        output = print_output(unmatched_dict, media_dict, logger)\n        if any(unmatched_dict.values()):\n            if config.notifications and output:\n                logger.info(\"Sending notification...\")\n                send_notification(\n                    logger=logger,\n                    module_name=config.module_name,\n                    config=config,\n                    output=output,\n                )\n        else:\n            logger.info(\"All assets matched.\")\n\n        if config.log_level == \"debug\":\n            print_json(assets_dict, logger, config.module_name, \"assets_dict\")\n            print_json(media_dict_copy, logger, config.module_name, \"media_dict\")\n            print_json(prefix_index, logger, config.module_name, \"prefix_index\")\n            print_json(unmatched_dict, logger, config.module_name, \"unmatched_dict\")\n\n    except KeyboardInterrupt:\n        print(\"Exiting due to keyboard interrupt.\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n        return\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "modules/upgradinatorr.py",
    "content": "import sys\nfrom types import SimpleNamespace\nfrom typing import Any, Dict, List, Optional\n\nfrom util.arrpy import BaseARRClient, create_arr_client\nfrom util.logger import Logger\nfrom util.notification import send_notification\nfrom util.utility import create_table, print_settings\n\nVALID_STATUSES = {\"continuing\", \"airing\", \"ended\", \"canceled\", \"released\"}\n\n\ndef filter_media(\n    media_dict: List[Dict[str, Any]],\n    checked_tag_id: int,\n    ignore_tag_id: int,\n    count: int,\n    season_monitored_threshold: int,\n    logger: Logger,\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Filter and return media entries that are eligible for processing.\n\n    Args:\n        media_dict: List of media entries.\n        checked_tag_id: Tag ID for already-processed items.\n        ignore_tag_id: Tag ID for ignored items.\n        count: Max number of entries to process.\n        season_monitored_threshold: Minimum monitored episode percentage.\n        logger: Logger instance.\n    Returns:\n        Filtered list of media entries to process.\n    \"\"\"\n    filtered_media_dict: List[Dict[str, Any]] = []\n    filter_count: int = 0\n    for item in media_dict:\n        if filter_count == count:\n            break\n        # Filter out media that is tagged, ignored, unmonitored, or not in valid status\n        if (\n            checked_tag_id in item[\"tags\"]\n            or ignore_tag_id in item[\"tags\"]\n            or not item[\"monitored\"]\n            or item[\"status\"] not in VALID_STATUSES\n        ):\n            reasons = []\n            if checked_tag_id in item[\"tags\"]:\n                reasons.append(\"tagged\")\n            if ignore_tag_id in item[\"tags\"]:\n                reasons.append(\"ignore\")\n            if not item[\"monitored\"]:\n                reasons.append(\"unmonitored\")\n            if item[\"status\"] not in VALID_STATUSES:\n                reasons.append(f\"status={item['status']}\")\n            logger.debug(\n                f\"Skipping {item['title']} ({item['year']}), Reason: {', '.join(reasons)}\"\n            )\n            continue\n        # Disable season if monitored percentage falls below threshold\n        if item[\"seasons\"]:\n            series_monitored = False\n            for i, season in enumerate(item[\"seasons\"]):\n                monitored_count = 0\n                for episode in season[\"episode_data\"]:\n                    if episode[\"monitored\"]:\n                        monitored_count += 1\n                if len(season[\"episode_data\"]) > 0:\n                    monitored_percentage = (\n                        monitored_count / len(season[\"episode_data\"])\n                    ) * 100\n                else:\n                    logger.debug(\n                        f\"Skipping {item['title']} ({item['year']}), Season {i} unmonitored. Reason: No episodes in season.\"\n                    )\n                    continue\n                if (\n                    season_monitored_threshold is not None\n                    and monitored_percentage < season_monitored_threshold\n                ):\n                    item[\"seasons\"][i][\"monitored\"] = False\n                    logger.debug(\n                        f\"{item['title']}, Season {i} unmonitored. Reason: monitored percentage {int(monitored_percentage)}% less than season_monitored_threshold {int(season_monitored_threshold)}%\"\n                    )\n                if item[\"seasons\"][i][\"monitored\"]:\n                    series_monitored = True\n            if not series_monitored:\n                logger.debug(\n                    f\"Skipping {item['title']} ({item['year']}), Status: {item['status']}, Monitored: {item['monitored']}, Tags: {item['tags']}\"\n                )\n                continue\n        filtered_media_dict.append(item)\n        logger.info(\n            f\"Queued for upgrade: {item['title']} ({item['year']}) [ID: {item['media_id']}]\"\n        )\n        filter_count += 1\n    return filtered_media_dict\n\n\ndef process_search_response(\n    search_response: Optional[Dict[str, Any]],\n    media_id: int,\n    app: BaseARRClient,\n    logger: Logger,\n) -> None:\n    \"\"\"\n    Wait for search command to complete and log the result.\n\n    Args:\n        search_response: API response from initiating a search.\n        media_id: ID of the media being searched.\n        app: ARR client instance.\n        logger: Logger instance.\n    Returns:\n        None\n    \"\"\"\n    if search_response:\n        logger.debug(\n            f\"    [CMD] Waiting for command to complete for search response ID: {search_response['id']}\"\n        )\n        ready = app.wait_for_command(search_response[\"id\"])\n        if ready:\n            logger.debug(\n                f\"    [CMD] Command completed successfully for search response ID: {search_response['id']}\"\n            )\n        else:\n            logger.debug(\n                f\"    [CMD] Command did not complete successfully for search response ID: {search_response['id']}\"\n            )\n    else:\n        logger.warning(f\"No search response for media ID: {media_id}\")\n\n\ndef process_queue(\n    queue: Dict[str, Any], instance_type: str, media_ids: List[int]\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Extract download records for matching media IDs from the queue.\n\n    Args:\n        queue: Queue data from the API.\n        instance_type: \"radarr\" or \"sonarr\".\n        media_ids: List of media IDs to filter.\n    Returns:\n        List of dicts with download info for matching media IDs.\n    \"\"\"\n    id_type = \"movieId\" if instance_type == \"radarr\" else \"seriesId\"\n    queue_dict: List[Dict[str, Any]] = []\n    records = queue.get(\"records\", [])\n    for item in records:\n        media_id = item.get(id_type)\n        if media_id not in media_ids:\n            continue\n        # Only add if 'downloadId' exists in the item\n        if \"downloadId\" not in item:\n            continue\n        queue_dict.append(\n            {\n                \"download_id\": item[\"downloadId\"],\n                \"media_id\": media_id,\n                \"download\": item.get(\"title\"),\n                \"torrent_custom_format_score\": item.get(\"customFormatScore\"),\n            }\n        )\n    # Remove duplicate download records\n    queue_dict = [dict(t) for t in {tuple(d.items()) for d in queue_dict}]\n    return queue_dict\n\n\ndef process_instance(\n    instance_type: str,\n    instance_settings: Dict[str, Any],\n    app: BaseARRClient,\n    logger: Logger,\n    config: SimpleNamespace,\n) -> Optional[Dict[str, Any]]:\n    \"\"\"\n    Process a single instance: filter media, trigger searches, tag media, and gather results.\n\n    Args:\n        instance_type: \"radarr\" or \"sonarr\".\n        instance_settings: Instance-specific settings from config.\n        app: ARR client instance.\n        logger: Logger instance.\n        config: Global config.\n    Returns:\n        Dictionary of summary and media results, or None.\n    \"\"\"\n    tagged_count: int = 0\n    untagged_count: int = 0\n    total_count: int = 0\n    count: int = instance_settings.get(\"count\", 2)\n    checked_tag_name: str = instance_settings.get(\"tag_name\", \"checked\")\n    ignore_tag_name: str = instance_settings.get(\"ignore_tag\", \"ignore\")\n    unattended: bool = instance_settings.get(\"unattended\", False)\n    season_monitored_threshold: int = instance_settings.get(\n        \"season_monitored_threshold\", 0\n    )\n\n    logger.info(f\"Gathering media from {app.instance_name} ({instance_type})\")\n    # Set default for season_monitored_threshold to 1 if not provided\n    if season_monitored_threshold is None:\n        logger.warning(\n            f\"No 'season_monitored_threshold' provided for {app.instance_name}. Defaulting to 1.\"\n        )\n        season_monitored_threshold = 1\n    media_dict: List[Dict[str, Any]] = (\n        app.get_parsed_media(include_episode=True)\n        if app.instance_type.lower() == \"sonarr\"\n        else app.get_parsed_media()\n    )\n    ignore_tag_id = None\n    checked_tag_id: int = app.get_tag_id_from_name(checked_tag_name)\n    if ignore_tag_name:\n        ignore_tag_id: int = app.get_tag_id_from_name(ignore_tag_name)\n\n    filtered_media_dict: List[Dict[str, Any]] = filter_media(\n        media_dict,\n        checked_tag_id,\n        ignore_tag_id,\n        count,\n        season_monitored_threshold,\n        logger,\n    )\n    if not filtered_media_dict and unattended:\n        logger.info(\n            f\"All media for {app.instance_name} is already tagged—removing tags for unattended operation.\"\n        )\n        media_ids = [item[\"media_id\"] for item in media_dict]\n        logger.info(\"All media is tagged. Removing tags...\")\n        app.remove_tags(media_ids, checked_tag_id)\n        media_dict = (\n            app.get_parsed_media(include_episode=True)\n            if app.instance_type.lower() == \"sonarr\"\n            else app.get_parsed_media()\n        )\n        filtered_media_dict = filter_media(\n            media_dict,\n            checked_tag_id,\n            ignore_tag_id,\n            count,\n            season_monitored_threshold,\n            logger,\n        )\n\n    if not filtered_media_dict and not unattended:\n        logger.info(f\"No media left to process for {app.instance_name}.\")\n        logger.warning(\n            f\"No media found for {app.instance_name}. Reason: nothing left to tag.\"\n        )\n        return None\n\n    logger.debug(f\"Filtered media count: {len(filtered_media_dict)}\")\n    if media_dict:\n        total_count = len(media_dict)\n        for item in media_dict:\n            if checked_tag_id in item[\"tags\"]:\n                tagged_count += 1\n            else:\n                untagged_count += 1\n\n    output_dict: Dict[str, Any] = {\n        \"server_name\": app.instance_name,\n        \"tagged_count\": tagged_count,\n        \"untagged_count\": untagged_count,\n        \"total_count\": total_count,\n        \"data\": [],\n    }\n\n    if not config.dry_run:\n        search_count: int = 0\n        media_ids: List[int] = [item[\"media_id\"] for item in filtered_media_dict]\n        # Search logic: trigger searches and tag after search\n        for item in filtered_media_dict:\n            logger.debug(\"\")  # Blank line before block\n            logger.debug(\"═\" * 70)\n            logger.debug(\n                f\"[PROCESSING] {item['title']} ({item['year']}) | ID: {item['media_id']}\"\n            )\n            logger.debug(\"═\" * 70)\n\n            if item[\"seasons\"] is None:\n                logger.debug(\n                    f\"Searching media without seasons for media ID: {item['media_id']}\"\n                )\n                search_response = app.search_media(item[\"media_id\"])\n                process_search_response(search_response, item[\"media_id\"], app, logger)\n                logger.debug(\n                    f\"  [TAG] Adding tag {checked_tag_id} to media ID: {item['media_id']}\"\n                )\n                app.add_tags(item[\"media_id\"], checked_tag_id)\n                search_count += 1\n                if search_count >= count:\n                    logger.debug(\n                        f\"🔁 Reached search count limit after non-season search ({search_count} >= {count}), breaking.\"\n                    )\n                    logger.debug(\"─\" * 70)\n                    logger.debug(f\"[END] Finished: {item['title']} ({item['year']}) | ID: {item['media_id']}\")\n                    logger.debug(\"─\" * 70)\n                    logger.debug(\"\")\n                    break\n            else:\n                searched = False\n                for season in item[\"seasons\"]:\n                    if season[\"monitored\"]:\n                        logger.debug(\n                            f\"  [SEASON] {season['season_number']}: Searching...\"\n                        )\n                        search_response = app.search_season(\n                            item[\"media_id\"], season[\"season_number\"]\n                        )\n                        process_search_response(\n                            search_response, item[\"media_id\"], app, logger\n                        )\n                        searched = True\n\n                if searched:\n                    logger.debug(\n                        f\"  [TAG] Adding tag {checked_tag_id} to media ID: {item['media_id']}\"\n                    )\n                    app.add_tags(item[\"media_id\"], checked_tag_id)\n                    search_count += 1\n                    if search_count >= count:\n                        logger.debug(\n                            f\"🔁 Reached series-based search count limit ({search_count} >= {count}), breaking.\"\n                        )\n                        logger.debug(\"─\" * 70)\n                        logger.debug(f\"[END] Finished: {item['title']} ({item['year']}) | ID: {item['media_id']}\")\n                        logger.debug(\"─\" * 70)\n                        logger.debug(\"\")\n                        break\n\n            logger.debug(\"─\" * 70)\n            logger.debug(f\"[END] Finished: {item['title']} ({item['year']}) | ID: {item['media_id']}\")\n            logger.debug(\"─\" * 70)\n            logger.debug(\"\")  # Blank line after block\n            logger.info(f\"Finished processing: {item['title']} ({item['year']})\")\n\n        logger.info(\n            f\"Completed upgrade operations for {app.instance_name}. Now retrieving download queue...\"\n        )\n        queue = app.get_queue()\n        logger.debug(f\"Queue item count: {len(queue.get('records', []))}\")\n        queue_dict: List[Dict[str, Any]] = process_queue(\n            queue, instance_type, media_ids\n        )\n        logger.debug(f\"Queue dict item count: {len(queue_dict)}\")\n\n        queue_map: Dict[int, List[Dict[str, Any]]] = {}\n        for q in queue_dict:\n            queue_map.setdefault(q[\"media_id\"], []).append(q)\n\n        for item in filtered_media_dict:\n            # Downloads are processed per media_id from the queue\n            downloads = {\n                q[\"download\"]: q[\"torrent_custom_format_score\"]\n                for q in queue_map.get(item[\"media_id\"], [])\n            }\n            output_dict[\"data\"].append(\n                {\n                    \"media_id\": item[\"media_id\"],\n                    \"title\": item[\"title\"],\n                    \"year\": item[\"year\"],\n                    \"download\": downloads,\n                }\n            )\n    else:\n        for item in filtered_media_dict:\n            output_dict[\"data\"].append(\n                {\n                    \"media_id\": item[\"media_id\"],\n                    \"title\": item[\"title\"],\n                    \"year\": item[\"year\"],\n                    \"download\": None,\n                    \"torrent_custom_format_score\": None,\n                }\n            )\n    return output_dict\n\n\ndef print_output(output_dict: Dict[str, Any], logger: Logger) -> None:\n    \"\"\"\n    Print a human-readable summary of upgrade results for each instance.\n\n    Args:\n        output_dict: Mapping of instance name to media results.\n        logger: Logger instance.\n    Returns:\n        None\n    \"\"\"\n    for instance, run_data in output_dict.items():\n        if run_data:\n            instance_data = run_data.get(\"data\", None)\n            if instance_data:\n                table = [[f\"{run_data['server_name']}\"]]\n                logger.info(create_table(table))\n                logger.info(\n                    f\"Upgrade summary for {run_data['server_name']}: {run_data.get('untagged_count', 0)} untagged, {run_data.get('tagged_count', 0)} tagged, {run_data.get('total_count', 0)} total.\"\n                )\n                for item in instance_data:\n                    logger.info(f\"{item['title']} ({item['year']})\")\n                    if item[\"download\"]:\n                        for download, format_score in item[\"download\"].items():\n                            logger.info(f\"\\t{download}\\tScore: {format_score}\")\n                    else:\n                        logger.info(\"\\tNo upgrades found for this item.\")\n                    logger.info(\"\")\n            else:\n                logger.info(f\"No items found for {instance}.\")\n\n\ndef main(config: SimpleNamespace) -> None:\n    \"\"\"\n    Entrypoint for upgradinatorr. Loads config, processes instances, prints results, and sends notifications.\n\n    Args:\n        config: Loaded configuration object.\n    Returns:\n        None\n    \"\"\"\n    logger = Logger(config.log_level, config.module_name)\n    try:\n        if config.log_level.lower() == \"debug\":\n            print_settings(logger, config)\n        if config.dry_run:\n            table = [[\"Dry Run\"], [\"NO CHANGES WILL BE MADE\"]]\n            logger.info(create_table(table))\n        if not getattr(config, \"instances_list\", None):\n            logger.error(\"No instances found in config file.\")\n            sys.exit()\n        final_output_dict: Dict[str, Any] = {}\n        for instance_entry in config.instances_list:\n            instance_name = instance_entry.get(\"instance\")\n            if not instance_name:\n                continue\n            for instance_type, instance_data in config.instances_config.items():\n                if instance_name in instance_data:\n                    url = instance_data[instance_name][\"url\"]\n                    api = instance_data[instance_name][\"api\"]\n                    app = create_arr_client(url, api, logger)\n                    if app and app.connect_status:\n                        output = process_instance(\n                            instance_type, instance_entry, app, logger, config\n                        )\n                        final_output_dict.setdefault(instance_name, {}).update(\n                            output or {}\n                        )\n        logger.debug(f\"Processed instances: {list(final_output_dict.keys())}\")\n        if final_output_dict:\n            print_output(final_output_dict, logger)\n            send_notification(\n                logger=logger,\n                module_name=config.module_name,\n                config=config,\n                output=final_output_dict,\n            )\n    except KeyboardInterrupt:\n        print(\"Keyboard Interrupt detected. Exiting...\")\n        sys.exit()\n    except Exception:\n        logger.error(\"\\n\\nAn error occurred:\\n\", exc_info=True)\n        logger.error(\"\\n\\n\")\n    finally:\n        # Log outro message with run time\n        logger.log_outro()\n"
  },
  {
    "path": "requirements.txt",
    "content": "annotated-types==0.7.0\nanyio==4.9.0\napprise==1.8.0\nblinker==1.8.2\ncertifi==2025.1.31\ncharset-normalizer==3.4.1\nclick==8.1.7\ncroniter==6.0.0\ndotenv==0.9.9\nfastapi==0.115.12\nh11==0.14.0\nidna==3.10\niniconfig==2.1.0\nitsdangerous==2.2.0\nJinja2==3.1.4\nMarkdown==3.6\nMarkupSafe==2.1.5\nmypy_extensions==1.1.0\noauthlib==3.2.2\npackaging==24.0\npathspec==0.12.1\npathvalidate==3.2.3\npillow==11.2.1\nplatformdirs==4.3.8\nPlexAPI==4.16.1\npluggy==1.5.0\nprettytable==3.16.0\npydantic==2.11.3\npydantic_core==2.33.1\npytest==8.3.5\npython-dateutil==2.9.0.post0\npython-dotenv==1.1.0\npytz==2025.2\nPyYAML==6.0.2\nqbittorrent-api==2024.3.60\nratelimit==2.2.1\nrequests==2.32.3\nrequests-oauthlib==2.0.0\nruamel.yaml.clib==0.2.8\nsix==1.17.0\nsniffio==1.3.1\nstarlette==0.46.2\ntqdm==4.67.1\ntyping-inspection==0.4.0\ntyping_extensions==4.13.2\nUnidecode==1.3.8\nurllib3==2.4.0\nuvicorn==0.34.2\nwatchdog==6.0.0\nwcwidth==0.2.13\nWerkzeug==3.0.3\n"
  },
  {
    "path": "start.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nPUID=${PUID:-99}\nPGID=${PGID:-100}\nUMASK=${UMASK:-002}\nBRANCH=${BRANCH:-master}\n\nexport RCLONE_CONFIG=\"${CONFIG_DIR}/rclone/rclone.conf\"\n\nVERSION=$(cat \"$(dirname \"$0\")/VERSION\")\n\necho \"\n---------------------------------------------------------\n     _____          _____   _____ \n    |  __ \\   /\\   |  __ \\ / ____|\n    | |  | | /  \\  | |__) | (___  \n    | |  | |/ /\\ \\ |  ___/ \\___ \\ \n    | |__| / ____ \\| |     ____) |\n    |_____/_/    \\_\\_|    |_____/ \n     (Drazzilb's Arr PMM Scripts)\n\n        PUID:           ${PUID}\n        PGID:           ${PGID}\n        UMASK:          ${UMASK}\n        BRANCH:         ${BRANCH}\n        DOCKER:         ${DOCKER_ENV}\n        VERSION:        ${VERSION}\n        CONFIG_DIR:     ${CONFIG_DIR}\n        RCLONE_CONFIG:  ${RCLONE_CONFIG}\n        APPDATA Path:   ${APPDATA_PATH}\n        LOG_DIR:        ${LOG_DIR}\n---------------------------------------------------------\n\"\n\necho \"Setting umask to ${UMASK}\"\numask \"$UMASK\"\n\ngroupmod -o -g \"$PGID\" dockeruser\nusermod -o -u \"$PUID\" dockeruser\n\necho \"Starting daps as $(whoami) with UID: $PUID and GID: $PGID\"\n\nchown -R \"${PUID}:${PGID}\" \"${CONFIG_DIR}\" /app\nchmod -R 777 \"${CONFIG_DIR}\"\n[ -f \"${CONFIG_DIR}/config.yml\" ] && chmod 660 \"${CONFIG_DIR}/config.yml\"\n\nexec su -s /bin/bash -c \"python3 main.py\" dockeruser"
  },
  {
    "path": "util/__init__.py",
    "content": ""
  },
  {
    "path": "util/arrpy.py",
    "content": "import html\nimport logging\nimport os\nimport time\nfrom typing import Any, Dict, List, Optional, Union\n\nimport requests\nfrom unidecode import unidecode\n\nfrom util.constants import windows_path_regex, year_regex\nfrom util.extract import extract_year\nfrom util.normalization import normalize_titles\n\nlogging.getLogger(\"requests\").setLevel(logging.WARNING)\n\n\nclass BaseARRClient:\n    \"\"\"Base class for interacting with ARR (Radarr/Sonarr) instances.\"\"\"\n\n    def __init__(self, url: str, api: str, logger: Any) -> None:\n        \"\"\"\n        Initialize the base ARR client.\n\n        Args:\n            url (str): API URL.\n            api (str): API key.\n            logger (Any): Logger instance.\n        \"\"\"\n        self.logger = logger\n        self.max_retries = 5\n        self.timeout = 60\n        self.url = url.rstrip(\"/\")\n        self.api = api\n        self.headers = {\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n            \"X-Api-Key\": api,\n        }\n        self.session = requests.Session()\n        self.session.headers.update({\"X-Api-Key\": self.api})\n        self.connect_status = False\n        self.instance_type = None\n        self.instance_name = None\n        self.app_name = None\n        self.app_version = None\n        status = self.get_system_status()\n        if not status:\n            return\n        self.app_name = status.get(\"appName\")\n        self.app_version = status.get(\"version\")\n        self.instance_name = status.get(\"instanceName\")\n        self.connect_status = True\n        self.logger.debug(\n            f\"Connected to {self.app_name} v{self.app_version} at {self.url}\"\n        )\n\n    def get_health(self) -> Optional[Dict[str, Any]]:\n        \"\"\"\n        Get the health status of the ARR instance.\n\n        Returns:\n            Optional[Dict[str, Any]]: Health status.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/health\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def wait_for_command(self, command_id: int) -> bool:\n        \"\"\"\n        Poll the given command ID until it completes, fails, or times out.\n\n        Args:\n            command_id (int): Command ID to wait for.\n        Returns:\n            bool: True if successful, False otherwise.\n        \"\"\"\n        self.logger.debug(\"Waiting for command to complete...\")\n        cycle = 0\n        while True:\n            endpoint = f\"{self.url}/api/v3/command/{command_id}\"\n            response = self.make_get_request(endpoint)\n            if response and response.get(\"status\") == \"completed\":\n                return True\n            if response and response.get(\"status\") == \"failed\":\n                return False\n            time.sleep(5)\n            cycle += 1\n            if cycle % 5 == 0:\n                self.logger.debug(\n                    f\"Still waiting for command {command_id}... (cycle {cycle})\"\n                )\n            if cycle > 120:\n                self.logger.error(f\"Command {command_id} timed out after 10 minutes.\")\n                return False\n\n    def create_tag(self, tag: str) -> int:\n        \"\"\"\n        Create a new tag.\n\n        Args:\n            tag (str): Tag label.\n        Returns:\n            int: Created tag ID.\n        \"\"\"\n        payload = {\"label\": tag}\n        self.logger.debug(f\"Create tag payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/tag\"\n        response = self.make_post_request(endpoint, json=payload)\n        return response[\"id\"]\n\n    def get_instance_name(self) -> Optional[str]:\n        \"\"\"\n        Get instance name.\n\n        Returns:\n            Optional[str]: Instance name.\n        \"\"\"\n        status = self.get_system_status()\n        return status.get(\"instanceName\") if status else None\n\n    def get_system_status(self) -> Optional[Dict[str, Any]]:\n        \"\"\"\n        Get ARR system status.\n\n        Returns:\n            Optional[Dict[str, Any]]: System status.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/system/status\"\n        return self.make_get_request(endpoint)\n\n    def make_get_request(\n        self, endpoint: str, headers: Optional[Dict[str, str]] = None\n    ) -> Any:\n        \"\"\"\n        Make a GET request to endpoint.\n\n        Args:\n            endpoint (str): API endpoint.\n            headers (Optional[Dict[str, str]]): Headers.\n        Returns:\n            Any: Response or JSON.\n        \"\"\"\n        return self._request_with_retries(\"GET\", endpoint, headers=headers)\n\n    def make_post_request(\n        self, endpoint: str, headers: Optional[Dict[str, str]] = None, json: Any = None\n    ) -> Any:\n        \"\"\"\n        Make a POST request to endpoint.\n\n        Args:\n            endpoint (str): API endpoint.\n            headers (Optional[Dict[str, str]]): Headers.\n            json (Any): JSON payload.\n        Returns:\n            Any: Response or JSON.\n        \"\"\"\n        return self._request_with_retries(\"POST\", endpoint, headers=headers, json=json)\n\n    def make_put_request(\n        self, endpoint: str, headers: Optional[Dict[str, str]] = None, json: Any = None\n    ) -> Any:\n        \"\"\"\n        Make a PUT request to endpoint.\n\n        Args:\n            endpoint (str): API endpoint.\n            headers (Optional[Dict[str, str]]): Headers.\n            json (Any): JSON payload.\n        Returns:\n            Any: Response or JSON.\n        \"\"\"\n        return self._request_with_retries(\"PUT\", endpoint, headers=headers, json=json)\n\n    def make_delete_request(self, endpoint: str, json: Any = None) -> Any:\n        \"\"\"\n        Make a DELETE request to endpoint.\n\n        Args:\n            endpoint (str): API endpoint.\n            json (Any): JSON payload.\n        Returns:\n            Any: Response or JSON.\n        \"\"\"\n        return self._request_with_retries(\"DELETE\", endpoint, json=json)\n\n    def _request_with_retries(\n        self,\n        method: str,\n        endpoint: str,\n        headers: Optional[Dict[str, str]] = None,\n        json: Any = None,\n    ) -> Any:\n        \"\"\"\n        Perform HTTP request with retry logic.\n\n        Args:\n            method (str): HTTP method.\n            endpoint (str): API endpoint.\n            headers (Optional[Dict[str, str]]): Headers.\n            json (Any): JSON payload.\n        Returns:\n            Any: Response or JSON.\n        \"\"\"\n        response = None\n        for i in range(self.max_retries):\n            try:\n                response = self.session.request(\n                    method, endpoint, headers=headers, json=json, timeout=self.timeout\n                )\n                response.raise_for_status()\n                return response if method == \"DELETE\" else response.json()\n            except (\n                requests.exceptions.Timeout,\n                requests.exceptions.HTTPError,\n                requests.exceptions.RequestException,\n            ) as ex:\n                if i < self.max_retries - 1:\n                    self.logger.warning(\n                        f\"{method} request failed ({ex}), retrying ({i+1}/{self.max_retries})...\"\n                    )\n                    time.sleep(1)\n                else:\n                    self._handle_request_exception(method, endpoint, ex, response, json)\n        return None\n\n    def _handle_request_exception(\n        self,\n        method: str,\n        endpoint: str,\n        ex: Exception,\n        response: Any,\n        payload: Any = None,\n    ) -> None:\n        \"\"\"\n        Handle exceptions during HTTP request.\n\n        Args:\n            method (str): HTTP method.\n            endpoint (str): API endpoint.\n            ex (Exception): Exception.\n            response (Any): Response object.\n            payload (Any): Payload data.\n        \"\"\"\n        status_code = (\n            response.status_code\n            if response is not None\n            and hasattr(response, \"status_code\")\n            and response.status_code\n            else \"No response\"\n        )\n        hint = (\n            self._get_error_hint(status_code)\n            if isinstance(status_code, int)\n            else \"No HTTP response received, check URL\"\n        )\n        self.logger.error(f\"{method} request failed after {self.max_retries} retries.\")\n        self.logger.error(f\"Endpoint: {endpoint}\")\n        if payload:\n            self.logger.error(f\"Payload: {payload}\")\n        if response is not None and hasattr(response, \"text\"):\n            self.logger.error(f\"Response: {response.text} Code: {status_code}\")\n        self.logger.error(f\"Status: {status_code}, Error: {ex}\")\n        self.logger.error(f\"\\nHint: {hint}\\n\")\n\n    def _get_error_hint(self, status_code: int) -> str:\n        \"\"\"\n        Get a user-friendly hint for a given HTTP status code.\n\n        Args:\n            status_code (int): HTTP status code.\n        Returns:\n            str: Hint.\n        \"\"\"\n        hints = {\n            400: \"Bad Request – likely malformed or missing parameters.\",\n            401: \"Unauthorized – check that your API key is correct.\",\n            403: \"Forbidden – the API key may not have the necessary permissions.\",\n            404: \"Not Found – the endpoint may be incorrect or the resource doesn't exist.\",\n            429: \"Too Many Requests – you may have hit a rate limit.\",\n            500: \"Internal Server Error – something went wrong on the server.\",\n            503: \"Service Unavailable – the server is currently down or overloaded.\",\n        }\n        return hints.get(status_code, \"Unknown error – check logs for more info.\")\n\n    def get_tag_id_from_name(self, tag_name: str) -> int:\n        \"\"\"\n        Retrieve a tag ID by its name, create if not exists.\n\n        Args:\n            tag_name (str): Tag name.\n        Returns:\n            int: Tag ID.\n        \"\"\"\n        all_tags = self.get_all_tags() or []\n        tag_name = tag_name.lower()\n        for tag in all_tags:\n            if tag[\"label\"] == tag_name:\n                tag_id = tag[\"id\"]\n                return tag_id\n        tag_id = self.create_tag(tag_name)\n        return tag_id\n\n    def get_all_tags(self) -> Optional[List[Dict[str, Any]]]:\n        \"\"\"\n        Get all tags from the ARR instance.\n\n        Returns:\n            Optional[List[Dict[str, Any]]]: List of tags.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/tag\"\n        return self.make_get_request(endpoint)\n\n    def get_quality_profile_names(self) -> Optional[Dict[str, int]]:\n        \"\"\"\n        Get names and IDs of all quality profiles.\n\n        Returns:\n            Optional[Dict[str, int]]: Mapping of profile names to IDs.\n        \"\"\"\n        dict_of_names_and_ids: Dict[str, int] = {}\n        endpoint = f\"{self.url}/api/v3/qualityprofile\"\n        response = self.make_get_request(endpoint, headers=self.headers)\n        if response:\n            for profile in response:\n                dict_of_names_and_ids[profile[\"name\"]] = profile[\"id\"]\n            return dict_of_names_and_ids\n\n\nclass RadarrClient(BaseARRClient):\n    \"\"\"Client for interacting with Radarr API.\"\"\"\n\n    def __init__(self, url: str, api: str, logger: Any) -> None:\n        \"\"\"\n        Initialize the Radarr client.\n\n        Args:\n            url (str): API URL.\n            api (str): API key.\n            logger (Any): Logger instance.\n        \"\"\"\n        super().__init__(url, api, logger)\n        self.instance_type = \"Radarr\"\n\n    def get_media(self) -> Optional[List[Dict[str, Any]]]:\n        \"\"\"\n        Get all movies from Radarr.\n\n        Returns:\n            Optional[List[Dict[str, Any]]]: List of movies.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/movie\"\n        return self.make_get_request(endpoint)\n\n    def add_tags(self, media_id: Union[int, List[int]], tag_id: int) -> Any:\n        \"\"\"\n        Add a tag to one or more movies.\n        Args:\n            media_id (Union[int, List[int]]): Movie ID(s).\n            tag_id (int): Tag ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        if isinstance(media_id, int):\n            media_id = [media_id]\n        payload = {\"movieIds\": media_id, \"tags\": [tag_id], \"applyTags\": \"add\"}\n        self.logger.debug(f\"Add tag payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/movie/editor\"\n        return self.make_put_request(endpoint, json=payload)\n\n    def remove_tags(self, media_ids: List[int], tag_id: int) -> Any:\n        \"\"\"\n        Remove a tag from movies.\n        Args:\n            media_ids (List[int]): Movie IDs.\n            tag_id (int): Tag ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\"movieIds\": media_ids, \"tags\": [tag_id], \"applyTags\": \"remove\"}\n        self.logger.debug(f\"Remove tag payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/movie/editor\"\n        return self.make_put_request(endpoint, json=payload)\n\n    def get_rename_list(self, media_id: int) -> Any:\n        \"\"\"\n        Preview renaming for a movie.\n        Args:\n            media_id (int): Movie ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/rename?movieId={media_id}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def rename_media(self, media_ids: List[int]) -> Any:\n        \"\"\"\n        Trigger renaming of movies.\n        Args:\n            media_ids (List[int]): Movie IDs.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\n            \"name\": \"RenameMovie\",\n            \"movieIds\": media_ids,\n        }\n        self.logger.debug(f\"Rename payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/command\"\n        return self.make_post_request(endpoint, json=payload)\n\n    def rename_folders(self, media_ids: List[int], root_folder_path: str) -> Any:\n        \"\"\"\n        Rename folders for given movies.\n        Args:\n            media_ids (List[int]): Movie IDs.\n            root_folder_path (str): Root folder path.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\n            \"movieIds\": media_ids,\n            \"moveFiles\": True,\n            \"rootFolderPath\": root_folder_path,\n        }\n        self.logger.debug(f\"Rename Folder Payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/movie/editor\"\n        return self.make_put_request(endpoint, json=payload)\n\n    def refresh_items(self, media_ids: Union[int, List[int]]) -> Any:\n        \"\"\"\n        Refresh one or more movies.\n        Args:\n            media_ids (Union[int, List[int]]): Movie IDs.\n        Returns:\n            Any: API response.\n        \"\"\"\n        if isinstance(media_ids, int):\n            media_ids = [media_ids]\n        payload = {\"name\": \"RefreshMovie\", \"movieIds\": media_ids}\n        self.logger.debug(f\"Refresh payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/command\"\n        return self.make_post_request(endpoint, headers=self.headers, json=payload)\n\n    def refresh_media(self) -> Any:\n        \"\"\"\n        Refresh all movies.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\n            \"name\": \"RefreshMovie\",\n        }\n        self.logger.debug(f\"Refresh payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/command\"\n        return self.make_post_request(endpoint, headers=self.headers, json=payload)\n\n    def search_media(self, media_ids: Union[int, List[int]]) -> Optional[Any]:\n        \"\"\"\n        Trigger a search for one or more movies.\n        Args:\n            media_ids (Union[int, List[int]]): Movie IDs.\n        Returns:\n            Optional[Any]: API response or None if search fails.\n        \"\"\"\n        self.logger.debug(f\"Media ID: {media_ids}\")\n        endpoint = f\"{self.url}/api/v3/command\"\n        payloads = []\n        if isinstance(media_ids, int):\n            media_ids = [media_ids]\n        payloads.append({\"name\": \"MoviesSearch\", \"movieIds\": media_ids})\n        self.logger.debug(f\"Search payload: {payloads}\")\n        result = None\n        for payload in payloads:\n            result = self.make_post_request(\n                endpoint, headers=self.headers, json=payload\n            )\n        if result:\n            return result\n        else:\n            self.logger.error(f\"Search failed for media ID: {media_ids}\")\n            return None\n\n    def get_movie_data(self, media_id: int) -> Any:\n        \"\"\"\n        Get movie file data for a specific movie.\n        Args:\n            media_id (int): Movie ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/moviefile?movieId={media_id}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_grab_history(self, media_id: int) -> Any:\n        \"\"\"\n        Get grab history for a movie.\n        Args:\n            media_id (int): Movie ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        url_addon = f\"movie?movieId={media_id}&eventType=grabbed&includeMovie=false\"\n        endpoint = f\"{self.url}/api/v3/history/{url_addon}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_import_history(self, media_id: int) -> Any:\n        \"\"\"\n        Get import history for a movie.\n        Args:\n            media_id (int): Movie ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        url_addon = f\"movie?movieId={media_id}&eventType=downloadFolderImported&includeMovie=false\"\n        endpoint = f\"{self.url}/api/v3/history/{url_addon}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_queue(self) -> Any:\n        \"\"\"\n        Get the current queue from Radarr.\n        Returns:\n            Any: API response.\n        \"\"\"\n        url_addon = \"page=1&pageSize=200&includeMovie=true\"\n        endpoint = f\"{self.url}/api/v3/queue?{url_addon}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def delete_media(self, media_id: int) -> Any:\n        \"\"\"\n        Delete a movie from Radarr.\n        Args:\n            media_id (int): Movie ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/movie/{media_id}\"\n        return self.make_delete_request(endpoint)\n\n    def delete_movie_file(self, media_id: int) -> Any:\n        \"\"\"\n        Delete a movie file by file ID.\n        Args:\n            media_id (int): Movie file ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/moviefile/{media_id}\"\n        return self.make_delete_request(endpoint)\n\n    def get_parsed_media(self, include_episode: bool = False) -> List[Dict[str, Any]]:\n        \"\"\"\n        Return a structured list of normalized movie items.\n\n        Args:\n            include_episode (bool): Ignored for Radarr.\n        Returns:\n            List[Dict[str, Any]]: List of normalized media entries.\n        \"\"\"\n        media_dict = []\n        media = self.get_media()\n        if not media:\n            return media_dict\n        for item in media:\n            file_id = item.get(\"movieFile\", {}).get(\"id\", None)\n            alternate_titles = [t[\"title\"] for t in item[\"alternateTitles\"]]\n            normalized_alternate_titles = [\n                normalize_titles(t[\"title\"]) for t in item[\"alternateTitles\"]\n            ]\n            if year_regex.search(item[\"title\"]):\n                title = year_regex.sub(\"\", item[\"title\"])\n                year = extract_year(item[\"title\"])\n            else:\n                title = item[\"title\"]\n                year = item[\"year\"]\n            reg = windows_path_regex.match(item[\"path\"])\n            if reg and reg.group(1):\n                folder = item[\"path\"][item[\"path\"].rfind(\"\\\\\") + 1 :]\n            else:\n                folder = os.path.basename(os.path.normpath(item[\"path\"]))\n            media_dict.append(\n                {\n                    \"title\": unidecode(html.unescape(title)),\n                    \"year\": year,\n                    \"media_id\": item[\"id\"],\n                    \"tmdb_id\": item[\"tmdbId\"],\n                    \"imdb_id\": item.get(\"imdbId\", None),\n                    \"monitored\": item[\"monitored\"],\n                    \"status\": item[\"status\"],\n                    \"root_folder\": item[\"rootFolderPath\"],\n                    \"quality_profile\": item[\"qualityProfileId\"],\n                    \"normalized_title\": normalize_titles(item[\"title\"]),\n                    \"path_name\": os.path.basename(item[\"path\"]),\n                    \"original_title\": item.get(\"originalTitle\", None),\n                    \"secondary_year\": item.get(\"secondaryYear\", None),\n                    \"alternate_titles\": alternate_titles,\n                    \"normalized_alternate_titles\": normalized_alternate_titles,\n                    \"file_id\": file_id,\n                    \"folder\": folder,\n                    \"normalized_folder\": normalize_titles(folder),\n                    \"has_file\": item[\"hasFile\"],\n                    \"tags\": item[\"tags\"],\n                    \"seasons\": None,\n                    \"season_numbers\": None,\n                }\n            )\n        return media_dict\n\n\nclass SonarrClient(BaseARRClient):\n    \"\"\"Client for interacting with Sonarr API.\"\"\"\n\n    def __init__(self, url: str, api: str, logger: Any) -> None:\n        \"\"\"\n        Initialize the Sonarr client.\n\n        Args:\n            url (str): API URL.\n            api (str): API key.\n            logger (Any): Logger instance.\n        \"\"\"\n        super().__init__(url, api, logger)\n        self.instance_type = \"Sonarr\"\n\n    def get_media(self) -> Optional[List[Dict[str, Any]]]:\n        \"\"\"\n        Get all series from Sonarr.\n\n        Returns:\n            Optional[List[Dict[str, Any]]]: List of series.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/series\"\n        return self.make_get_request(endpoint)\n\n    def add_tags(self, media_id: Union[int, List[int]], tag_id: int) -> Any:\n        \"\"\"\n        Add a tag to one or more series.\n        Args:\n            media_id (Union[int, List[int]]): Series ID(s).\n            tag_id (int): Tag ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        if isinstance(media_id, int):\n            media_id = [media_id]\n        payload = {\"seriesIds\": media_id, \"tags\": [tag_id], \"applyTags\": \"add\"}\n        self.logger.debug(f\"Add tag payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/series/editor\"\n        return self.make_put_request(endpoint, json=payload)\n\n    def remove_tags(self, media_ids: List[int], tag_id: int) -> Any:\n        \"\"\"\n        Remove a tag from series.\n        Args:\n            media_ids (List[int]): Series IDs.\n            tag_id (int): Tag ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\"seriesIds\": media_ids, \"tags\": [tag_id], \"applyTags\": \"remove\"}\n        self.logger.debug(f\"Remove tag payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/series/editor\"\n        return self.make_put_request(endpoint, json=payload)\n\n    def get_rename_list(self, media_id: int) -> Any:\n        \"\"\"\n        Preview renaming for a series.\n        Args:\n            media_id (int): Series ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/rename?seriesId={media_id}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def rename_media(self, media_ids: List[int]) -> Any:\n        \"\"\"\n        Trigger renaming of series.\n        Args:\n            media_ids (List[int]): Series IDs.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\n            \"name\": \"RenameSeries\",\n            \"seriesIds\": media_ids,\n        }\n        self.logger.debug(f\"Rename payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/command\"\n        return self.make_post_request(endpoint, json=payload)\n\n    def rename_folders(self, media_ids: List[int], root_folder_path: str) -> Any:\n        \"\"\"\n        Rename folders for given series.\n        Args:\n            media_ids (List[int]): Series IDs.\n            root_folder_path (str): Root folder path.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\n            \"seriesIds\": media_ids,\n            \"moveFiles\": True,\n            \"rootFolderPath\": root_folder_path,\n        }\n        self.logger.debug(f\"Rename Folder Payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/series/editor\"\n        return self.make_put_request(endpoint, json=payload)\n\n    def refresh_items(self, media_ids: Union[int, List[int]]) -> Any:\n        \"\"\"\n        Refresh one or more series.\n        Args:\n            media_ids (Union[int, List[int]]): Series IDs.\n        Returns:\n            Any: API response.\n        \"\"\"\n        if isinstance(media_ids, int):\n            media_ids = [media_ids]\n        payload = {\"name\": \"RefreshSeries\", \"seriesIds\": media_ids}\n        self.logger.debug(f\"Refresh payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/command\"\n        return self.make_post_request(endpoint, headers=self.headers, json=payload)\n\n    def refresh_media(self) -> Any:\n        \"\"\"\n        Refresh all series.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\n            \"name\": \"RefreshSeries\",\n        }\n        self.logger.debug(f\"Refresh payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/command\"\n        return self.make_post_request(endpoint, headers=self.headers, json=payload)\n\n    def search_media(self, media_ids: Union[int, List[int]]) -> Optional[Any]:\n        \"\"\"\n        Trigger a search for one or more series.\n        Args:\n            media_ids (Union[int, List[int]]): Series IDs.\n        Returns:\n            Optional[Any]: API response or None if search fails.\n        \"\"\"\n        self.logger.debug(f\"Media ID: {media_ids}\")\n        endpoint = f\"{self.url}/api/v3/command\"\n        payloads = []\n        if isinstance(media_ids, int):\n            media_ids = [media_ids]\n        for id in media_ids:\n            payloads.append({\"name\": \"SeriesSearch\", \"seriesId\": id})\n        self.logger.debug(f\"Search payload: {payloads}\")\n        result = None\n        for payload in payloads:\n            result = self.make_post_request(\n                endpoint, headers=self.headers, json=payload\n            )\n        if result:\n            return result\n        else:\n            self.logger.error(f\"Search failed for media ID: {media_ids}\")\n            return None\n\n    def search_season(self, media_id: int, season_number: int) -> Any:\n        \"\"\"\n        Trigger a search for a specific season of a series.\n        Args:\n            media_id (int): Series ID.\n            season_number (int): Season number.\n        Returns:\n            Any: API response.\n        \"\"\"\n        payload = {\n            \"name\": \"SeasonSearch\",\n            \"seriesId\": media_id,\n            \"SeasonNumber\": season_number,\n        }\n        endpoint = f\"{self.url}/api/v3/command\"\n        return self.make_post_request(endpoint, json=payload)\n\n    def get_episode_data(self, media_id: int) -> Any:\n        \"\"\"\n        Get episode file data for a specific series.\n        Args:\n            media_id (int): Series ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/episodefile?seriesId={media_id}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_episode_data_by_season(self, media_id: int, season_number: int) -> Any:\n        \"\"\"\n        Get episode data for a specific season of a series.\n        Args:\n            media_id (int): Series ID.\n            season_number (int): Season number.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/episode?seriesId={media_id}&seasonNumber={season_number}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_season_data(self, media_id: int) -> Any:\n        \"\"\"\n        Get all episode data for a specific series.\n        Args:\n            media_id (int): Series ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/episode?seriesId={media_id}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def delete_episode_file(self, episode_file_id: int) -> Any:\n        \"\"\"\n        Delete an episode file by file ID.\n        Args:\n            episode_file_id (int): Episode file ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/episodefile/{episode_file_id}\"\n        return self.make_delete_request(endpoint)\n\n    def delete_episode_files(self, episode_file_ids: Union[int, List[int]]) -> Any:\n        \"\"\"\n        Delete multiple episode files by their IDs.\n        Args:\n            episode_file_ids (Union[int, List[int]]): Episode file IDs.\n        Returns:\n            Any: API response.\n        \"\"\"\n        if isinstance(episode_file_ids, int):\n            episode_file_ids = [episode_file_ids]\n        payload = {\"episodeFileIds\": episode_file_ids}\n        self.logger.debug(f\"Delete episode files payload: {payload}\")\n        endpoint = f\"{self.url}/api/v3/episodefile/bulk\"\n        return self.make_delete_request(endpoint, payload)\n\n    def search_episodes(self, episode_ids: List[int]) -> Any:\n        \"\"\"\n        Trigger a search for specific episodes.\n        Args:\n            episode_ids (List[int]): Episode IDs.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/command\"\n        payload = {\"name\": \"EpisodeSearch\", \"episodeIds\": episode_ids}\n        self.logger.debug(f\"Search payload: {payload}\")\n        return self.make_post_request(endpoint, json=payload)\n\n    def get_grab_history(self, media_id: int) -> Any:\n        \"\"\"\n        Get grab history for a series.\n        Args:\n            media_id (int): Series ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        url_addon = f\"series?seriesId={media_id}&eventType=grabbed&includeSeries=false&includeEpisode=false\"\n        endpoint = f\"{self.url}/api/v3/history/{url_addon}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_import_history(self, media_id: int) -> Any:\n        \"\"\"\n        Get import history for a series.\n        Args:\n            media_id (int): Series ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        url_addon = f\"series?seriesId={media_id}&eventType=downloadFolderImported&includeSeries=false&includeEpisode=false\"\n        endpoint = f\"{self.url}/api/v3/history/{url_addon}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_season_grab_history(self, media_id: int, season: int) -> Any:\n        \"\"\"\n        Get grab history for a specific season of a series.\n        Args:\n            media_id (int): Series ID.\n            season (int): Season number.\n        Returns:\n            Any: API response.\n        \"\"\"\n        url_addon = f\"series?seriesId={media_id}&seasonNumber={season}&eventType=grabbed&includeSeries=false&includeEpisode=false\"\n        endpoint = f\"{self.url}/api/v3/history/{url_addon}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_season_import_history(self, media_id: int, season: int) -> Any:\n        \"\"\"\n        Get import history for a specific season of a series.\n        Args:\n            media_id (int): Series ID.\n            season (int): Season number.\n        Returns:\n            Any: API response.\n        \"\"\"\n        url_addon = f\"series?seriesId={media_id}&seasonNumber={season}&eventType=downloadFolderImported&includeSeries=false&includeEpisode=false\"\n        endpoint = f\"{self.url}/api/v3/history/{url_addon}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def get_queue(self) -> Any:\n        \"\"\"\n        Get the current queue from Sonarr.\n        Returns:\n            Any: API response.\n        \"\"\"\n        url_addon = \"page=1&pageSize=200&includeSeries=true\"\n        endpoint = f\"{self.url}/api/v3/queue?{url_addon}\"\n        return self.make_get_request(endpoint, headers=self.headers)\n\n    def delete_media(self, media_id: int) -> Any:\n        \"\"\"\n        Delete a series from Sonarr.\n        Args:\n            media_id (int): Series ID.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/series/{media_id}\"\n        return self.make_delete_request(endpoint)\n\n    def get_parsed_media(self, include_episode: bool = False) -> List[Dict[str, Any]]:\n        \"\"\"\n        Return a structured list of normalized series items.\n\n        Args:\n            include_episode (bool): If True, include episode-level metadata.\n        Returns:\n            List[Dict[str, Any]]: List of normalized media entries.\n        \"\"\"\n        media_dict = []\n        media = self.get_media()\n        if not media:\n            return media_dict\n        for item in media:\n            season_data = item.get(\"seasons\", [])\n            season_list = []\n            for season in season_data:\n                if include_episode:\n                    episode_data = self.get_episode_data_by_season(\n                        item[\"id\"], season[\"seasonNumber\"]\n                    )\n                    episode_list = [\n                        {\n                            \"episode_number\": ep[\"episodeNumber\"],\n                            \"monitored\": ep[\"monitored\"],\n                            \"episode_file_id\": ep[\"episodeFileId\"],\n                            \"episode_id\": ep[\"id\"],\n                            \"has_file\": ep[\"hasFile\"],\n                        }\n                        for ep in episode_data\n                    ]\n                else:\n                    episode_list = []\n                try:\n                    status = (\n                        season[\"statistics\"][\"episodeCount\"]\n                        == season[\"statistics\"][\"totalEpisodeCount\"]\n                    )\n                except Exception:\n                    status = False\n                try:\n                    season_stats = season[\"statistics\"][\"episodeCount\"]\n                except Exception:\n                    season_stats = 0\n                season_list.append(\n                    {\n                        \"season_number\": season[\"seasonNumber\"],\n                        \"monitored\": season[\"monitored\"],\n                        \"season_pack\": status,\n                        \"season_has_episodes\": season_stats,\n                        \"episode_data\": episode_list,\n                    }\n                )\n            alternate_titles = [t[\"title\"] for t in item[\"alternateTitles\"]]\n            normalized_alternate_titles = [\n                normalize_titles(t[\"title\"]) for t in item[\"alternateTitles\"]\n            ]\n            if year_regex.search(item[\"title\"]):\n                title = year_regex.sub(\"\", item[\"title\"])\n                year = extract_year(item[\"title\"])\n            else:\n                title = item[\"title\"]\n                year = item[\"year\"]\n            reg = windows_path_regex.match(item[\"path\"])\n            if reg and reg.group(1):\n                folder = item[\"path\"][item[\"path\"].rfind(\"\\\\\") + 1 :]\n            else:\n                folder = os.path.basename(os.path.normpath(item[\"path\"]))\n            media_dict.append(\n                {\n                    \"title\": unidecode(html.unescape(title)),\n                    \"year\": year,\n                    \"media_id\": item[\"id\"],\n                    \"tvdb_id\": item[\"tvdbId\"],\n                    \"imdb_id\": item.get(\"imdbId\", None),\n                    \"monitored\": item[\"monitored\"],\n                    \"status\": item[\"status\"],\n                    \"root_folder\": item[\"rootFolderPath\"],\n                    \"quality_profile\": item[\"qualityProfileId\"],\n                    \"normalized_title\": normalize_titles(item[\"title\"]),\n                    \"path_name\": os.path.basename(item[\"path\"]),\n                    \"original_title\": item.get(\"originalTitle\", None),\n                    \"secondary_year\": item.get(\"secondaryYear\", None),\n                    \"alternate_titles\": alternate_titles,\n                    \"normalized_alternate_titles\": normalized_alternate_titles,\n                    \"file_id\": None,\n                    \"folder\": folder,\n                    \"normalized_folder\": normalize_titles(folder),\n                    \"has_file\": None,\n                    \"tags\": item[\"tags\"],\n                    \"seasons\": season_list,\n                    \"season_numbers\": [s[\"season_number\"] for s in season_list],\n                }\n            )\n        return media_dict\n\n    def refresh_queue(self) -> Any:\n        \"\"\"\n        Refresh the queue in Sonarr.\n        Returns:\n            Any: API response.\n        \"\"\"\n        endpoint = f\"{self.url}/api/v3/command\"\n        payload = {\"name\": \"RefreshMonitoredDownloads\"}\n        self.logger.debug(f\"Refresh queue payload: {payload}\")\n        return self.make_post_request(endpoint, json=payload)\n\n    def remove_item_from_queue(self, queue_ids: Union[int, List[int]]) -> Any:\n        \"\"\"\n        Remove an item or items from the queue.\n        Args:\n            queue_ids (Union[int, List[int]]): Queue item IDs.\n        Returns:\n            Any: API response.\n        \"\"\"\n        if isinstance(queue_ids, int):\n            queue_ids = [queue_ids]\n        payload = {\"ids\": queue_ids}\n        endpoint = f\"{self.url}/api/v3/queue/bulk?removeFromClient=false&blocklist=false&skipRedownload=false&changeCategory=false\"\n        return self.make_delete_request(endpoint, payload)\n\n\ndef create_arr_client(\n    url: str, api: str, logger: Any\n) -> Optional[Union[RadarrClient, SonarrClient]]:\n    \"\"\"\n    Factory to create a Radarr or Sonarr client.\n\n    Args:\n        url (str): API URL.\n        api (str): API key.\n        logger (Any): Logger instance.\n    Returns:\n        Optional[Union[RadarrClient, SonarrClient]]: The client or None on failure.\n    \"\"\"\n\n    class SilentLogger:\n        def debug(self, *args, **kwargs):\n            pass\n\n        def info(self, *args, **kwargs):\n            pass\n\n        def warning(self, *args, **kwargs):\n            pass\n\n        def error(self, *args, **kwargs):\n            pass\n\n    temp = BaseARRClient(url, api, SilentLogger())\n    if not temp.connect_status:\n        return None\n    if temp.app_name == \"Radarr\":\n        return RadarrClient(url, api, logger)\n    if temp.app_name == \"Sonarr\":\n        return SonarrClient(url, api, logger)\n    logger.error(\"Unknown ARR type\")\n    return None\n"
  },
  {
    "path": "util/assets.py",
    "content": "import datetime\nimport os\nfrom typing import Any, Dict, List, Optional, Tuple\n\nfrom util.construct import generate_title_variants\nfrom util.index import build_search_index, create_new_empty_index, search_matches\nfrom util.match import is_match\nfrom util.normalization import normalize_file_names\nfrom util.scanner import process_files\nfrom util.utility import progress\n\n\ndef get_assets_files(\n    source_dirs: str | List[str],\n    logger: Optional[Any],\n    merge: bool = True,\n) -> Tuple[Optional[List[Dict]], Optional[Dict[str, Any]]]:\n    \"\"\"Process one or more directories to extract and organize media assets.\n\n    Args:\n        source_dirs (str or List[str]): One or more paths to media source directories.\n        merge (bool): Whether to merge/deduplicate assets by content and title.\n        logger (Any, optional): Logger instance for debug/info messages.\n\n    Returns:\n        Tuple[Optional[List[Dict]], Optional[Dict[str, Any]]]: A tuple containing a flat\n            asset list and a search index.\n    \"\"\"\n    if isinstance(source_dirs, str):\n        source_dirs = [source_dirs]\n\n    final_assets: List[Dict] = []\n    prefix_index: Dict[str, Any] = create_new_empty_index()\n\n    start_time = datetime.datetime.now()\n\n    for source_dir in source_dirs:\n        new_assets = process_files(source_dir, logger)\n        if new_assets:\n            if merge:\n                merge_assets(new_assets, final_assets, prefix_index, logger)\n            else:\n                for asset in new_assets:\n                    asset[\"files\"].sort()\n                    final_assets.append(asset)\n                    build_search_index(prefix_index, asset[\"title\"], asset, logger)\n\n    end_time = datetime.datetime.now()\n    elapsed_time = (end_time - start_time).total_seconds()\n    items_per_second = len(source_dirs) / elapsed_time if elapsed_time > 0 else 0\n    if logger:\n        logger.debug(\n            f\"Processed {len(source_dirs)} source directories in {elapsed_time:.2f} seconds \"\n            f\"({items_per_second:.2f} items/s)\"\n        )\n\n    if not final_assets:\n        if logger:\n            logger.warning(\n                f\"No valid files were found in any of the source directories: {source_dirs}\"\n            )\n        return None, None\n\n    return final_assets, prefix_index\n\n\ndef merge_assets(\n    new_assets: List[Dict], final_assets: List[Dict], prefix_index: Dict, logger: Any\n) -> None:\n    \"\"\"Merge new asset entries into the final asset list, collapsing duplicates,\n    handling upgrades, and indexing.\n\n    Args:\n        new_assets (List[Dict]): List of new asset dictionaries.\n        final_assets (List[Dict]): List to append/merge assets into.\n        prefix_index (Dict): Index for fast search/lookup.\n        logger (Any): Logger instance.\n    \"\"\"\n    with progress(\n        new_assets,\n        desc=\"Processing assets\",\n        total=len(new_assets),\n        unit=\"asset\",\n        logger=logger,\n        leave=False,\n    ) as pbar:\n        for new in pbar:\n            search_matched_assets = search_matches(prefix_index, new[\"title\"], logger)\n            merged = False\n            for final in search_matched_assets:\n                new_dirs = {os.path.dirname(f) for f in new[\"files\"]}\n                final_dirs = {os.path.dirname(f) for f in final[\"files\"]}\n                if new_dirs & final_dirs:\n                    continue\n\n                is_matched, reason = is_match(final, new)\n                if is_matched and (\n                    final[\"type\"] == new[\"type\"]\n                    or final.get(\"season_numbers\")\n                    or new.get(\"season_numbers\")\n                ):\n                    if new.get(\"season_numbers\") or final.get(\"season_numbers\"):\n                        final[\"type\"] = \"series\"\n                    pre_files = list(final[\"files\"])\n                    upgrades = []\n                    for new_file in new[\"files\"]:\n                        normalized_new_file = normalize_file_names(\n                            os.path.basename(new_file)\n                        )\n                        for final_file in final[\"files\"]:\n                            normalized_final_file = normalize_file_names(\n                                os.path.basename(final_file)\n                            )\n                            if final.get(\"type\") == \"collections\":\n                                final_base = os.path.splitext(\n                                    os.path.basename(final_file)\n                                )[0]\n                                final_file_variants = generate_title_variants(\n                                    final_base\n                                )[\"normalized_alternate_titles\"]\n                            if normalized_final_file == normalized_new_file or (\n                                final.get(\"type\") == \"collections\"\n                                and normalized_new_file in final_file_variants\n                            ):\n                                final[\"files\"].remove(final_file)\n                                final[\"files\"].append(new_file)\n                                upgrades.append((final_file, new_file))\n                                break\n                        else:\n                            final[\"files\"].append(new_file)\n\n                    new_season_numbers = new.get(\"season_numbers\")\n                    if new_season_numbers:\n                        final_season_numbers = final.get(\"season_numbers\")\n                        if final_season_numbers:\n                            final[\"season_numbers\"] = list(\n                                set(final_season_numbers + new_season_numbers)\n                            )\n                        else:\n                            final[\"season_numbers\"] = new_season_numbers\n                    final[\"files\"].sort()\n                    for key in [\"tmdb_id\", \"tvdb_id\", \"imdb_id\"]:\n                        if not final.get(key) and new.get(key):\n                            final[key] = new[key]\n                    post_files = list(final[\"files\"])\n                    src_parent = os.path.basename(os.path.dirname(new[\"files\"][0]))\n                    reason_str = f\"  Reason: {reason}.\"\n                    files_str = f\"  Files: {len(pre_files)} → {len(post_files)}\"\n\n                    pre_basenames = {os.path.basename(f): f for f in pre_files}\n                    post_basenames = {os.path.basename(f): f for f in post_files}\n                    new_basenames = {os.path.basename(f): f for f in new[\"files\"]}\n\n                    upgrade_lines = []\n                    for pre_base, pre_full in pre_basenames.items():\n                        if pre_base in new_basenames:\n                            new_full = new_basenames[pre_base]\n                            pre_dir = os.path.basename(os.path.dirname(pre_full))\n                            new_dir = os.path.basename(os.path.dirname(new_full))\n                            if pre_full != new_full:\n                                upgrade_lines.append(\n                                    f\"    - Replaced: {pre_base} [{pre_dir}]\\n\"\n                                    f\"        → {os.path.basename(new_full)} [{new_dir}]\"\n                                )\n                    for post_base, post_full in post_basenames.items():\n                        if post_base not in pre_basenames:\n                            post_dir = os.path.basename(os.path.dirname(post_full))\n                            upgrade_lines.append(\n                                f\"    - Added:    {post_base} [{post_dir}]\"\n                            )\n\n                    logger.debug(\n                        f\"[MERGE] '{final['title']}' ({final['type']}) from [{src_parent}]\\n\"\n                        f\"{reason_str}\\n\"\n                        f\"{files_str}\\n\"\n                        + (\"\\n\".join(upgrade_lines) if upgrade_lines else \"\")\n                    )\n                    merged = True\n                    break\n            if not merged:\n                new[\"files\"].sort()\n                final_assets.append(new)\n                build_search_index(prefix_index, new[\"title\"], new, logger)\n                src_parent = os.path.basename(os.path.dirname(new[\"files\"][0]))\n                logger.debug(\n                    f\"[ADD] New asset '{new['title']}' ({new['type']}), {len(new['files'])} file(s), from {src_parent}\"\n                )\n"
  },
  {
    "path": "util/config.py",
    "content": "import json\nimport os\nimport pathlib\nimport sys\nfrom copy import deepcopy\nfrom types import SimpleNamespace\nfrom typing import Any, Dict, List, Tuple\n\nimport yaml\n\nfrom util.logger import Logger\n\n\nclass Config:\n    \"\"\"Manages loading and accessing configuration for a given module.\"\"\"\n\n    def __init__(self, module_name: str) -> None:\n        \"\"\"\n        Initialize Config with module name.\n\n        Args:\n            module_name (str): Name of the module requesting configuration.\n        \"\"\"\n        self.config_path: str = config_file_path\n        self.module_name: str = module_name\n        self.load_config()\n\n    def load_config(self) -> None:\n        \"\"\"\n        Load the YAML configuration and set attributes for scheduler, discord,\n        notifications, instances, and module config.\n        \"\"\"\n        try:\n            config = load_user_config(self.config_path)\n        except Exception:\n            return\n\n        self._config = config\n\n        if \"schedule\" not in config:\n            print(\n                \"[CONFIG] Warning: 'schedule' key missing in config; defaulting to empty schedule\"\n            )\n        self.scheduler = config.get(\"schedule\", {})\n        self.discord = config.get(\"discord\", {})\n        self.notifications = config.get(\"notifications\", [])\n        if \"instances\" not in config:\n            sys.stderr.write(\n                f\"[CONFIG] Missing 'instances' key! Config keys: {list(config.keys())}\\n\"\n            )\n        self.instances_config = config.get(\"instances\", {})\n\n        if self.module_name:\n            self.module_config = self._config.get(self.module_name, {})\n            self.module_config = SimpleNamespace(**self.module_config)\n            self.module_config.module_name = self.module_name\n            module_notifications = self._config.get(\"notifications\", {}).get(\n                self.module_name, {}\n            )\n            setattr(self.module_config, \"notifications\", module_notifications)\n            return\n\n\ndef load_user_config(path: str) -> Dict[str, Any]:\n    \"\"\"\n    Load YAML configuration from the specified file path.\n\n    Args:\n        path (str): Path to the YAML configuration file.\n\n    Returns:\n        dict: Parsed configuration dictionary, or empty dict if file is missing or invalid.\n    \"\"\"\n    try:\n        with open(path, \"r\") as f:\n            raw = f.read()\n        data = yaml.safe_load(raw)\n        return data or {}\n    except FileNotFoundError:\n        sys.stderr.write(\"[CONFIG] config file not found\\n\")\n        return {}\n    except yaml.YAMLError as e:\n        sys.stderr.write(f\"[CONFIG] Error parsing config file: {e}\\n\")\n        print(f\"Error parsing config file: {e}\")\n        return {}\n\n\nTEMPLATE_PATH = pathlib.Path(__file__).parent / \"template\" / \"config_template.json\"\nif os.environ.get(\"DOCKER_ENV\"):\n    config_dir = os.getenv(\"CONFIG_DIR\", \"/config\")\n    config_file_path = os.path.join(config_dir, \"config.yml\")\nelse:\n    config_dir = pathlib.Path(__file__).parents[1] / \"config\"\n    config_dir.mkdir(parents=True, exist_ok=True)\n    config_file_path = config_dir / \"config.yml\"\n\nif not os.path.exists(config_file_path):\n    from json import load as _json_load\n\n    with open(TEMPLATE_PATH, \"r\") as tf:\n        default_cfg = _json_load(tf)\n    with open(config_file_path, \"w\") as wf:\n        yaml.safe_dump(default_cfg, wf, sort_keys=False)\n\n\ndef _reconcile_config_data(\n    template_data: Dict[str, Any], user_data: Dict[str, Any]\n) -> Tuple[Dict[str, Any], List[str], List[str]]:\n    \"\"\"\n    Recursively reconcile user configuration with a template.\n\n    Args:\n        template_data (dict): Template configuration dictionary.\n        user_data (dict): User configuration dictionary.\n\n    Returns:\n        Tuple containing reconciled dictionary, list of added keys, and list of removed keys.\n    \"\"\"\n    reconciled_dict: Dict[str, Any] = {}\n    added_keys: List[str] = []\n    removed_keys: List[str] = []\n\n    for key, template_value in template_data.items():\n        if key in user_data:\n            user_value = user_data[key]\n            if isinstance(template_value, dict):\n                if isinstance(user_value, dict):\n                    if not template_value:\n                        reconciled_dict[key] = deepcopy(user_value)\n                    else:\n                        rec, add, rem = _reconcile_config_data(\n                            template_value, user_value\n                        )\n                        reconciled_dict[key] = rec\n                        added_keys.extend([f\"{key}.{k}\" for k in add])\n                        removed_keys.extend([f\"{key}.{k}\" for k in rem])\n                else:\n                    reconciled_dict[key] = deepcopy(template_value)\n            else:\n                reconciled_dict[key] = user_value\n        else:\n            reconciled_dict[key] = deepcopy(template_value)\n            added_keys.append(key)\n\n    for key in user_data.keys():\n        if key not in template_data:\n            removed_keys.append(key)\n    return reconciled_dict, added_keys, removed_keys\n\n\ndef manage_config(logger: Logger) -> None:\n    \"\"\"\n    Update user's config.yml based on config_template.json.\n\n    Logs keys that are added or removed.\n\n    Args:\n        logger (Logger): Logger instance for logging messages.\n    \"\"\"\n    global TEMPLATE_PATH, config_file_path\n    try:\n        with open(TEMPLATE_PATH, \"r\", encoding=\"utf-8\") as f:\n            template_data = json.load(f)\n    except FileNotFoundError:\n        logger.error(\n            f\"[CONFIG] Template configuration file not found at {TEMPLATE_PATH}\"\n        )\n        return\n    except json.JSONDecodeError as e:\n        logger.error(\n            f\"[CONFIG] Could not parse template configuration file {TEMPLATE_PATH}: {e}\"\n        )\n        return\n\n    user_data: Dict[str, Any] = {}\n    if os.path.exists(config_file_path):\n        try:\n            with open(config_file_path, \"r\", encoding=\"utf-8\") as f:\n                user_data = yaml.safe_load(f) or {}\n        except yaml.YAMLError as e:\n            logger.error(\n                f\"[CONFIG] Could not parse user configuration file {config_file_path}: {e}\"\n            )\n            logger.warning(\n                \"[CONFIG] Proceeding with an empty user configuration for reconciliation.\"\n            )\n    if not isinstance(user_data, dict):\n        logger.warning(\n            f\"User configuration at {config_file_path} is not a dictionary. Treating as empty.\"\n        )\n        user_data = {}\n\n    reconciled_data, added_keys, removed_keys = _reconcile_config_data(\n        template_data, user_data\n    )\n\n    try:\n        with open(config_file_path, \"w\", encoding=\"utf-8\") as f:\n            yaml.safe_dump(\n                reconciled_data,\n                f,\n                sort_keys=False,\n                indent=2,\n                default_flow_style=False,\n                allow_unicode=True,\n            )\n        logger.info(\n            f\"[CONFIG] Configuration file {config_file_path} updated successfully based on template.\"\n        )\n        if added_keys:\n            logger.info(f\"[CONFIG] Keys ADDED to config: {added_keys}\")\n        if removed_keys:\n            logger.info(f\"[CONFIG] Keys REMOVED from config: {removed_keys}\")\n    except IOError as e:\n        logger.error(\n            f\"[CONFIG] Could not write to configuration file {config_file_path}: {e}\"\n        )\n"
  },
  {
    "path": "util/constants.py",
    "content": "import re\nfrom typing import List, Pattern, Set\n\n# Matches suffixes like \" - Season X\" or \"_SeasonX\" (where X is 1–4 digits), as well as \"- Specials\" or \"_Specials\" (case-insensitive)\nseason_pattern: Pattern = re.compile(\n    r\"(?:\\s*-\\s*Season\\s*\\d+|_Season\\d{1,4}|\\s*-\\s*Specials|_Specials)\", re.IGNORECASE\n)\n\n# Matches optional leading hyphens, underscores, or spaces before \"Season\" followed by 1–4 digits (e.g. \"- Season 2\", \"_Season10\"), capturing the digits as group 1\nseason_number_regex: Pattern = re.compile(\n    r\"(?:[-\\s_]+)?Season\\s*(\\d{1,4})\", re.IGNORECASE\n)\n\n# Matches the literal \"Season \" followed by 1–4 digits (e.g. \"Season 1\", \"Season 12\", up to \"Season 9999\"), capturing those digits as group 1\nseason_regex: str = r\"Season (\\d{1,4})\"\n\n# Matches strings like \"E01\" or \"e5\", capturing 1–2 digits as the episode number\nepisode_regex: str = r\"(?:E|e)(\\d{1,2})\"\n\n# Matches any text (group 1) followed by a space and a 4-digit year in parentheses (group 2), e.g. \"Movie Title (2022)\"\nfolder_year_regex: Pattern = re.compile(r\"(.*)\\s\\((\\d{4})\\)\")\n\n# Matches an optional space, a 4-digit year in parentheses (captured as group 1), ensures “Collection” does not appear later, and consumes any trailing text\nyear_regex: Pattern = re.compile(r\"\\s?\\((\\d{4})\\)(?!.*Collection).*\")\n\n# Matches one or more illegal filename characters—including < > : \" / \\ | ? * and control characters U+0000–U+001F\nillegal_chars_regex: Pattern = re.compile(r\"[<>:\\\"/\\\\|?*\\x00-\\x1f]+\")\n\n# Matches one or more characters that are not letters (A–Z, a–z), digits (0–9), or whitespace, i.e., special symbols and punctuation\nremove_special_chars: Pattern = re.compile(r\"[^a-zA-Z0-9\\s]+\")\n\n# Matches any path ending in “/Title (YYYY)” (possibly with characters after), capturing the title (everything after the last slash up to the space) as group 1 and the 4-digit year as group 2\ntitle_regex: str = r\".*\\/([^/]+)\\s\\((\\d{4})\\).*\"\n\n# matches \"tmdbid 12345\"  or  \"tmdb-12345\"  or  \"tmdb_12345\"  or  \"tmdb 12345\"\ntmdb_id_regex = re.compile(r\"(?i)\\btmdb(?:id|[-_\\s])(\\d+)\\b\")\n\n# matches \"tvdbid 67890\"  or  \"tvdb-67890\"  or  \"tvdb_67890\"  or  \"tvdb 67890\"\ntvdb_id_regex = re.compile(r\"(?i)\\btvdb(?:id|[-_\\s])(\\d+)\\b\")\n\n# Matches strings like \"imdb-tt1234567\", \"imdb_tt1234567\", or \"imdb tt1234567\", capturing the \"tt\" plus digits as the IMDb ID\nimdb_id_regex: Pattern = re.compile(r\"imdb[-_\\s](tt\\d+)\")\n\n# Matches the start of a Windows drive path like \"C:\\\" or \"D:\\\", capturing the drive letter and colon\nwindows_path_regex: Pattern = re.compile(r\"^([A-Z]:\\\\)\")\n\n# Remove curly‐brace blocks containing TMDB, TVDB, or IMDb IDs\nid_content_regex = re.compile(\n    r\"\\s*\\{\\s*(?:\"\n    r\"tmdb(?:[-_\\s]\\d+)|\"\n    r\"tvdb(?:[-_\\s]\\d+)|\"\n    r\"imdb(?:[-_\\s](?:tt)?\\d+)\"\n    r\")\\s*\\}\",\n    flags=re.IGNORECASE,\n)\n\nwords_to_remove: List[str] = [\n    \"(US)\",\n    \"(UK)\",\n    \"(AU)\",\n    \"(CA)\",\n    \"(NZ)\",\n    \"(FR)\",\n    \"(NL)\",\n    \"DC's\",\n]\n\ncommon_words: Set[str] = {\"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\"}\n\nprefixes: List[str] = [\n    \"The\",\n    \"A\",\n    \"An\",\n]\n\nsuffixes: List[str] = [\n    \"Collection\",\n    \"Saga\",\n]\n"
  },
  {
    "path": "util/construct.py",
    "content": "import os\nimport re\nfrom typing import Any, Dict, List, Optional\n\nfrom util.constants import prefixes, season_number_regex, suffixes\nfrom util.normalization import normalize_titles\n\n\ndef generate_title_variants(title: str) -> Dict[str, List[str]]:\n    \"\"\"Generate alternate and normalized title variants for a given media title.\n\n    Args:\n        title (str): The original media title.\n\n    Returns:\n        Dict[str, List[str]]: Dictionary with 'alternate_titles' and\n            'normalized_alternate_titles' keys.\n    \"\"\"\n    stripped_prefix = next(\n        (title[len(p) + 1 :].strip() for p in prefixes if title.startswith(p + \" \")),\n        title,\n    )\n    stripped_suffix = next(\n        (title[: -(len(s) + 1)].strip() for s in suffixes if title.endswith(\" \" + s)),\n        title,\n    )\n    stripped_both = next(\n        (\n            stripped_prefix[: -(len(s) + 1)].strip()\n            for s in suffixes\n            if stripped_prefix.endswith(\" \" + s)\n        ),\n        stripped_prefix,\n    )\n    alternate_titles = [stripped_prefix, stripped_suffix, stripped_both]\n    if not title.lower().endswith(\"collection\"):\n        alternate_titles.append(f\"{title} Collection\")\n    normalized_alternate_titles = [normalize_titles(alt) for alt in alternate_titles]\n    alternate_titles = list(dict.fromkeys(alternate_titles))\n    normalized_alternate_titles = list(dict.fromkeys(normalized_alternate_titles))\n    return {\n        \"alternate_titles\": alternate_titles,\n        \"normalized_alternate_titles\": normalized_alternate_titles,\n    }\n\n\ndef create_collection(\n    title: str,\n    tmdb_id: int,\n    normalized_title: str,\n    files: List[str],\n    parent_folder: Optional[str] = None,\n    media_folder: Optional[str] = None,\n) -> Dict[str, Any]:\n    \"\"\"Construct a standardized dictionary representing a collection entry.\n\n    Args:\n        title (str): Display title of the collection.\n        tmdb_id (int): TMDB identifier.\n        normalized_title (str): Normalized version of the title.\n        files (List[str]): Associated media file paths.\n        parent_folder (Optional[str]): Folder containing the files.\n\n    Returns:\n        Dict[str, Any]: Dictionary with metadata fields for a collection.\n    \"\"\"\n    variants = generate_title_variants(title)\n    return {\n        \"type\": \"collections\",\n        \"title\": title,\n        \"year\": None,\n        \"normalized_title\": normalized_title,\n        \"files\": [files[-1]],\n        \"alternate_titles\": variants[\"alternate_titles\"],\n        \"normalized_alternate_titles\": variants[\"normalized_alternate_titles\"],\n        \"tmdb_id\": tmdb_id,\n        \"folder\": parent_folder,\n        \"media_folder\": media_folder,\n    }\n\n\ndef create_series(\n    title: str,\n    year: Optional[int],\n    tvdb_id: Optional[int],\n    imdb_id: Optional[str],\n    normalized_title: str,\n    files: List[str],\n    parent_folder: Optional[str] = None,\n    media_folder: Optional[str] = None,\n) -> Dict[str, Any]:\n    \"\"\"Construct a standardized dictionary representing a series entry.\n\n    Args:\n        title (str): Series title.\n        year (Optional[int]): Release year of the series.\n        tvdb_id (Optional[int]): TVDB identifier.\n        imdb_id (Optional[str]): IMDB identifier.\n        normalized_title (str): Normalized version of the title.\n        files (List[str]): List of associated media file paths.\n        parent_folder (Optional[str]): Folder containing the files.\n\n    Returns:\n        Dict[str, Any]: Dictionary with metadata fields for a series.\n    \"\"\"\n    season_numbers_dict = {}\n    series_poster = None\n    for file_path in files:\n        base = os.path.basename(file_path)\n        if \"Specials\" in base:\n            season_numbers_dict[0] = file_path\n        else:\n            match = re.search(season_number_regex, base)\n            if match:\n                season_numbers_dict[int(match.group(1))] = file_path\n            else:\n                series_poster = file_path\n\n    season_numbers = sorted(season_numbers_dict.keys())\n    final_files = list(season_numbers_dict.values())\n    if series_poster:\n        final_files.append(series_poster)\n    return {\n        \"type\": \"series\",\n        \"title\": title,\n        \"year\": year,\n        \"tvdb_id\": tvdb_id,\n        \"imdb_id\": imdb_id,\n        \"normalized_title\": normalized_title,\n        \"files\": final_files,\n        \"season_numbers\": season_numbers,\n        \"folder\": parent_folder,\n        \"media_folder\": media_folder,\n    }\n\n\ndef create_movie(\n    title: str,\n    year: Optional[int],\n    tmdb_id: Optional[int],\n    imdb_id: Optional[str],\n    normalized_title: str,\n    files: List[str],\n    parent_folder: Optional[str] = None,\n    media_folder: Optional[str] = None,\n) -> Dict[str, Any]:\n    \"\"\"Construct a standardized dictionary representing a movie entry.\n\n    Args:\n        title (str): Movie title.\n        year (Optional[int]): Release year of the movie.\n        tmdb_id (Optional[int]): TMDB identifier.\n        imdb_id (Optional[str]): IMDB identifier.\n        normalized_title (str): Normalized version of the title.\n        files (List[str]): List of associated media file paths.\n        parent_folder (Optional[str]): Folder containing the files.\n\n    Returns:\n        Dict[str, Any]: Dictionary with metadata fields for a movie.\n    \"\"\"\n    return {\n        \"type\": \"movies\",\n        \"title\": title,\n        \"year\": year,\n        \"tmdb_id\": tmdb_id,\n        \"imdb_id\": imdb_id,\n        \"normalized_title\": normalized_title,\n        \"files\": [files[-1]],\n        \"folder\": parent_folder,\n        \"media_folder\": media_folder,\n    }\n"
  },
  {
    "path": "util/extract.py",
    "content": "from typing import Optional, Tuple\n\nfrom util.constants import imdb_id_regex, tmdb_id_regex, tvdb_id_regex, year_regex\n\n\ndef extract_year(text: str) -> Optional[int]:\n    \"\"\"Extract the first 4-digit year from text.\n\n    Args:\n        text: Input string to search for a year.\n\n    Returns:\n        The extracted year as an integer, or None if not found.\n    \"\"\"\n    try:\n        return int(year_regex.search(text).group(1))\n    except Exception:\n        return None\n\n\ndef extract_ids(text: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:\n    \"\"\"Extract TMDB, TVDB, and IMDB IDs from text.\n\n    Args:\n        text: Input string containing IDs.\n\n    Returns:\n        Tuple of TMDB ID (int or None), TVDB ID (int or None), IMDB ID (str or None).\n    \"\"\"\n    tmdb_match = tmdb_id_regex.search(text)\n    tmdb = int(tmdb_match.group(1)) if tmdb_match else None\n    tvdb_match = tvdb_id_regex.search(text)\n    tvdb = int(tvdb_match.group(1)) if tvdb_match else None\n    imdb_match = imdb_id_regex.search(text)\n    imdb = imdb_match.group(1) if imdb_match else None\n    return tmdb, tvdb, imdb\n"
  },
  {
    "path": "util/index.py",
    "content": "from typing import Any, Dict, List, Optional\n\nfrom util.constants import common_words\nfrom util.normalization import normalize_titles\n\nAsset = Dict[str, Any]\nPrefixIndex = Dict[str, List[Asset]]\n\nprefix_length: int = 3\n\n\ndef create_new_empty_index() -> PrefixIndex:\n    \"\"\"Create and return an empty search index structure.\n\n    Returns:\n        PrefixIndex: An empty dictionary.\n    \"\"\"\n    return {}\n\n\ndef remove_common_words(text: str) -> str:\n    \"\"\"Remove any word that matches an entry in common_words (case-insensitive).\n\n    Only removes complete words, does not touch substrings or special characters.\n\n    Args:\n        text (str): Input text to filter.\n\n    Returns:\n        str: Text with common words removed.\n    \"\"\"\n    words = text.split()\n    filtered = [\n        word for word in words if word.lower() not in {w.lower() for w in common_words}\n    ]\n    return \" \".join(filtered)\n\n\ndef build_search_index(\n    prefix_index: PrefixIndex,\n    title: str,\n    asset: Asset,\n    logger: Optional[Any],\n    debug_items: Optional[List[str]] = None,\n) -> None:\n    \"\"\"Populate the search index with normalized forms of the asset title and TMDB/TVDB IDs.\n\n    Args:\n        prefix_index (PrefixIndex): The overall index to update.\n        title (str): Original title to normalize and index.\n        asset (Asset): Dictionary containing asset metadata.\n        logger (Optional[Any]): Logger instance for debug output.\n        debug_items (Optional[List[str]]): List of normalized titles to enable debug logging on.\n    \"\"\"\n    title = remove_common_words(title)\n    processed = normalize_titles(title)\n    debug_build_index = bool(\n        debug_items and len(debug_items) > 0 and processed in debug_items\n    )\n\n    if debug_build_index and logger:\n        logger.info(\"debug_build_search_index\")\n        logger.info(processed)\n        logger.info(asset)\n\n    if asset.get(\"tmdb_id\"):\n        key = f\"tmdb:{asset['tmdb_id']}\"\n        prefix_index.setdefault(key, []).append(asset)\n        if debug_build_index and logger:\n            logger.info(f\"Indexed by {key}\")\n\n    if asset.get(\"tvdb_id\"):\n        key = f\"tvdb:{asset['tvdb_id']}\"\n        prefix_index.setdefault(key, []).append(asset)\n        if debug_build_index and logger:\n            logger.info(f\"Indexed by {key}\")\n\n    words = processed.split()\n    if debug_build_index and logger:\n        logger.info(words)\n\n    for word in words:\n        prefix_index.setdefault(word, []).append(asset)\n        if len(word) > prefix_length:\n            prefix = word[:prefix_length]\n            if debug_build_index and logger:\n                logger.info(prefix)\n            prefix_index.setdefault(prefix, []).append(asset)\n        break\n\n\ndef search_matches(\n    prefix_index: PrefixIndex,\n    title: str,\n    logger: Optional[Any],\n    tmdb_id: Optional[int] = None,\n    tvdb_id: Optional[int] = None,\n) -> List[Asset]:\n    \"\"\"Search for matching assets in the index.\n\n    If a TMDB or TVDB ID is provided, search strictly by that ID and return results.\n    Only perform title-based search if neither ID is provided.\n\n    Args:\n        prefix_index (PrefixIndex): The populated search index.\n        title (str): The title to search for.\n        logger (Optional[Any]): Logger instance for optional logging.\n        tmdb_id (Optional[int]): TMDB ID for direct lookup.\n        tvdb_id (Optional[int]): TVDB ID for direct lookup.\n\n    Returns:\n        List[Asset]: List of matching assets from the index.\n    \"\"\"\n    if tmdb_id is not None:\n        key = f\"tmdb:{tmdb_id}\"\n        filtered_assets = [\n            a for a in prefix_index.get(key, []) if a.get(\"tmdb_id\") == tmdb_id\n        ]\n        return filtered_assets\n    if tvdb_id is not None:\n        key = f\"tvdb:{tvdb_id}\"\n        filtered_assets = [\n            a for a in prefix_index.get(key, []) if a.get(\"tvdb_id\") == tvdb_id\n        ]\n        return filtered_assets\n\n    title = remove_common_words(title)\n    processed_title = normalize_titles(title)\n    words = processed_title.split()\n    matches: List[Asset] = []\n    for word in words:\n        if len(word) > prefix_length:\n            prefix = word[:prefix_length]\n            if prefix in prefix_index:\n                matches.extend(prefix_index[prefix])\n                return matches\n        if word in prefix_index:\n            matches.extend(prefix_index[word])\n        break\n    return matches\n"
  },
  {
    "path": "util/logger.py",
    "content": "import builtins\nimport logging\nimport os\nimport sys\nfrom datetime import datetime\nfrom logging.handlers import RotatingFileHandler\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom util.utility import create_bar\nfrom util.version import get_version\n\n\nclass Logger:\n    \"\"\"Logger with file rotation, console output, and versioned header.\"\"\"\n\n    def __init__(self, log_level: str, module_name: str, max_logs: int = 9):\n        \"\"\"Set up file and console logging handlers and emit versioned header.\"\"\"\n        log_base = os.getenv(\"LOG_DIR\")\n        if log_base:\n            log_dir = Path(log_base) / module_name\n        else:\n            log_dir = Path(__file__).resolve().parents[1] / \"logs\" / module_name\n        os.makedirs(log_dir, exist_ok=True)\n\n        base = os.path.join(log_dir, module_name)\n        log_file = f\"{base}.log\"\n\n        if os.path.isfile(log_file):\n            for i in range(max_logs - 1, 0, -1):\n                old = f\"{base}.{i}.log\"\n                new = f\"{base}.{i + 1}.log\"\n                if os.path.exists(old):\n                    os.rename(old, new)\n            os.rename(log_file, f\"{base}.1.log\")\n\n        self._logger = logging.getLogger(f\"{module_name}_{os.getpid()}\")\n        self._logger.handlers.clear()\n        self._logger.propagate = False\n        self._logger.setLevel(getattr(logging, log_level.lower().upper(), logging.INFO))\n\n        formatter = logging.Formatter(\n            fmt=\"%(asctime)s %(levelname)s: %(message)s\", datefmt=\"%m/%d/%y %I:%M:%S %p\"\n        )\n        file_handler = RotatingFileHandler(log_file, mode=\"w\", backupCount=max_logs)\n        file_handler.setFormatter(formatter)\n        self._logger.addHandler(file_handler)\n\n        if module_name == \"main\" or os.environ.get(\"LOG_TO_CONSOLE\", \"\").lower() in (\n            \"1\",\n            \"true\",\n            \"yes\",\n        ):\n            console = logging.StreamHandler()\n            console.setLevel(self._logger.level)\n            console.addFilter(lambda record: record.levelno < logging.ERROR)\n            console.setFormatter(logging.Formatter(\"%(message)s\"))\n            self._logger.addHandler(console)\n\n        error_console = logging.StreamHandler()\n        error_console.setLevel(logging.ERROR)\n        error_console.setFormatter(\n            logging.Formatter(f\"%(levelname)s [{module_name}]: %(message)s\")\n        )\n        self._logger.addHandler(error_console)\n\n        if not hasattr(logging, log_level.lower().upper()):\n            self._logger.warning(f\"Invalid log level '{log_level}', defaulting to INFO\")\n\n        version = get_version()\n        self.start_time = datetime.now()\n        self._logger.start_time = self.start_time\n        self._logger.info(\n            create_bar(f\"{module_name.replace('_', ' ').upper()} Version: {version}\")\n        )\n\n    def log_outro(self) -> None:\n        \"\"\"Log runtime duration since start_time.\"\"\"\n        start = getattr(self, \"start_time\", None)\n        if start is None:\n            return\n        duration = datetime.now() - start\n        hours, remainder = divmod(duration.total_seconds(), 3600)\n        minutes, seconds = divmod(remainder, 60)\n        formatted_duration = f\"{int(hours)}h {int(minutes)}m {int(seconds)}s\"\n        module_name = self._logger.name.rsplit(\"_\", 1)[0].replace(\"_\", \" \").upper()\n        self._logger.info(create_bar(f\"{module_name} | Run Time: {formatted_duration}\"))\n\n    def __getattr__(self, name):\n        return getattr(self._logger, name)\n\n\n_orig_print = builtins.print\n\n\ndef _print(*args: object, file: Optional[object] = None, **kwargs: object) -> None:\n    \"\"\"Custom print respecting LOG_TO_CONSOLE for stdout; always allow stderr.\"\"\"\n    target = file if file is not None else sys.stdout\n    log_console = os.environ.get(\"LOG_TO_CONSOLE\", \"\").lower() in (\"1\", \"true\", \"yes\")\n    if target in (sys.stderr, sys.__stderr__):\n        _orig_print(*args, file=target, **kwargs)\n        return\n    if target in (sys.stdout, sys.__stdout__):\n        if log_console:\n            _orig_print(*args, file=target, **kwargs)\n        return\n    _orig_print(*args, file=target, **kwargs)\n\n\nbuiltins.print = _print\n"
  },
  {
    "path": "util/match.py",
    "content": "import os\nimport re\nimport time\nfrom types import SimpleNamespace\nfrom typing import Any, Dict, List, Optional, Tuple\n\nfrom util.constants import folder_year_regex, season_pattern\nfrom util.index import search_matches\nfrom util.normalization import normalize_titles\nfrom util.utility import progress\n\n\ndef compare_strings(string1: str, string2: str) -> bool:\n    \"\"\"Loosely compare two strings by removing non-alphanumeric characters and comparing lowercase.\"\"\"\n    string1 = re.sub(r\"\\W+\", \"\", string1)\n    string2 = re.sub(r\"\\W+\", \"\", string2)\n    return string1.lower() == string2.lower()\n\n\ndef is_match(\n    asset: Dict[str, Any],\n    media: Dict[str, Any],\n    strict_folder_match: bool = False,\n) -> Tuple[bool, str]:\n    \"\"\"Determine if a media entry and an asset match based on ID, title, and year heuristics.\n\n    Args:\n      asset: Asset dictionary.\n      media: Media dictionary.\n      strict_folder_match: Only consider match if asset's folder matches media's folder.\n\n    Returns:\n      Tuple of (True, reason) if matched, else (False, \"\").\n    \"\"\"\n    if media.get(\"folder\"):\n        folder_base_name = os.path.basename(media[\"folder\"])\n        match = re.search(folder_year_regex, folder_base_name)\n        if match:\n            media[\"folder_title\"], media[\"folder_year\"] = match.groups()\n            media[\"folder_year\"] = (\n                int(media[\"folder_year\"]) if media[\"folder_year\"] else None\n            )\n            media[\"normalized_folder_title\"] = normalize_titles(media[\"folder_title\"])\n\n    def year_matches() -> bool:\n        asset_year = asset.get(\"year\")\n        media_years = [\n            media.get(key) for key in [\"year\", \"secondary_year\", \"folder_year\"]\n        ]\n        if asset_year is None and all(year is None for year in media_years):\n            return True\n        return any(asset_year == year for year in media_years if year is not None)\n\n    def has_any_valid_id(d: Dict[str, Any]) -> bool:\n        for k in [\"tmdb_id\", \"tvdb_id\", \"imdb_id\"]:\n            v = d.get(k)\n            if k == \"imdb_id\":\n                if v and isinstance(v, str) and v.startswith(\"tt\"):\n                    return True\n            else:\n                if v and str(v).isdigit() and int(v) > 0:\n                    return True\n        return False\n\n    has_asset_ids = has_any_valid_id(asset)\n    has_media_ids = has_any_valid_id(media)\n\n    if strict_folder_match:\n        match_criteria = [\n            (\n                asset.get(\"media_folder\") == media.get(\"folder\"),\n                \"Asset folder equals media folder (media_folder)\",\n            ),\n            (\n                asset.get(\"folder\") == media.get(\"folder\"),\n                \"Asset folder equals media folder (folder)\",\n            ),\n        ]\n        for condition, reason in match_criteria:\n            if condition and year_matches():\n                return True, reason\n        return False, \"\"\n\n    if has_asset_ids and has_media_ids:\n        id_match_criteria = [\n            (\n                media.get(\"tvdb_id\")\n                and asset.get(\"tvdb_id\")\n                and media[\"tvdb_id\"] == asset[\"tvdb_id\"],\n                \"ID match: tvdb_id\",\n            ),\n            (\n                media.get(\"tmdb_id\")\n                and asset.get(\"tmdb_id\")\n                and media[\"tmdb_id\"] == asset[\"tmdb_id\"],\n                \"ID match: tmdb_id\",\n            ),\n            (\n                media.get(\"imdb_id\")\n                and asset.get(\"imdb_id\")\n                and media[\"imdb_id\"] == asset[\"imdb_id\"],\n                \"ID match: imdb_id\",\n            ),\n        ]\n        for matched, reason in id_match_criteria:\n            if matched:\n                return True, reason\n        return False, \"\"\n\n    match_criteria = [\n        (asset.get(\"title\") == media.get(\"title\"), \"Asset title equals media title\"),\n        (\n            asset.get(\"title\") in media.get(\"alternate_titles\", []),\n            \"Asset title found in media's alternate titles\",\n        ),\n        (asset.get(\"title\") == media.get(\"folder\"), \"Asset title equals media folder\"),\n        (\n            asset.get(\"title\") == media.get(\"original_title\"),\n            \"Asset title equals media original title\",\n        ),\n        (\n            asset.get(\"normalized_title\") == media.get(\"normalized_title\"),\n            \"Asset normalized title equals media normalized title\",\n        ),\n        (\n            asset.get(\"normalized_title\") == media.get(\"normalized_folder\"),\n            \"Asset normalized title equals media folder normalized\",\n        ),\n        (\n            asset.get(\"normalized_title\")\n            in media.get(\"normalized_alternate_titles\", []),\n            \"Asset normalized title found in media's normalized alternate titles\",\n        ),\n        (\n            any(\n                assets == media.get(\"title\")\n                for assets in asset.get(\"alternate_titles\", [])\n            ),\n            \"One of asset's alternate_titles matches media title\",\n        ),\n        (\n            any(\n                assets == media.get(\"normalized_title\")\n                for assets in asset.get(\"normalized_alternate_titles\", [])\n            ),\n            \"One of asset's normalized_alternate_titles matches media normalized title\",\n        ),\n        (\n            any(\n                media_alt == asset.get(\"title\")\n                for media_alt in media.get(\"alternate_titles\", [])\n            ),\n            \"One of media's alternate_titles matches asset title\",\n        ),\n        (\n            any(\n                media_alt == asset.get(\"normalized_title\")\n                for media_alt in media.get(\"normalized_alternate_titles\", [])\n            ),\n            \"One of media's normalized_alternate_titles matches asset normalized title\",\n        ),\n        (\n            compare_strings(media.get(\"title\", \"\"), asset.get(\"title\", \"\")),\n            \"Titles match under loose string comparison\",\n        ),\n        (\n            compare_strings(\n                media.get(\"normalized_title\", \"\"), asset.get(\"normalized_title\", \"\")\n            ),\n            \"Normalized titles match under loose string comparison\",\n        ),\n    ]\n    for condition, reason in match_criteria:\n        if condition and year_matches():\n            return True, reason\n    return False, \"\"\n\n\ndef match_media_to_assets(\n    media_dict: Dict[str, List[Dict[str, Any]]],\n    prefix_index: Dict[str, Any],\n    ignore_root_folders: List[str],\n    logger: Any,\n) -> Dict[str, List[Dict[str, Any]]]:\n    \"\"\"Match media entries against known asset entries and return unmatched assets by type.\n\n    Args:\n      media_dict: Dictionary of media grouped by type.\n      prefix_index: Search index for assets.\n      ignore_root_folders: List of folder names or paths to ignore.\n      logger: Logger instance.\n\n    Returns:\n      Dictionary of unmatched entries by type as flat lists.\n    \"\"\"\n    unmatched: Dict[str, List[Dict[str, Any]]] = {\n        \"movies\": [],\n        \"series\": [],\n        \"collections\": [],\n    }\n    for media_type in [\"movies\", \"series\", \"collections\"]:\n        media_list = media_dict.get(media_type, [])\n        with progress(\n            media_list,\n            desc=f\"Matching {media_type}\",\n            total=len(media_list),\n            unit=\"media\",\n            logger=logger,\n        ) as pbar:\n            for media_data in pbar:\n                if media_type in [\"series\", \"movies\"] and media_data.get(\n                    \"status\"\n                ) not in [\"released\", \"ended\", \"continuing\"]:\n                    logger.debug(\n                        f\"Skipping {media_type} '{media_data.get('title')}' with status '{media_data.get('status')}'\"\n                    )\n                    continue\n                location = (\n                    media_data.get(\"location\")\n                    if media_type == \"collections\"\n                    else media_data.get(\"root_folder\")\n                )\n                if not location:\n                    continue\n                root = os.path.basename(location.rstrip(\"/\")).lower()\n                if ignore_root_folders and (\n                    root in ignore_root_folders or location in ignore_root_folders\n                ):\n                    continue\n                media_seasons: List[int] = []\n                if media_type == \"series\":\n                    media_seasons = [\n                        s[\"season_number\"]\n                        for s in media_data.get(\"seasons\", [])\n                        if s.get(\"season_has_episodes\")\n                    ]\n                found = False\n                tmdb_id = media_data.get(\"tmdb_id\")\n                tvdb_id = media_data.get(\"tvdb_id\")\n                candidates = []\n                id_assets_found = []\n                if tmdb_id or tvdb_id:\n                    id_assets_found = search_matches(\n                        prefix_index,\n                        media_data.get(\"title\", \"\"),\n                        logger,\n                        tmdb_id=tmdb_id,\n                        tvdb_id=tvdb_id,\n                    )\n                if id_assets_found:\n                    asset_data = id_assets_found[0]\n                    found = True\n                    if media_type == \"series\":\n                        missing = [\n                            s\n                            for s in media_seasons\n                            if s not in asset_data.get(\"season_numbers\", [])\n                        ]\n                        has_main_poster = any(\n                            not season_pattern.search(os.path.basename(f))\n                            for f in asset_data.get(\"files\", [])\n                        )\n                        missing_main_poster = not has_main_poster\n                        if missing or missing_main_poster:\n                            entry = {\n                                \"title\": media_data.get(\"title\"),\n                                \"year\": media_data.get(\"year\"),\n                                \"missing_seasons\": missing,\n                                \"missing_main_poster\": missing_main_poster,\n                            }\n                            unmatched[media_type].append(entry)\n                else:\n                    titles_to_try = [media_data.get(\"title\")] + media_data.get(\n                        \"alternate_titles\", []\n                    )\n                    for title in titles_to_try:\n                        assets_found = search_matches(prefix_index, title, logger)\n                        candidates.extend(assets_found)\n                    for asset_data in candidates:\n                        is_matched, reason = is_match(asset_data, media_data)\n                        if is_matched:\n                            logger.debug(\n                                f\"✓ Fallback match: {reason}: {media_data.get('title')} ({media_data.get('year')}) <-> {asset_data.get('title')} ({asset_data.get('year')})\"\n                            )\n                            found = True\n                            if media_type == \"series\" and media_seasons:\n                                missing = [\n                                    s\n                                    for s in media_seasons\n                                    if s not in asset_data.get(\"season_numbers\", [])\n                                ]\n                                if missing:\n                                    has_main_poster = any(\n                                        not season_pattern.search(os.path.basename(f))\n                                        for f in asset_data.get(\"files\", [])\n                                    )\n                                    missing_main_poster = not has_main_poster\n                                    unmatched[media_type].append(\n                                        {\n                                            \"title\": media_data.get(\"title\"),\n                                            \"year\": media_data.get(\"year\"),\n                                            \"missing_seasons\": missing,\n                                            \"missing_main_poster\": missing_main_poster,\n                                        }\n                                    )\n                            break\n                if not found:\n                    entry = {\n                        \"title\": media_data.get(\"title\"),\n                        \"year\": media_data.get(\"year\"),\n                        \"missing_main_poster\": True,\n                    }\n                    if media_type == \"series\":\n                        entry[\"missing_seasons\"] = media_seasons\n                    unmatched[media_type].append(entry)\n    return unmatched\n\n\ndef match_assets_to_media(\n    media_dict: Dict[str, List[Dict[str, Any]]],\n    prefix_index: Dict[str, Any],\n    logger: Optional[Any] = None,\n    return_unmatched_assets: bool = False,\n    config: Optional[SimpleNamespace] = None,\n    strict_folder_match: bool = False,\n) -> Dict[str, List[Dict[str, Any]]]:\n    \"\"\"Match assets to media. Optionally, return unmatched assets instead of matched.\n\n    Args:\n      media_dict: Dictionary of media grouped by type.\n      prefix_index: Search index for assets.\n      logger: Logger instance.\n      return_unmatched_assets: Whether to return unmatched assets.\n      config: Optional config namespace.\n      strict_folder_match: If True, only match if folder matches.\n\n    Returns:\n      Dictionary of matched or unmatched assets by type.\n    \"\"\"\n    asset_types = [\"movies\", \"series\", \"collections\"]\n    all_assets = {atype: [] for atype in asset_types}\n    asset_key_to_asset: Dict[Any, Any] = {}\n    for asset_list in prefix_index.values():\n        for asset in asset_list:\n            atype = asset.get(\"type\")\n            if atype in asset_types:\n                key = (\n                    asset.get(\"title\"),\n                    asset.get(\"year\"),\n                    tuple(asset.get(\"files\") or []),\n                    asset.get(\"path\"),\n                )\n                if key not in asset_key_to_asset:\n                    all_assets[atype].append(asset)\n                    asset_key_to_asset[key] = asset\n    matched_asset_keys = set()\n    matched: Dict[str, List[Dict[str, Any]]] = {atype: [] for atype in asset_types}\n    use_asset_types = [t for t in media_dict if media_dict[t] is not None]\n    total_comparisons = 0\n    total_items = 0\n    matches = 0\n    non_matches = 0\n    with progress(\n        use_asset_types,\n        desc=\"Matching assets...\",\n        total=len(use_asset_types),\n        unit=\"asset types\",\n        logger=logger,\n    ) as pbar_outer:\n        for asset_type in pbar_outer:\n            if asset_type in media_dict:\n                matched_dict: List[Dict[str, Any]] = []\n                media_data = media_dict[asset_type]\n                start_time = time.time()\n                with progress(\n                    media_data,\n                    desc=f\"Matching {asset_type}\",\n                    total=len(media_data),\n                    unit=\"media\",\n                    logger=logger,\n                ) as pbar_inner:\n                    for media in pbar_inner:\n                        total_items += 1\n                        found_match = False\n                        search_asset = None\n                        seasons = media.get(\"seasons\") or []\n                        media_seasons_numbers = [\n                            season[\"season_number\"] for season in seasons\n                        ]\n                        tmdb_id = media.get(\"tmdb_id\")\n                        tvdb_id = media.get(\"tvdb_id\")\n                        candidates = []\n                        id_candidates = []\n                        if tmdb_id or tvdb_id:\n                            id_candidates = search_matches(\n                                prefix_index,\n                                media.get(\"title\", \"\"),\n                                logger,\n                                tmdb_id=tmdb_id,\n                                tvdb_id=tvdb_id,\n                            )\n                            for candidate in id_candidates:\n                                total_comparisons += 1\n                                is_matched, reason = is_match(\n                                    candidate, media, strict_folder_match\n                                )\n                                if is_matched:\n                                    logger.debug(\n                                        f\"✓ Matched: {reason}: {media['title']} ({media['year']}) <-> {candidate['title']} ({candidate.get('year')})\"\n                                    )\n                                    search_asset = candidate\n                                    found_match = True\n                                    asset_season_numbers = search_asset.get(\n                                        \"season_numbers\", None\n                                    )\n                                    if asset_season_numbers and media_seasons_numbers:\n                                        handle_series_match(\n                                            search_asset,\n                                            media_seasons_numbers,\n                                            asset_season_numbers,\n                                        )\n                                    key = (\n                                        search_asset.get(\"title\"),\n                                        search_asset.get(\"year\"),\n                                        tuple(search_asset.get(\"files\") or []),\n                                        search_asset.get(\"path\"),\n                                    )\n                                    matched_asset_keys.add(key)\n                                    break\n                        if not found_match and not id_candidates:\n                            titles_to_check = [media[\"title\"]] + media.get(\n                                \"alternate_titles\", []\n                            )\n                            for title in titles_to_check:\n                                candidate_list = search_matches(\n                                    prefix_index, title, logger\n                                )\n                                candidates.extend(candidate_list)\n                            type_candidates = [\n                                a for a in candidates if a.get(\"type\") == asset_type\n                            ]\n                            if type_candidates:\n                                candidates = type_candidates\n                            for search_asset in candidates:\n                                total_comparisons += 1\n                                is_matched, reason = is_match(\n                                    search_asset, media, strict_folder_match\n                                )\n                                if is_matched:\n                                    logger.debug(\n                                        f\"✓ Matched: {reason}: {media['title']} ({media['year']}) <-> {search_asset['title']} ({search_asset.get('year')})\"\n                                    )\n                                    asset_season_numbers = search_asset.get(\n                                        \"season_numbers\", None\n                                    )\n                                    if (\n                                        not asset_season_numbers\n                                        or not media_seasons_numbers\n                                        or (\n                                            asset_season_numbers\n                                            and media_seasons_numbers\n                                        )\n                                    ):\n                                        found_match = True\n                                        if (\n                                            asset_season_numbers\n                                            and media_seasons_numbers\n                                        ):\n                                            handle_series_match(\n                                                search_asset,\n                                                media_seasons_numbers,\n                                                asset_season_numbers,\n                                            )\n                                        key = (\n                                            search_asset.get(\"title\"),\n                                            search_asset.get(\"year\"),\n                                            tuple(search_asset.get(\"files\") or []),\n                                            search_asset.get(\"path\"),\n                                        )\n                                        matched_asset_keys.add(key)\n                                        break\n                        if found_match:\n                            matches += 1\n                            matched_dict.append(\n                                {\n                                    \"title\": media[\"title\"],\n                                    \"year\": media[\"year\"],\n                                    \"folder\": media.get(\"folder\"),\n                                    \"files\": search_asset[\"files\"],\n                                    \"seasons_numbers\": (\n                                        search_asset.get(\"season_numbers\", None)\n                                        if search_asset\n                                        else None\n                                    ),\n                                    \"asset_ref\": search_asset,\n                                }\n                            )\n                        else:\n                            non_matches += 1\n                            candidate_titles = []\n                            if id_candidates or candidates:\n                                for c in (id_candidates or []) + (candidates or []):\n                                    ct = c.get(\"title\")\n                                    cy = c.get(\"year\")\n                                    if ct:\n                                        candidate_titles.append(\n                                            f\"{ct} ({cy})\" if cy else str(ct)\n                                        )\n                                if candidate_titles:\n                                    col_width = (\n                                        max(len(s) for s in candidate_titles) + 2\n                                    )\n                                    rows = []\n                                    for i in range(0, len(candidate_titles), 3):\n                                        chunk = candidate_titles[i : i + 3]\n                                        row = \" | \".join(\n                                            c.ljust(col_width) for c in chunk\n                                        )\n                                        rows.append(row)\n                                    candidates_str = \"\\n      \".join(rows)\n                                    logger.debug(\n                                        f\"✗ No match: {media['title']} ({media['year']})\\n\"\n                                        f\"  Candidates checked:\\n\"\n                                        f\"      {candidates_str}\"\n                                    )\n                                else:\n                                    logger.debug(\n                                        f\"✗ No match: {media['title']} ({media['year']}) | No candidates found\"\n                                    )\n                            else:\n                                logger.debug(\n                                    f\"✗ No match: {media['title']} ({media['year']}) | No candidates found\"\n                                )\n                matched[asset_type] = matched_dict\n                elapsed_time = time.time() - start_time\n                items_per_second = (\n                    len(media_data) / elapsed_time if elapsed_time > 0 else 0\n                )\n                logger.debug(\n                    f\"Completed matching for {asset_type}: {len(media_data)} items in {elapsed_time:.2f} seconds ({items_per_second:.2f} items/s)\"\n                )\n    logger.debug(f\"{total_items} total_items\")\n    logger.debug(f\"{total_comparisons} total_comparisons\")\n    logger.debug(f\"{matches} total_matches\")\n    logger.debug(f\"{non_matches} non_matches\")\n    if return_unmatched_assets:\n        unmatched_assets = {atype: [] for atype in asset_types}\n        for atype in asset_types:\n            for asset in all_assets[atype]:\n                if asset.get(\"title\", \"\").lower() == \"tmp\":\n                    continue\n                key = (\n                    asset.get(\"title\"),\n                    asset.get(\"year\"),\n                    tuple(asset.get(\"files\") or []),\n                    asset.get(\"path\"),\n                )\n                if key in matched_asset_keys:\n                    continue\n                if config and getattr(config, \"ignore_media\", None):\n                    ignore_title = asset[\"title\"]\n                    ignore_title_year = f\"{asset['title']} ({asset['year']})\"\n                    if (\n                        ignore_title in config.ignore_media\n                        or ignore_title_year in config.ignore_media\n                    ):\n                        logger.debug(\n                            f\"{asset['title']} ({asset['year']}) is in ignore_media, skipping...\"\n                        )\n                        continue\n                unmatched_assets[atype].append(\n                    {\n                        \"title\": asset[\"title\"],\n                        \"year\": asset[\"year\"],\n                        \"files\": asset[\"files\"],\n                        \"path\": asset.get(\"path\", None),\n                    }\n                )\n        return unmatched_assets\n    return matched\n\n\ndef handle_series_match(\n    asset: Dict[str, Any],\n    media_seasons_numbers: List[int],\n    asset_season_numbers: List[int],\n) -> None:\n    \"\"\"Prune asset data to remove files/seasons not present in the media entry.\n\n    Args:\n      asset: Asset dictionary with file and season data.\n      media_seasons_numbers: List of seasons found in the media source.\n      asset_season_numbers: List of seasons declared in the asset.\n    \"\"\"\n    files_to_remove = []\n    seasons_to_remove = []\n    for file in asset.get(\"files\", []):\n        if re.search(r\" - Season| - Specials\", file):\n            match = re.search(r\"Season (\\d+)\", file)\n            if match:\n                season_number = int(match.group(1))\n            elif \"Specials\" in file:\n                season_number = 0\n            else:\n                continue\n            if season_number not in media_seasons_numbers:\n                files_to_remove.append(file)\n    for file in files_to_remove:\n        asset[\"files\"].remove(file)\n    for season in asset_season_numbers:\n        if season not in media_seasons_numbers:\n            seasons_to_remove.append(season)\n    for season in seasons_to_remove:\n        asset_season_numbers.remove(season)\n"
  },
  {
    "path": "util/normalization.py",
    "content": "import html\nimport os\nimport re\n\nfrom unidecode import unidecode\n\nfrom util.constants import (\n    common_words,\n    id_content_regex,\n    illegal_chars_regex,\n    remove_special_chars,\n    words_to_remove,\n    year_regex,\n)\n\n\ndef remove_common_words(text: str) -> str:\n    \"\"\"Remove complete words found in common_words (case-insensitive).\n\n    Args:\n        text (str): Input text.\n\n    Returns:\n        str: Text with common words removed.\n    \"\"\"\n    words = text.split()\n    filtered = [\n        word for word in words if word.lower() not in {w.lower() for w in common_words}\n    ]\n    return \" \".join(filtered)\n\n\ndef remove_tokens(text: str) -> str:\n    \"\"\"Remove specified unwanted substrings from text.\n\n    Args:\n        text (str): Input text.\n\n    Returns:\n        str: Text with tokens removed.\n    \"\"\"\n    for token in words_to_remove:\n        text = text.replace(token, \"\")\n    return text\n\n\ndef normalize_file_names(file_name: str) -> str:\n    \"\"\"Normalize filename for indexing.\n\n    Steps:\n      1. Strip extension.\n      2. Convert HTML entities and unicode to ASCII.\n      3. Remove ID tokens in curly braces.\n      4. Remove specified unwanted substrings.\n      5. Remove illegal filename characters.\n      6. Remove miscellaneous special symbols.\n      7. Remove common filler words.\n      8. Remove whitespace and lowercase.\n\n    Args:\n        file_name (str): Filename to normalize.\n\n    Returns:\n        str: Normalized filename.\n    \"\"\"\n    base, _ = os.path.splitext(file_name)\n    cleaned = unidecode(html.unescape(base))\n    cleaned = id_content_regex.sub(\"\", cleaned)\n    cleaned = remove_tokens(cleaned)\n    cleaned = illegal_chars_regex.sub(\"\", cleaned)\n    cleaned = re.sub(remove_special_chars, \"\", cleaned)\n    cleaned = remove_common_words(cleaned)\n    cleaned = cleaned.replace(\" \", \"\").lower()\n    return cleaned.strip()\n\n\ndef normalize_titles(title: str) -> str:\n    \"\"\"Normalize media title for matching and indexing.\n\n    Steps:\n      1. Strip year tag.\n      2. Convert HTML entities and unicode to ASCII.\n      3. Remove ID tokens in curly braces.\n      4. Remove specified unwanted substrings.\n      5. Remove illegal filename characters.\n      6. Remove miscellaneous special symbols.\n      7. Remove whitespace and lowercase.\n\n    Args:\n        title (str): Media title to normalize.\n\n    Returns:\n        str: Normalized title.\n    \"\"\"\n    normalized_title = year_regex.sub(\"\", title)\n    normalized_title = unidecode(html.unescape(normalized_title)).strip()\n    normalized_title = id_content_regex.sub(\"\", normalized_title)\n    normalized_title = remove_tokens(normalized_title)\n    normalized_title = illegal_chars_regex.sub(\"\", normalized_title)\n    normalized_title = re.sub(remove_special_chars, \"\", normalized_title)\n    normalized_title = normalized_title.replace(\" \", \"\").lower()\n    return normalized_title.strip()\n"
  },
  {
    "path": "util/notification.py",
    "content": "import json\nimport logging\nimport os\nimport random\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Any, Callable, Dict, List, Optional, Tuple, Union\nfrom urllib.parse import quote\n\nimport requests\nfrom apprise import Apprise\nfrom ratelimit import limits, sleep_and_retry\n\n\nclass ErrorNotifyHandler(logging.Handler):\n    \"\"\"Custom logging handler to send errors to Discord/Notifiarr via notifications.\"\"\"\n\n    def __init__(self, config, module_name=\"main\", logger=None):\n        super().__init__(level=logging.ERROR)\n        self.config = config\n        self.module_name = module_name\n        self.logger = logger  # for logging send status, not for the error itself\n\n    def emit(self, record):\n        try:\n            msg = record.getMessage()\n            tb = None\n            error_type_msg = \"\"\n            if record.exc_info:\n                tb_lines = traceback.format_exception(*record.exc_info)\n                tb = \"\".join(tb_lines)\n                if tb_lines:\n                    error_type_msg = tb_lines[-1].strip()\n            elif record.stack_info:\n                tb = record.stack_info\n            else:\n                tb = None\n\n            if error_type_msg:\n                error_msg = f\"{msg}\\n{error_type_msg}\"\n            else:\n                error_msg = msg\n\n            output = {\n                \"error_message\": error_msg,\n                \"traceback\": tb,\n                \"color\": \"FF0000\",\n                \"source_module\": getattr(record, \"module\", self.module_name),\n            }\n\n            notify_mod = \"error_notify\"\n            config = self.config\n            if hasattr(config, \"module_name\"):\n                old_mod = config.module_name\n                config.module_name = notify_mod\n                send_notification(self.logger or config, notify_mod, config, output)\n                config.module_name = old_mod\n            else:\n                temp_cfg = dict(config)\n                temp_cfg[\"module_name\"] = notify_mod\n                send_notification(self.logger or config, notify_mod, temp_cfg, output)\n        except Exception as e:\n            if self.logger:\n                self.logger.error(\n                    f\"[ErrorNotifyHandler] Failed to send error notification: {e}\"\n                )\n\n\n@dataclass\nclass NotifiarrConfig:\n    webhook: str\n    channel_id: int\n\n\ndef extract_error(resp: requests.Response) -> str:\n    \"\"\"Extract a user-friendly error message from an HTTP response.\n\n    Args:\n      resp: HTTP response object.\n\n    Returns:\n      User-friendly error message.\n    \"\"\"\n    try:\n        data = resp.json()\n        return data.get(\"error\", resp.text)\n    except ValueError:\n        return resp.text\n\n\ndef build_notifiarr_payload(module_title: str, cid: int) -> Dict[str, Any]:\n    \"\"\"Build the JSON payload for Notifiarr Passthrough.\n\n    Args:\n      module_title: Title of the module.\n      cid: Channel ID.\n\n    Returns:\n      Notifiarr payload dict.\n    \"\"\"\n    return {\n        \"notification\": {\"update\": False, \"name\": module_title, \"event\": \"0\"},\n        \"discord\": {\n            \"color\": \"\",\n            \"ping\": {\"pingUser\": 0, \"pingRole\": 0},\n            \"images\": {\"thumbnail\": \"\", \"image\": \"\"},\n            \"text\": {\n                \"title\": \"Test Notification\",\n                \"icon\": \"\",\n                \"content\": \"This is a test notification.\",\n                \"description\": \"This is a test notification.\",\n                \"fields\": [],\n                \"footer\": \"\",\n            },\n            \"ids\": {\"channel\": cid},\n        },\n    }\n\n\ndef build_discord_payload(\n    module_title: str,\n    data: Any,\n    timestamp: str,\n    dry_run: bool = False,\n    color: Union[int, str] = 0x00FF00,\n) -> List[Dict[str, Any]]:\n    \"\"\"Build Discord payload(s) for embeds/content.\n\n    Args:\n      module_title: Title of the module.\n      data: Data for the payload.\n      timestamp: ISO timestamp string.\n      dry_run: If True, marks as dry run.\n      color: Embed color as int (0xRRGGBB) or hex string (\"FF0000\" or \"#FF0000\").\n\n    Returns:\n      List of Discord payload dicts.\n    \"\"\"\n    # Handle string color input\n    if isinstance(color, str):\n        color = int(color.lstrip(\"#\"), 16)\n    payloads: List[Dict[str, Any]] = []\n    if isinstance(data, dict):\n        data = [\n            {\n                \"embed\": True,\n                \"fields\": fields,\n                \"part\": f\" (Part {idx} of {len(data)})\" if len(data) > 1 else \"\",\n            }\n            for idx, fields in data.items()\n        ]\n    for part in data:\n        payload: Dict[str, Any] = {}\n        if \"embed\" in part:\n            payload[\"embeds\"] = [\n                {\n                    \"title\": f\"{module_title} Notification{part.get('part', '')}\",\n                    \"description\": None,\n                    \"color\": color,\n                    \"timestamp\": timestamp,\n                    \"fields\": part.get(\"fields\", []),\n                    \"footer\": {\"text\": f\"Powered by: Drazzilb | {get_random_joke()}\"},\n                }\n            ]\n        if \"content\" in part:\n            payload[\"content\"] = (\n                f\"__**Dry Run**__\\n{part['content']}\" if dry_run else part[\"content\"]\n            )\n        elif dry_run:\n            payload[\"content\"] = \"__**Dry Run**__\"\n        payload[\"username\"] = \"Notification Bot\"\n        payloads.append(payload)\n    return payloads\n\n\n@sleep_and_retry\n@limits(calls=5, period=5)\ndef safe_post(url: str, payload: Dict[str, Any]) -> requests.Response:\n    \"\"\"Send a POST request with a JSON payload.\n\n    Args:\n      url: Target URL.\n      payload: Payload dict.\n\n    Returns:\n      HTTP response.\n    \"\"\"\n    return requests.post(url, json=payload)\n\n\ndef get_random_joke() -> str:\n    \"\"\"Retrieve a random joke from jokes.txt in the parent directory.\n\n    Returns:\n      A random joke string, or empty string if not found.\n    \"\"\"\n    root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), \"..\"))\n    jokes_path = os.path.join(root_dir, \"jokes.txt\")\n    if os.path.exists(jokes_path):\n        with open(jokes_path, encoding=\"utf-8\") as f:\n            jokes = [line.strip() for line in f if line.strip()]\n            if jokes:\n                return random.choice(jokes)\n    return \"\"\n\n\ndef send_and_log_response(\n    logger: Any, label: str, hook: str, payload: Dict[str, Any]\n) -> None:\n    \"\"\"Send a POST request and log the response status.\n\n    Args:\n      logger: Logger instance.\n      label: Notification label.\n      hook: Webhook URL.\n      payload: Payload dict.\n    \"\"\"\n    try:\n        resp = safe_post(hook, payload)\n        if resp.status_code not in (200, 204):\n            err = format_notification_error(resp, label)\n            logger.error(\n                f\"[Notification] ❌ {label} failed ({resp.status_code}): {err}\\n\"\n                f\"Payload:\\n{json.dumps(payload, indent=2)}\"\n            )\n        else:\n            logger.info(f\"[Notification] ✅ {label} notification sent.\")\n    except Exception as e:\n        logger.error(f\"[Notification] {label} send exception: {e}\")\n\n\ndef send_notifiarr_notification(\n    logger: Any,\n    config: Any,\n    auth_data: NotifiarrConfig,\n    module_title: str,\n    output: Any,\n    test: bool = False,\n) -> Optional[Tuple[bool, str]]:\n    \"\"\"Send structured notifications to Notifiarr via Passthrough API.\n\n    Args:\n      logger: Logger instance.\n      config: Configuration object.\n      auth_data: NotifiarrConfig instance.\n      module_title: Module title.\n      output: Output data.\n      test: Whether to send a test notification.\n\n    Returns:\n      (success, message) if test, else None.\n    \"\"\"\n    hook = auth_data.webhook.rstrip(\"/\")\n    cid = auth_data.channel_id\n    payload = build_notifiarr_payload(module_title, cid)\n    if test:\n        resp = safe_post(hook, payload)\n        success = resp.status_code in (200, 204)\n        msg = (\n            \"Test notification sent via Notifiarr.\"\n            if success\n            else f\"Notifiarr Test failed ({resp.status_code}): {extract_error(resp)}\"\n        )\n        return success, msg\n    from util.notification_formatting import format_for_discord\n\n    data, _ = format_for_discord(config, output)\n    parts: List[Dict[str, Any]] = []\n    if isinstance(data, dict):\n        for idx, fields in data.items():\n            parts.append(\n                {\n                    \"embed\": True,\n                    \"fields\": fields,\n                    \"part\": f\" (Part {idx} of {len(data)})\" if len(data) > 1 else \"\",\n                }\n            )\n    else:\n        parts = data\n    for part in parts:\n        pt_payload = {\n            \"notification\": {\"update\": False, \"name\": module_title, \"event\": \"\"},\n            \"discord\": {\n                \"color\": \"\",\n                \"ping\": {\"pingUser\": 0, \"pingRole\": 0},\n                \"images\": {\"thumbnail\": \"\", \"image\": \"\"},\n                \"ids\": {\"channel\": cid},\n            },\n        }\n        if part.get(\"embed\"):\n            fields = [\n                {\n                    \"title\": f.get(\"name\", \"\"),\n                    \"text\": f.get(\"value\", \"\"),\n                    \"inline\": bool(f.get(\"inline\", False)),\n                }\n                for f in part.get(\"fields\", [])\n            ]\n            pt_payload[\"discord\"][\"text\"] = {\n                \"title\": f\"{module_title} Notification{part.get('part','')}\",\n                \"fields\": fields,\n                \"footer\": get_random_joke(),\n            }\n        else:\n            content = part.get(\"content\")\n            pt_payload[\"discord\"][\"text\"] = {\n                \"description\": \" \",\n                \"content\": content,\n            }\n        color = output.get(\"color\", \"00FF00\") if isinstance(output, dict) else \"00FF00\"\n        if isinstance(color, int):\n            color = f\"{color:06X}\"\n        elif isinstance(color, str):\n            color = color.lstrip(\"#\")\n        pt_payload[\"discord\"][\"color\"] = color\n        send_and_log_response(logger, \"Notifiarr\", hook, pt_payload)\n    return None\n\n\ndef send_discord_notification(\n    logger: Any,\n    config: Any,\n    hook: str,\n    module_title: str,\n    output: Any,\n) -> None:\n    from util.notification_formatting import format_for_discord\n\n    data, _ = format_for_discord(config, output)\n    timestamp = datetime.utcnow().isoformat()\n    dry_run = getattr(config, \"dry_run\", False)\n    if isinstance(output, dict):\n        color = output.get(\"color\", 0x00FF00)\n    else:\n        color = 0x00FF00\n    for payload in build_discord_payload(\n        module_title, data, timestamp, dry_run=dry_run, color=color\n    ):\n        send_and_log_response(logger, \"Discord\", hook, payload)\n\n\ndef extract_apprise_errors(apprise: Apprise) -> str:\n    \"\"\"Extract concise error messages from Apprise services.\n\n    Args:\n      apprise: Apprise instance.\n\n    Returns:\n      Concatenated error message string.\n    \"\"\"\n    errors: List[str] = []\n    for service in apprise:\n        if hasattr(service, \"last_response\") and service.last_response:\n            errors.append(f\"Last response: {service.last_response}\")\n        if hasattr(service, \"response\") and service.response:\n            errors.append(f\"Response: {service.response}\")\n        if hasattr(service, \"details\") and callable(service.details):\n            try:\n                details = service.details()\n                if details:\n                    errors.append(f\"Details: {details}\")\n            except Exception:\n                pass\n        errors.append(f\"Service config: {service}\")\n    return \"; \".join(errors) if errors else \"Unknown error\"\n\n\ndef format_notification_error(source: Any, label: str = \"\") -> str:\n    \"\"\"Return a user-friendly error message from a response or Apprise.\n\n    Args:\n      source: requests.Response or Apprise instance.\n      label: Optional label.\n\n    Returns:\n      Error message string.\n    \"\"\"\n    if isinstance(source, requests.Response):\n        return extract_error(source)\n    try:\n        if isinstance(source, Apprise):\n            return extract_apprise_errors(source)\n    except Exception:\n        pass\n    return f\"{label} unknown error\"\n\n\ndef format_module_title(name: str) -> str:\n    \"\"\"Convert a module name to a human-readable title.\n\n    Args:\n      name: Module name.\n\n    Returns:\n      Title string.\n    \"\"\"\n    return name.replace(\"_\", \" \").title()\n\n\ndef send_apprise_notification(\n    logger: Any,\n    label: str,\n    apprise: Apprise,\n    title: str,\n    body: str,\n    body_format: str = \"text\",\n) -> bool:\n    \"\"\"Send a notification via Apprise and log the result.\n\n    Args:\n      logger: Logger instance.\n      label: Notification label.\n      apprise: Apprise instance.\n      title: Notification title.\n      body: Notification body.\n      body_format: Body format.\n\n    Returns:\n      True on success, False on failure.\n    \"\"\"\n    try:\n        success = apprise.notify(title=title, body=body, body_format=body_format)\n        if success:\n            logger.info(f\"[Notification] ✅ {label} sent via Apprise.\")\n        else:\n            err_msg = format_notification_error(apprise, label)\n            logger.error(f\"[Notification] ❌ {label} failed via Apprise: {err_msg}\")\n        return success\n    except Exception as e:\n        logger.error(\n            f\"[Notification] ❌ {label} exception via Apprise: {e}\", exc_info=True\n        )\n        return False\n\n\ndef send_email_notification(\n    logger: Any,\n    config: Any,\n    apprise: Apprise,\n    module_title: str,\n    output: Any,\n) -> None:\n    \"\"\"Send an HTML formatted email using Apprise.\n\n    Args:\n      logger: Logger instance.\n      config: Configuration object.\n      apprise: Apprise instance.\n      module_title: Module title.\n      output: Output data.\n    \"\"\"\n    from util.notification_formatting import format_for_email\n\n    try:\n        body, success = format_for_email(config, output)\n        if not success:\n            logger.warning(\"[Notification] Email skipped: no formatter found.\")\n            return\n        subject = f\"{module_title} Notification\"\n        send_apprise_notification(\n            logger, f\"{module_title} Email\", apprise, subject, body, \"html\"\n        )\n    except Exception as e:\n        logger.error(\n            f\"[Notification] Unhandled exception during email notification: {e}\",\n            exc_info=True,\n        )\n\n\ndef collect_valid_targets(\n    config: Any, logger: Any, test: bool = False\n) -> Dict[str, Union[str, Tuple[str, Union[str, int]]]]:\n    \"\"\"Collect and format valid notification targets from the configuration.\n\n    Args:\n      config: Configuration object.\n      logger: Logger instance.\n      test: If True, format for test mode.\n\n    Returns:\n      Dictionary of notification targets.\n    \"\"\"\n    target_data: Dict[str, Union[str, Tuple[str, Union[str, int]]]] = {}\n    notification_targets = getattr(config, \"notifications\", None)\n    if notification_targets is None and isinstance(config, dict):\n        notification_targets = config.get(\"notifications\", [])\n    if notification_targets is None:\n        notification_targets = {}\n    try:\n        for ttype, target in notification_targets.items():\n            if not isinstance(target, dict):\n                logger.warning(f\"Invalid config structure for {ttype}: expected dict.\")\n                target_data[ttype] = f\"Invalid config for {ttype}\"\n                continue\n            if ttype == \"discord\":\n                if test:\n                    hook = target.get(\"webhook\", \"\").rstrip(\"/\")\n                    parts = hook.rstrip(\"/\").split(\"/\")\n                    if len(parts) >= 7 and parts[4] == \"webhooks\":\n                        webhook_id = parts[5]\n                        token = parts[6]\n                        apprise_url = f\"discord://{webhook_id}/{token}\"\n                        target_data[\"discord\"] = apprise_url\n                    else:\n                        msg = \"Invalid Discord webhook URL\"\n                        logger.warning(msg)\n                        target_data[\"discord\"] = msg\n                else:\n                    hook = target.get(\"webhook\", \"\").rstrip(\"/\")\n                    if hook:\n                        target_data[ttype] = hook\n                    else:\n                        msg = \"Invalid Notifiarr configuration\"\n                        logger.warning(msg)\n                        target_data[ttype] = msg\n            elif ttype == \"notifiarr\":\n                hook = target.get(\"webhook\", \"\").rstrip(\"/\")\n                cid = target.get(\"channel_id\")\n                if hook and cid is not None:\n                    target_data[\"notifiarr\"] = {\n                        \"webhook\": hook,\n                        \"channel_id\": int(cid),\n                    }\n                else:\n                    logger.warning(\"Invalid Notifiarr configuration\")\n                    target_data[\"notifiarr\"] = \"Invalid Notifiarr configuration\"\n            elif ttype == \"email\":\n                smtp_server = target.get(\"smtp_server\")\n                smtp_port = target.get(\"smtp_port\", 587)\n                username = target.get(\"username\", \"\")\n                password = target.get(\"password\", \"\")\n                from_addr = target.get(\"from\", \"\")\n                to_addrs = target.get(\"to\", [])\n                use_tls = target.get(\"use_tls\", False)\n                if smtp_server and from_addr and to_addrs:\n                    proto = \"mailtos\" if use_tls else \"mailto\"\n                    if username and password:\n                        user = quote(username, safe=\"\")\n                        pwd = quote(password, safe=\"\")\n                        auth = f\"{user}:{pwd}@\"\n                    else:\n                        auth = \"\"\n                    host_part = f\"{smtp_server}:{smtp_port}\"\n                    params = []\n                    to_addrs_list = (\n                        [to_addrs] if isinstance(to_addrs, str) else to_addrs\n                    )\n                    params.append(\"to=\" + quote(\",\".join(to_addrs_list)))\n                    params.append(\"from=\" + quote(from_addr))\n                    query = \"&\".join(params)\n                    mail_url = f\"{proto}://{auth}{host_part}?{query}\"\n                    target_data[ttype] = mail_url\n                else:\n                    msg = \"Invalid email configuration\"\n                    logger.warning(msg)\n                    target_data[ttype] = msg\n            else:\n                target_data[ttype] = f\"Invalid - Unknown notification type: {ttype}\"\n    except Exception as e:\n        logger.error(f\"[Notification] Error collecting targets: {e}\", exc_info=True)\n        target_data = {}\n    return target_data\n\n\ndef send_test_notification(\n    payload: Dict[str, Any], logger: Any\n) -> Dict[str, Union[str, bool, None]]:\n    \"\"\"Send a simple test notification using Apprise.\n\n    Args:\n      payload: Payload dict.\n      logger: Logger instance.\n\n    Returns:\n      Result dict with type, message, and result.\n    \"\"\"\n    module_name = payload.get(\"module\", \"Unknown\")\n    module_title = format_module_title(module_name)\n    target_data = collect_valid_targets(payload, logger=logger, test=True)\n    for target, data in target_data.items():\n        entry: Dict[str, Union[str, bool, None]] = {\n            \"type\": target,\n            \"message\": None,\n            \"result\": False,\n        }\n        if not data or (isinstance(data, str) and data.startswith(\"Invalid\")):\n            entry[\"message\"] = (\n                data\n                if isinstance(data, str)\n                else f\"No valid URL for '{target.upper()}'\"\n            )\n            entry[\"result\"] = False\n            entry[\"type\"] = target\n            return entry\n        if target == \"notifiarr\":\n            cfg = NotifiarrConfig(**data)\n            success, msg = send_notifiarr_notification(\n                logger, None, cfg, module_title, None, test=True\n            )\n            return {\"type\": target, \"message\": msg, \"result\": success}\n        apprise = Apprise()\n        apprise.add(data)\n        subject = f\"{target} Notification Test\"\n        body = \"This is a test notification.\"\n        success = send_apprise_notification(\n            logger, f\"{target} Notification Test\", apprise, subject, body, \"text\"\n        )\n        entry[\"message\"] = (\n            \"Notification sent successfully.\"\n            if success\n            else extract_apprise_errors(apprise)\n        )\n        entry[\"result\"] = success\n        entry[\"type\"] = target\n        return entry\n    return {\n        \"type\": None,\n        \"message\": \"No valid notification targets found.\",\n        \"result\": False,\n    }\n\n\nSEND_HANDLERS: Dict[str, Callable[..., Any]] = {\n    \"notifiarr\": send_notifiarr_notification,\n    \"discord\": send_discord_notification,\n    \"email\": send_email_notification,\n}\n\n\ndef send_notification(logger: Any, module_name: str, config: Any, output: Any) -> None:\n    \"\"\"Dispatch notifications to Discord, Notifiarr, and other Apprise targets.\n\n    Args:\n      logger: Logger instance.\n      module_name: Module name.\n      config: Configuration object.\n      output: Output data.\n    \"\"\"\n    target_data = collect_valid_targets(config, logger)\n    module_title = format_module_title(module_name)\n    for target, data in target_data.items():\n        logger.debug(f\"[Notification] Queued {target} via Apprisse\")\n        handler = SEND_HANDLERS.get(target)\n        if handler:\n            if target == \"notifiarr\":\n                cfg = NotifiarrConfig(**data)\n                handler(logger, config, cfg, module_title, output)\n            elif target == \"discord\":\n                handler(logger, config, data, module_title, output)\n            elif target == \"email\":\n                apprise = Apprise()\n                apprise.add(data)\n                handler(logger, config, apprise, module_title, output)\n        else:\n            logger.warning(f\"[Notification] Unknown target: {target}\")\n"
  },
  {
    "path": "util/notification_formatting.py",
    "content": "import os\nfrom typing import Any, Dict, List, Tuple\n\nfrom util.notification import get_random_joke\n\n\ndef format_for_discord(\n    config: Any, output: Any\n) -> Tuple[Dict[int, List[Dict[str, Any]]], bool]:\n    \"\"\"Format notification output for Discord embeds and chunking.\n\n    Args:\n        config: Module config object (must have 'module_name').\n        output: Output from the module to be formatted.\n\n    Returns:\n        Tuple of (embed field dict, success bool).\n    \"\"\"\n    DISCORD_FIELD_CHAR_LIMIT = 1000\n    DISCORD_EMBED_CHAR_LIMIT = 5000\n    DISCORD_FIELD_COUNT_LIMIT = 25\n\n    def chunk_code_fields(\n        name: str, text: str, inline: bool = False\n    ) -> List[Dict[str, Any]]:\n        \"\"\"Chunk a string into Discord embed fields by char limit.\n\n        Args:\n          name: Name of the field (used for the first chunk).\n          text: The code/text to chunk.\n          inline: Whether this field should be inline.\n\n        Returns:\n          List of Discord embed field dicts.\n        \"\"\"\n        fields: List[Dict[str, Any]] = []\n        lines = text.split(\"\\n\")\n        buffer = \"\"\n        first = True\n        for line in lines:\n            candidate = buffer + line + \"\\n\"\n            if len(candidate) > DISCORD_FIELD_CHAR_LIMIT:\n                # Discord embed field value chunk limit reached.\n                field = {\n                    \"name\": name if first else \"\",\n                    \"value\": f\"```{buffer.rstrip()}```\",\n                }\n                if inline:\n                    field[\"inline\"] = True\n                fields.append(field)\n                buffer = line + \"\\n\"\n                first = False\n            else:\n                buffer = candidate\n        if buffer:\n            field = {\"name\": name if first else \"\", \"value\": f\"```{buffer.rstrip()}```\"}\n            if inline:\n                field[\"inline\"] = True\n            fields.append(field)\n        return fields\n\n    def split_fields(fields: List[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]:\n        \"\"\"Split embed fields into multiple Discord embeds by char and field limits.\n\n        Args:\n          fields: List of Discord embed field dicts.\n\n        Returns:\n          Dict mapping embed index to list of fields for that embed.\n        \"\"\"\n        expanded: List[Dict[str, Any]] = []\n        for f in fields:\n            name = f.get(\"name\", \"\")\n            inline = f.get(\"inline\", False)\n            val = f.get(\"value\", \"\")\n            if val == \"\":\n                chunk = {\"name\": name, \"value\": \"\"}\n                if inline:\n                    chunk[\"inline\"] = True\n                expanded.append(chunk)\n                continue\n            # Unwrap code block if present\n            content = (\n                val[3:-3] if val.startswith(\"```\") and val.endswith(\"```\") else val\n            )\n            lines = content.split(\"\\n\")\n            buffer = \"\"\n            first = True\n            for line in lines:\n                candidate = buffer + line + \"\\n\"\n                if len(candidate) > DISCORD_FIELD_CHAR_LIMIT:\n                    chunk = {\n                        \"name\": name if first else \"\",\n                        \"value\": f\"```{buffer.strip()}```\",\n                    }\n                    if inline:\n                        chunk[\"inline\"] = True\n                    expanded.append(chunk)\n                    buffer = line + \"\\n\"\n                    first = False\n                else:\n                    buffer = candidate\n            if buffer:\n                chunk = {\n                    \"name\": name if first else \"\",\n                    \"value\": f\"```{buffer.strip()}```\",\n                }\n                if inline:\n                    chunk[\"inline\"] = True\n                expanded.append(chunk)\n        # Batch fields into embeds, respecting Discord's limits.\n        result: Dict[int, List[Dict[str, Any]]] = {}\n        batch: List[Dict[str, Any]] = []\n        size_acc = 0\n        idx = 1\n        limit = (\n            DISCORD_EMBED_CHAR_LIMIT + 500\n        )  # Discord embed character limit (buffered)\n        for f in expanded:\n            est = len(f.get(\"name\", \"\")) + len(f.get(\"value\", \"\")) + 30\n            if len(batch) >= DISCORD_FIELD_COUNT_LIMIT or size_acc + est > limit:\n                result[idx] = batch\n                idx += 1\n                batch = []\n                size_acc = 0\n            batch.append(f)\n            size_acc += est\n        if batch:\n            result[idx] = batch\n        return result\n\n    def chunk_flat_content(\n        header: str, content: str, footer: str = \"\"\n    ) -> List[Dict[str, Any]]:\n        \"\"\"Chunk plain content into Discord message blocks under 1900 chars.\n\n        Args:\n          header: Header to prepend to the first chunk.\n          content: The content to chunk.\n          footer: Footer to append to the last chunk.\n\n        Returns:\n          List of Discord message dicts (with 'content' key).\n        \"\"\"\n        CHUNK_LIMIT = 1900\n        lines = content.split(\"\\n\")\n        results = []\n        buffer_lines: List[str] = []\n        first_chunk = True\n\n        def flush_chunk(buf_lines: List[str], is_last: bool) -> None:\n            chunk_text = \"\\n\".join(buf_lines)\n            parts: List[str] = []\n            if first_chunk and header:\n                parts.append(header)\n            parts.append(f\"```{chunk_text}```\")\n            if is_last and footer:\n                parts.append(footer)\n            results.append({\"content\": \"\\n\".join(parts)})\n\n        for line in lines:\n            buffer_lines.append(line)\n            total_len = sum(len(line) for line in buffer_lines) + len(buffer_lines) - 1\n            if total_len > CHUNK_LIMIT:\n                overflow_line = buffer_lines.pop()\n                flush_chunk(buffer_lines, is_last=False)\n                first_chunk = False\n                buffer_lines = [overflow_line]\n        if buffer_lines:\n            flush_chunk(buffer_lines, is_last=True)\n        return results\n\n    def fmt_poster_renamerr(o: Any) -> List[Dict[str, Any]]:\n        \"\"\"Format poster_renamerr output for Discord embeds.\n\n        Args:\n          o: Output data for poster_renamerr.\n\n        Returns:\n          List of Discord embed field dicts.\n        \"\"\"\n        fields: List[Dict[str, Any]] = []\n        for assets in o.values():\n            for a in assets:\n                title = a.get(\"title\", \"\")\n                year = f\" ({a.get('year')})\" if a.get(\"year\") else \"\"\n                msgs = sorted(a.get(\"discord_messages\", []))\n                text = \"\\n\".join([f\"{title}{year}\"] + [f\"\\t{m}\" for m in msgs])\n                fields.extend(chunk_code_fields(f\"{title}{year}\", text))\n        return fields\n\n    def fmt_renameinatorr(o: Any) -> List[Dict[str, Any]]:\n        \"\"\"Format renameinatorr output for Discord embeds.\n\n        Args:\n          o: Output data for renameinatorr.\n\n        Returns:\n          List of Discord embed field dicts.\n        \"\"\"\n        grouped: Dict[str, List[str]] = {}\n        for inst in o.values():\n            for item in inst.get(\"data\", []):\n                title = item.get(\"title\", \"Unknown\")\n                year = item.get(\"year\")\n                name = f\"{title}{f' ({year})' if year else ''}\"\n                lst = grouped.setdefault(name, [])\n                if np := item.get(\"new_path_name\"):\n                    lst.append(\n                        f\"Folder:\\n{item.get('path_name','').lstrip('/')} -> {np.lstrip('/')}\"\n                    )\n                for old, new in item.get(\"file_info\", {}).items():\n                    lst.append(old.lstrip(\"/\"))\n                    lst.append(new.lstrip(\"/\"))\n        fields: List[Dict[str, Any]] = []\n        for name, lines in grouped.items():\n            if not lines:\n                continue\n            text = \"\\n\".join(lines)\n            fields.append({\"name\": name, \"value\": f\"```{text}```\"})\n        return fields\n\n    def fmt_health_checkarr(o: Any) -> List[Dict[str, Any]]:\n        \"\"\"Format health_checkarr output for Discord embeds.\n\n        Args:\n          o: Output data for health_checkarr.\n\n        Returns:\n          List of Discord embed field dicts.\n        \"\"\"\n        fields: List[Dict[str, Any]] = []\n        grouped: Dict[str, List[str]] = {}\n        for item in output:\n            title = item.get(\"title\", \"Untitled\")\n            year = f\" ({item.get('year')})\" if item.get(\"year\") else \"\"\n            db_id = (\n                item[\"tvdb_id\"]\n                if item[\"instance_type\"] == \"sonarr\"\n                else item.get(\"tmdb_id\")\n            )\n            grouped.setdefault(item[\"instance_name\"], []).append(\n                f\"{title}{year}\\t{db_id}\"\n            )\n        for instance, lines in grouped.items():\n            text = \"\\n\".join(lines)\n            fields.extend(chunk_code_fields(instance, text))\n        if fields:\n            summary = (\n                \"🔍 The following items were flagged as removed from TMDB/TVDB and would be deleted.\"\n                if output and output[0].get(\"dry_run\")\n                else \"🧹 The following items were deleted as they were removed from TMDB/TVDB.\"\n            )\n            fields.insert(0, {\"name\": \"Summary\", \"value\": f\"```{summary}```\"})\n        return fields\n\n    def fmt_nohl(o: Any) -> List[Dict[str, Any]]:\n        \"\"\"Format nohl output for Discord embeds.\n\n        Args:\n          o: Output data for nohl.\n\n        Returns:\n          List of Discord embed field dicts.\n        \"\"\"\n        fields: List[Dict[str, Any]] = []\n        scanned = o.get(\"scanned\", {})\n        for path, results in scanned.items():\n            title = f\"Scanned: {os.path.basename(path).capitalize()}\"\n            lines: List[str] = []\n            for item in results.get(\"movies\", []):\n                lines.append(f\"{item['title']} ({item['year']})\")\n            for item in results.get(\"series\", []):\n                lines.append(f\"{item['title']} ({item['year']})\")\n                for season in item.get(\"season_info\", []):\n                    lines.append(f\"\\tSeason: {season['season_number']}\")\n                    for episode in season.get(\"episodes\", []):\n                        lines.append(f\"\\t\\tEpisode: {episode}\")\n            if lines:\n                fields.extend(chunk_code_fields(title, \"\\n\".join(lines)))\n            else:\n                fields.append(\n                    {\n                        \"name\": \"✅ All Scanned files are hardlinked!\",\n                        \"value\": \"\",\n                    }\n                )\n        resolved = o.get(\"resolved\", {})\n        for instance, data in resolved.items():\n            srv = data.get(\"server_name\", instance)\n            inst_type = data.get(\"instance_type\", \"\")\n            title = f\"Resolved: {srv}\"\n            sm = data.get(\"data\", {}).get(\"search_media\", [])\n            if not sm:\n                fields.append(\n                    {\n                        \"name\": f\"✅ {srv} all resolve files are hardlinked!\",\n                        \"value\": \"\",\n                    }\n                )\n                continue\n            lines: List[str] = []\n            for item in sm:\n                if inst_type == \"radarr\":\n                    lines.append(f\"{item['title']} ({item['year']})\")\n                else:\n                    lines.append(f\"{item['title']} ({item['year']})\")\n                    for season in item.get(\"seasons\", []):\n                        lines.append(f\"\\tSeason {season['season_number']}\")\n                        if not season.get(\"season_pack\", False):\n                            for ep in season.get(\"episode_data\", []):\n                                lines.append(f\"\\t\\tEpisode {ep['episode_number']}\")\n                lines.append(\"\")\n            if lines:\n                fields.extend(chunk_code_fields(title, \"\\n\".join(lines)))\n        summary = o.get(\"summary\", {})\n        if not all(value == 0 for value in summary.values()):\n            title = \"Summary\"\n            lines = [\n                f\"Total Non-Hardlinked Scanned Movies: {summary.get('total_scanned_movies', 0)}\",\n                f\"Total Non-Hardlinked Scanned Series: {summary.get('total_scanned_series', 0)}\",\n                f\"Total Non-Hardlinked Resolved Movies: {summary.get('total_resolved_movies', 0)}\",\n                f\"Total Non-Hardlinked Resolved Series: {summary.get('total_resolved_series', 0)}\",\n            ]\n            fields.extend(chunk_code_fields(title, \"\\n\".join(lines)))\n        return fields\n\n    def fmt_upgradinatorr(o: Any) -> List[Dict[str, Any]]:\n        \"\"\"Format upgradinatorr output for Discord embeds.\n\n        Args:\n          o: Output data for upgradinatorr.\n\n        Returns:\n          List of Discord embed field dicts.\n        \"\"\"\n        fields: List[Dict[str, Any]] = []\n        for inst, data in o.items():\n            srv = data.get(\"server_name\", inst)\n            lines: List[str] = []\n            for item in data.get(\"data\", []):\n                dl = item.get(\"download\") or {}\n                if dl:\n                    title = item.get(\"title\", \"Unknown\")\n                    year = f\" ({item.get('year')})\" if item.get(\"year\") else \"\"\n                    lines.append(f\"{title}{year}\")\n                    for t, score in dl.items():\n                        lines.append(f\"\\t{t}\")\n                        lines.append(f\"\\tCF Score: {score}\")\n                    lines.append(\"\")\n            if lines:\n                fields.extend(chunk_code_fields(srv, \"\\n\".join(lines).strip()))\n        return fields\n\n    def fmt_labelarr(o: Any) -> List[Dict[str, Any]]:\n        \"\"\"Format labelarr output for Discord embeds.\n\n        Args:\n          o: Output data for labelarr.\n\n        Returns:\n          List of Discord embed field dicts.\n        \"\"\"\n        fields: List[Dict[str, Any]] = []\n        summary = f\"Synced {len(o)} items across configured Plex libraries.\"\n        fields.append({\"name\": \"Summary\", \"value\": f\"```{summary}```\"})\n        label_changes: Dict[Tuple[str, str], List[str]] = {}\n        for item in o:\n            for label, action in item[\"add_remove\"].items():\n                key = (label, action)\n                label_changes.setdefault(key, []).append(\n                    f\"{item['title']} ({item['year']})\"\n                )\n        for (label, action), items in label_changes.items():\n            verb = \"added to\" if action == \"add\" else \"removed from\"\n            fields.append(\n                {\n                    \"name\": f\"Label: `{label}` has been {verb}:\",\n                    \"value\": f\"```{chr(10).join(items)}```\",\n                    \"inline\": False,\n                }\n            )\n        return fields\n\n    def fmt_jduparr(o: Any) -> List[Dict[str, Any]]:\n        \"\"\"Format jduparr output for Discord flat messages.\n\n        Args:\n          o: Output data for jduparr.\n\n        Returns:\n          List of Discord message dicts (with 'content' key).\n        \"\"\"\n        results: List[Dict[str, Any]] = []\n        for item in o:\n            source_dir = item.get(\"source_dir\", \"Unknown\")\n            field_message = item.get(\"field_message\", \"\")\n            parsed_files = item.get(\"output\", [])\n            sub_count = item.get(\"sub_count\", 0)\n            dir = os.path.basename(source_dir).capitalize()\n            header = f\"_\\nSource Directory: '__**{dir}**__'\\n{field_message}\"\n            footer = f\"\\nPowered by: Drazzilb | {get_random_joke()}\"\n            lines = [f\"\\t{line}\" for line in parsed_files]\n            lines.append(f\"\\tTotal items re-linked in '{dir}': {sub_count}\")\n            content = \"\\n\".join(lines)\n            results.extend(chunk_flat_content(header, content, footer))\n        return results\n\n    def fmt_version_check(o: dict) -> list:\n        # o = {\"local_version\": \"...\", \"remote_version\": \"...\"}\n        fields = [\n            {\"name\": \"Update Available\", \"value\": \"🚨 A new update is available!\"},\n            {\"name\": \"Your Version\", \"value\": o.get(\"local_version\", \"\")},\n            {\"name\": \"Latest Version\", \"value\": o.get(\"remote_version\", \"\")},\n        ]\n        return fields\n\n    def fmt_error_notify(o: dict) -> list:\n        fields = [\n            {\"name\": \"Error\", \"value\": o.get(\"error_message\", \"\")},\n            {\"name\": \"Module\", \"value\": o.get(\"source_module\", \"\")},\n        ]\n        tb = o.get(\"traceback\")\n        if tb:\n            # Truncate traceback if too long for Discord embed field\n            if len(tb) > 1800:\n                tb = tb[:1800] + \"\\n...truncated...\"\n            fields.append({\"name\": \"Traceback\", \"value\": f\"```{tb}```\"})\n        return fields\n\n    registry: Dict[str, Dict[str, Any]] = {\n        \"poster_renamerr\": {\"formatter\": fmt_poster_renamerr, \"type\": \"embedded\"},\n        \"renameinatorr\": {\"formatter\": fmt_renameinatorr, \"type\": \"embedded\"},\n        \"health_checkarr\": {\"formatter\": fmt_health_checkarr, \"type\": \"embedded\"},\n        \"nohl\": {\"formatter\": fmt_nohl, \"type\": \"embedded\"},\n        \"upgradinatorr\": {\"formatter\": fmt_upgradinatorr, \"type\": \"embedded\"},\n        \"labelarr\": {\"formatter\": fmt_labelarr, \"type\": \"embedded\"},\n        \"jduparr\": {\"formatter\": fmt_jduparr, \"type\": \"flat\"},\n        \"version_check\": {\"formatter\": fmt_version_check, \"type\": \"embedded\"},\n        \"error_notify\": {\"formatter\": fmt_error_notify, \"type\": \"embedded\"},\n    }\n    formatter_entry = registry.get(config.module_name)\n    if not formatter_entry:\n        return {}, True\n    formatter = formatter_entry[\"formatter\"]\n    output_type = formatter_entry[\"type\"]\n    formatted_output = formatter(output)\n    if output_type == \"flat\":\n        return formatted_output, True\n    return split_fields(formatted_output), True\n\n\ndef format_for_email(config: Any, output: Any) -> Tuple[str, bool]:\n    \"\"\"Format notification output for email (HTML).\n\n    Args:\n      config: Module config object (must have 'module_name').\n      output: Output from the module to be formatted.\n\n    Returns:\n      Tuple of (HTML email body, success bool).\n    \"\"\"\n\n    def fmt_labelarr(o: Any) -> str:\n        \"\"\"Format labelarr output for email.\n\n        Args:\n          o: Output data for labelarr.\n\n        Returns:\n          HTML string.\n        \"\"\"\n        from collections import defaultdict\n\n        summary_html = f\"<div class='summary'><strong>Synced {len(o)} items across configured Plex libraries.</strong></div>\"\n        label_changes = defaultdict(list)\n        for item in o:\n            for label, action in item[\"add_remove\"].items():\n                key = (label, action)\n                label_changes[key].append(f\"{item['title']} ({item['year']})\")\n        blocks = []\n        for (label, action), items in label_changes.items():\n            verb = \"added to\" if action == \"add\" else \"removed from\"\n            blocks.append(\n                f\"<div class='group'><h3>Label: {label} has been {verb}</h3><ul>\"\n            )\n            for entry in items:\n                blocks.append(f\"<li>{entry}</li>\")\n            blocks.append(\"</ul></div>\")\n        return summary_html + \"\\n\" + \"\\n\".join(blocks)\n\n    def wrap_email(title: str, body: str) -> str:\n        \"\"\"Wrap formatted output in an HTML email template.\n\n        Args:\n          title: Email subject/title.\n          body: HTML content.\n\n        Returns:\n          HTML string.\n        \"\"\"\n        return f\"\"\"\n        <html>\n        <head>\n            <style>\n                body {{ font-family: Arial, sans-serif; line-height: 1.5; color: #333; }}\n                h2 {{ color: #2c3e50; }}\n                h3 {{ margin-top: 1em; border-bottom: 1px solid #ccc; }}\n                .instance {{ margin-bottom: 2em; padding: 1em; background: #f9f9f9; border-left: 5px solid #ccc; }}\n                .media {{ margin-bottom: 1em; }}\n                .title {{ font-weight: bold; font-size: 1.1em; margin-bottom: 0.3em; }}\n                .files {{ margin-left: 1em; }}\n                .files ul {{ list-style: disc; padding-left: 1.5em; }}\n                .files li {{ margin-bottom: 0.25em; }}\n                .label {{ font-weight: bold; }}\n                .folder-rename {{ margin: 0.5em 0; color: #d35400; }}\n                .summary {{ background: #ecf0f1; padding: 0.5em 1em; margin-top: 1em; border-left: 5px solid #27ae60; }}\n                .no-change {{ color: #999; font-style: italic; margin-top: 0.5em; }}\n                .all-linked {{ color: #27ae60; font-weight: bold; }}\n                .group {{ margin-bottom: 2em; padding: 1em; background: #f9f9f9; border-left: 5px solid #ccc; }}\n                .item {{ margin-bottom: 1em; }}\n                .footer {{ margin-top: 2em; font-size: 0.9em; color: #999; }}\n            </style>\n        </head>\n        <body>\n            <h2>{title} Notification</h2>\n            {body}\n            <div class=\"footer\">Powered by: Drazzilb | “{get_random_joke()}”</div>\n        </body>\n        </html>\n        \"\"\".strip()\n\n    def fmt_poster_renamerr(o: Any) -> str:\n        \"\"\"Format poster_renamerr output for email (HTML).\n\n        Args:\n          o: Output data for poster_renamerr.\n\n        Returns:\n          HTML string.\n        \"\"\"\n\n        def render_group(title: str, assets: List[Dict[str, Any]]) -> str:\n            if not assets:\n                return \"\"\n            block: List[str] = [f\"<div class='group'><h3>{title}</h3>\"]\n            for asset in assets:\n                name = asset.get(\"title\", \"\")\n                year = f\" ({asset.get('year')})\" if asset.get(\"year\") else \"\"\n                renamed = sorted(asset.get(\"messages\", []))\n                if not renamed:\n                    continue\n                block.append(\n                    f\"<div class='item'><div class='title'><strong>{name}{year}</strong></div>\"\n                )\n                block.append(\"<div class='files'><ul>\")\n                for msg in renamed:\n                    block.append(f\"<li>{msg}</li>\")\n                block.append(\"</ul></div></div>\")\n            block.append(\"</div>\")\n            return \"\\n\".join(block)\n\n        return \"\\n\".join(\n            [\n                render_group(\"Collections\", o.get(\"collections\", [])),\n                render_group(\"Movies\", o.get(\"movies\", [])),\n                render_group(\"Series\", o.get(\"series\", [])),\n            ]\n        )\n\n    def fmt_renameinatorr(o: Any) -> str:\n        \"\"\"Format renameinatorr output for email (HTML).\n\n        Args:\n          o: Output data for renameinatorr.\n\n        Returns:\n          HTML string.\n        \"\"\"\n        sections: List[str] = []\n        for inst, inst_data in o.items():\n            server_name = inst_data.get(\"server_name\", inst).capitalize()\n            title_header = f\"{server_name} Rename List\"\n            section: List[str] = [\n                \"<div class='instance'>\",\n                f\"<h3>{title_header}</h3>\",\n            ]\n            renamed_count = 0\n            folder_renamed_count = 0\n            for item in inst_data[\"data\"]:\n                title = item.get(\"title\", \"Unknown\")\n                year = f\" ({item.get('year')})\" if item.get(\"year\") else \"\"\n                file_info = item.get(\"file_info\", {})\n                folder_renamed = item.get(\"new_path_name\")\n                if not file_info and not folder_renamed:\n                    continue\n                section.append(\"<div class='media'>\")\n                section.append(\n                    f\"<div class='title'><strong>{title}{year}</strong></div>\"\n                )\n                if folder_renamed:\n                    section.append(\n                        f\"<div class='folder-rename'>📁 Folder Renamed:<br><span>{item['path_name']}</span> ➜ <span>{folder_renamed}</span></div>\"\n                    )\n                    folder_renamed_count += 1\n                if file_info:\n                    section.append(\"<div class='files'><strong>🎬 Files:</strong><ul>\")\n                    for old, new in file_info.items():\n                        section.append(\n                            f\"<li><span class='label'>Original:</span> {old}<br><span class='label'>New:</span> {new}</li>\"\n                        )\n                        renamed_count += 1\n                    section.append(\"</ul></div>\")\n                section.append(\"</div>\")\n            if renamed_count or folder_renamed_count:\n                summary = [\n                    \"<div class='summary'>\",\n                    f\"<h4>{server_name} Rename Summary</h4>\",\n                    f\"<p>Total Items: {len(inst_data['data'])}</p>\",\n                ]\n                if renamed_count:\n                    summary.append(f\"<p>Total Renamed Items: {renamed_count}</p>\")\n                if folder_renamed_count:\n                    summary.append(\n                        f\"<p>Total Folder Renames: {folder_renamed_count}</p>\"\n                    )\n                summary.append(\"</div>\")\n                section.extend(summary)\n            else:\n                section.append(\n                    f\"<div class='no-change'>No items renamed in {server_name}.</div>\"\n                )\n            section.append(\"</div>\")\n            sections.append(\"\\n\".join(section))\n        return \"\".join(sections)\n\n    def fmt_health_checkarr(o: Any) -> str:\n        \"\"\"Format health_checkarr output for email (HTML).\n\n        Args:\n          o: Output data for health_checkarr.\n\n        Returns:\n          HTML string.\n        \"\"\"\n        grouped: Dict[str, List[str]] = {}\n        for item in o:\n            name = item.get(\"title\", \"Untitled\")\n            year = f\" ({item.get('year')})\" if item.get(\"year\") else \"\"\n            db_id = item.get(\"tvdb_id\") or item.get(\"tmdb_id\")\n            key = item[\"instance_name\"]\n            grouped.setdefault(key, []).append(f\"{name}{year} - {db_id}\")\n        sections: List[str] = []\n        for instance, entries in grouped.items():\n            block: List[str] = [\n                \"<div class='instance'>\",\n                f\"<h3>{instance}</h3>\",\n                \"<ul>\",\n            ]\n            for entry in entries:\n                block.append(f\"<li>{entry}</li>\")\n            block.append(\"</ul></div>\")\n            sections.append(\"\\n\".join(block))\n        return \"\".join(sections)\n\n    def fmt_upgradinatorr(o: Any) -> str:\n        \"\"\"Format upgradinatorr output for email (HTML).\n\n        Args:\n          o: Output data for upgradinatorr.\n\n        Returns:\n          HTML string.\n        \"\"\"\n        sections: List[str] = []\n        for inst, data in o.items():\n            server_name = data.get(\"server_name\", inst).capitalize()\n            section: List[str] = [\n                \"<div class='instance'>\",\n                f\"<h3>{server_name}</h3>\",\n            ]\n            for item in data.get(\"data\", []):\n                title = item.get(\"title\", \"Unknown\")\n                year = f\" ({item.get('year')})\" if item.get(\"year\") else \"\"\n                downloads = item.get(\"download\", {})\n                if not downloads:\n                    continue\n                section.append(\"<div class='media'>\")\n                section.append(\n                    f\"<div class='title'><strong>{title}{year}</strong></div>\"\n                )\n                section.append(\"<div class='files'><ul>\")\n                for quality, score in downloads.items():\n                    section.append(\n                        f\"<li><span class='label'>{quality}:</span> CF Score: {score}</li>\"\n                    )\n                section.append(\"</ul></div>\")\n                section.append(\"</div>\")\n            section.append(\"</div>\")\n            sections.append(\"\\n\".join(section))\n        return \"\".join(sections)\n\n    def fmt_nohl(o: Any) -> str:\n        \"\"\"Format nohl output for email (HTML).\n\n        Args:\n          o: Output data for nohl.\n\n        Returns:\n          HTML string.\n        \"\"\"\n\n        sections: List[str] = []\n        for inst_name, inst_data in o.items():\n            server_name = inst_data.get(\"server_name\", inst_name).capitalize()\n            section: List[str] = [f\"<div class='instance'><h3>{server_name}</h3>\"]\n            search_media = inst_data.get(\"data\", {}).get(\"search_media\", [])\n            filtered_media = inst_data.get(\"data\", {}).get(\"filtered_media\", [])\n            if not search_media:\n                section.append(\n                    \"<div class='all-linked'>✅ All files are already hardlinked.</div>\"\n                )\n            else:\n                section.append(\n                    \"<div class='media'><div class='title'><strong>❌ Non-hardlinked Files:</strong></div><ul>\"\n                )\n                for item in search_media:\n                    title = item.get(\"title\", \"Unknown\")\n                    year = f\" ({item.get('year')})\" if item.get(\"year\") else \"\"\n                    section.append(f\"<li>{title}{year}\")\n                    seasons = item.get(\"seasons\", [])\n                    if seasons:\n                        section.append(\"<ul>\")\n                        for season in seasons:\n                            section.append(f\"<li>Season {season.get('season_number')}\")\n                            episodes = season.get(\"episode_data\", [])\n                            if episodes:\n                                section.append(\"<ul>\")\n                                for ep in episodes:\n                                    section.append(\n                                        f\"<li>Episode {ep.get('episode_number')}</li>\"\n                                    )\n                                section.append(\"</ul>\")\n                            section.append(\"</li>\")\n                        section.append(\"</ul>\")\n                    section.append(\"</li>\")\n                section.append(\"</ul></div>\")\n            if filtered_media:\n                section.append(\n                    \"<div class='media'><div class='title'><strong>🎛️ Filtered Media:</strong></div><ul>\"\n                )\n                for item in filtered_media:\n                    title = item.get(\"title\", \"Unknown\")\n                    year = f\" ({item.get('year')})\" if item.get(\"year\") else \"\"\n                    section.append(f\"<li>{title}{year}<ul>\")\n                    if not item.get(\"monitored\"):\n                        section.append(\"<li>⏭️ Skipped (not monitored)</li>\")\n                    elif item.get(\"exclude_media\"):\n                        section.append(\"<li>⛔ Skipped (excluded)</li>\")\n                    elif item.get(\"quality_profile\"):\n                        section.append(\n                            f\"<li>📉 Skipped (quality: {item['quality_profile']})</li>\"\n                        )\n                    section.append(\"</ul></li>\")\n                section.append(\"</ul></div>\")\n            section.append(\"</div>\")\n            sections.append(\"\\n\".join(section))\n        return \"\".join(sections)\n\n    def fmt_unmatched_assets(output: dict) -> str:\n        \"\"\"\n        Format unmatched_assets output for email (HTML).\n        Args:\n            output: Output data for unmatched_assets.\n        Returns:\n            HTML string.\n        \"\"\"\n        sections = []\n        o = output.get(\"unmatched_dict\", {})\n        asset_types = [\"movies\", \"series\", \"collections\"]\n        for asset_type in asset_types:\n            data_set = o.get(asset_type, [])\n            if data_set:\n                block = [\n                    \"<div class='group'>\",\n                    f\"<h3>Unmatched {asset_type.title()}</h3>\",\n                    \"<ul>\",\n                ]\n                for item in data_set:\n                    title = item.get(\"title\", \"Unknown\")\n                    year = f\" ({item['year']})\" if item.get(\"year\") else \"\"\n                    if asset_type == \"series\":\n                        missing_seasons = item.get(\"missing_seasons\", [])\n                        missing_main = item.get(\"missing_main_poster\", False)\n                        if missing_seasons and missing_main:\n                            block.append(f\"<li><strong>{title}{year}</strong><ul>\")\n                            for season in missing_seasons:\n                                block.append(\n                                    f\"<li>Season: {season} <span style='color:#e74c3c;'>&larr; Missing</span></li>\"\n                                )\n                            block.append(\"</ul></li>\")\n                        elif missing_seasons:\n                            block.append(f\"<li><strong>{title}{year}</strong><ul>\")\n                            for season in missing_seasons:\n                                block.append(f\"<li>Season: {season}</li>\")\n                            block.append(\"</ul></li>\")\n                        elif missing_main:\n                            block.append(\n                                f\"<li><strong>{title}{year}</strong> <span style='color:#e74c3c;'>&larr; Main series poster missing</span></li>\"\n                            )\n                        else:\n                            block.append(f\"<li><strong>{title}{year}</strong></li>\")\n                    else:\n                        block.append(f\"<li>{title}{year}</li>\")\n                block.append(\"</ul></div>\")\n                sections.append(\"\".join(block))\n        # Summary block for unmatched_assets\n        summary_data = output.get(\"summary\")\n        if summary_data:\n            summary_block = [\n                \"<div class='summary'>\",\n                \"<h4>Statistics</h4>\",\n                \"<table>\",\n            ]\n            for row in summary_data:\n                if len(row) == 1:\n                    summary_block.append(f\"<tr><th colspan='4'>{row[0]}</th></tr>\")\n                elif len(row) == 4:\n                    summary_block.append(\n                        f\"<tr><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td><td>{row[3]}</td></tr>\"\n                    )\n                else:\n                    summary_block.append(\n                        \"<tr>\" + \"\".join(f\"<td>{cell}</td>\" for cell in row) + \"</tr>\"\n                    )\n            summary_block.append(\"</table></div>\")\n            sections.append(\"\".join(summary_block))\n        return \"\".join(sections)\n\n    def fmt_jduparr(output: Any) -> str:\n        \"\"\"Format jduparr output for email (HTML).\n\n        Args:\n          output: Output data for jduparr.\n\n        Returns:\n          HTML string.\n        \"\"\"\n        total_count = 0\n        blocks: List[str] = []\n        for item in output:\n            path = item.get(\"source_dir\", \"Unknown Path\")\n            field_message = item.get(\"field_message\", \"\")\n            parsed_files = item.get(\"output\", [])\n            sub_count = item.get(\"sub_count\", 0)\n            total_count += sub_count\n            block: List[str] = [f\"<div class='group'><h3>{os.path.basename(path)}</h3>\"]\n            block.append(f\"<div class='summary'>{field_message}</div>\")\n            if parsed_files:\n                block.append(\"<div class='files'><ul>\")\n                for fname in parsed_files:\n                    block.append(f\"<li>{fname}</li>\")\n                block.append(\"</ul></div>\")\n            block.append(\n                f\"<div class='summary'><strong>Total items for '{os.path.basename(path)}': {sub_count}</strong></div>\"\n            )\n            block.append(\"</div>\")\n            blocks.append(\"\".join(block))\n        blocks.append(\n            f\"<div class='summary'><strong>Total items relinked: {total_count}</strong></div>\"\n        )\n        return \"\\n\".join(blocks)\n\n    registry: Dict[str, Any] = {\n        \"poster_renamerr\": fmt_poster_renamerr,\n        \"renameinatorr\": fmt_renameinatorr,\n        \"health_checkarr\": fmt_health_checkarr,\n        \"nohl\": fmt_nohl,\n        \"upgradinatorr\": fmt_upgradinatorr,\n        \"unmatched_assets\": fmt_unmatched_assets,\n        \"labelarr\": fmt_labelarr,\n        \"jduparr\": fmt_jduparr,\n    }\n    formatter = registry.get(config.module_name)\n    if not formatter:\n        return \"\", False\n    inner = formatter(output)\n    return wrap_email(config.module_name.replace(\"_\", \" \").title(), inner), True\n"
  },
  {
    "path": "util/scanner.py",
    "content": "import datetime\nimport html\nimport os\nimport re\nfrom collections import defaultdict\nfrom typing import Any, Dict, List, Optional\n\nfrom unidecode import unidecode\n\nfrom util.constants import (\n    illegal_chars_regex,\n    remove_special_chars,\n    season_pattern,\n    year_regex,\n)\nfrom util.construct import create_collection, create_movie, create_series\nfrom util.extract import extract_ids, extract_year\nfrom util.normalization import normalize_titles\nfrom util.utility import progress\n\n\ndef scan_files_in_flat_folder(folder_path: str, logger: Any) -> List[Dict]:\n    \"\"\"Scan a flat directory structure (no subfolders) for media assets.\n\n    Args:\n      folder_path (str): Path to the folder containing files.\n      logger (Any): Logger instance for progress and debugging.\n\n    Returns:\n      List[Dict]: List of parsed media asset dictionaries.\n    \"\"\"\n    try:\n        files = os.listdir(folder_path)\n    except FileNotFoundError:\n        return []\n    except Exception as exc:\n        logger.error(f\"Unexpected error listing files in folder {folder_path}: {exc}\")\n        return []\n\n    groups = defaultdict(list)\n    normalized_map = {}\n    assets_dict = []\n\n    for file in files:\n        try:\n            if re.match(r\"^\\.[^.]\", file):\n                continue\n            title = file.rsplit(\".\", 1)[0]\n            title = unidecode(html.unescape(title))\n            title = re.sub(illegal_chars_regex, \"\", title)\n            raw_title = season_pattern.split(title)[0].strip()\n            normalized_title = remove_special_chars.sub(\"\", raw_title.lower())\n            if normalized_title in normalized_map:\n                match_key = normalized_map[normalized_title]\n                groups[match_key].append(file)\n            else:\n                groups[raw_title].append(file)\n                normalized_map[normalized_title] = raw_title\n        except Exception as exc:\n            logger.error(\n                f\"Error processing file '{file}' in folder {folder_path}: {exc}\"\n            )\n            continue\n\n    groups = dict(sorted(groups.items(), key=lambda x: x[0].lower()))\n\n    with progress(\n        groups.items(),\n        desc=f\"Processing files {os.path.basename(folder_path)}\",\n        total=len(groups),\n        unit=\"file\",\n        logger=logger,\n    ) as pbar:\n        for base_name, files in groups.items():\n            try:\n                assets_dict.append(parse_file_group(folder_path, base_name, files))\n            except Exception as exc:\n                logger.error(\n                    f\"Error parsing file group '{base_name}' in folder {folder_path}: {exc}\"\n                )\n                continue\n            pbar.update(1)\n\n    return assets_dict\n\n\ndef scan_files_in_nested_folders(folder_path: str, logger: Any) -> Optional[List[Dict]]:\n    \"\"\"Scan a directory with subfolders representing grouped assets (e.g., per movie/series).\n\n    Args:\n      folder_path (str): Path to the base folder.\n      logger (Any): Logger instance.\n\n    Returns:\n      Optional[List[Dict]]: List of parsed asset dictionaries from subfolders, or None on error.\n    \"\"\"\n    assets_dict = []\n    try:\n        entries = list(os.scandir(folder_path))\n        progress_bar = progress(\n            entries,\n            desc=\"Processing posters\",\n            total=len(entries),\n            unit=\"folder\",\n            logger=logger,\n        )\n\n        for dir_entry in progress_bar:\n            if (\n                not dir_entry.is_dir()\n                or dir_entry.name.startswith(\".\")\n                or dir_entry.name == \"tmp\"\n            ):\n                continue\n            base_name = os.path.basename(dir_entry.path)\n            try:\n                files = [f.name for f in os.scandir(dir_entry.path) if f.is_file()]\n            except Exception as exc:\n                logger.error(\n                    f\"Failed to scan nested folder: {dir_entry.path} | Exception: {exc}\"\n                )\n                continue\n            if not files:\n                logger.debug(f\"Skipping empty folder: {dir_entry.path}\")\n                continue\n            try:\n                assets_dict.append(parse_folder_group(dir_entry.path, base_name, files))\n            except Exception as exc:\n                logger.error(\n                    f\"Failed to parse folder group: {dir_entry.path} | Exception: {exc}\"\n                )\n                continue\n    except Exception as exc:\n        logger.error(f\"Error scanning folder {folder_path}: {exc}\")\n        return None\n    return assets_dict\n\n\ndef parse_folder_group(folder_path: str, base_name: str, files: List[str]) -> Dict:\n    \"\"\"Parse metadata and build a structured dictionary for assets within a folder.\n\n    Args:\n      folder_path (str): Path to the folder.\n      base_name (str): Base name of the folder.\n      files (List[str]): List of file names.\n\n    Returns:\n      Dict: Structured asset dictionary.\n    \"\"\"\n    try:\n        title = re.sub(year_regex, \"\", base_name)\n        title = unidecode(html.unescape(title))\n        year = extract_year(base_name)\n        tmdb_id, tvdb_id, imdb_id = extract_ids(base_name)\n        normalized_title = normalize_titles(base_name)\n        full_paths = sorted(\n            [\n                os.path.join(folder_path, file)\n                for file in files\n                if not file.startswith(\".\")\n            ]\n        )\n        parent_folder = os.path.basename(folder_path)\n\n        if not full_paths:\n            raise ValueError(\"No valid files found in folder\")\n\n        is_series = len(files) > 1 and any(\n            \"Season\" in os.path.basename(file) for file in files\n        )\n        is_collection = not year\n\n        if is_collection:\n            return create_collection(\n                title, tmdb_id, normalized_title, full_paths, parent_folder\n            )\n        if is_series or tvdb_id:\n            return create_series(\n                title,\n                year,\n                tvdb_id,\n                imdb_id,\n                normalized_title,\n                full_paths,\n                parent_folder,\n            )\n        return create_movie(\n            title, year, tmdb_id, imdb_id, normalized_title, full_paths, parent_folder\n        )\n    except Exception as exc:\n        raise ValueError(\n            f\"Error parsing folder group. Folder: {folder_path}, Base name: {base_name}, Exception: {exc}\"\n        )\n\n\ndef parse_file_group(folder_path: str, base_name: str, files: List[str]) -> Dict:\n    \"\"\"Parse a group of files in a flat folder into structured metadata.\n\n    Args:\n      folder_path (str): Path to the containing folder.\n      base_name (str): Group title.\n      files (List[str]): List of file names.\n\n    Returns:\n      Dict: Structured media dictionary.\n    \"\"\"\n    try:\n        id_cleaned_name = re.sub(r\"\\{(?:tmdb|tvdb|imdb)-\\w+\\}\", \"\", base_name).strip()\n        title = re.sub(year_regex, \"\", id_cleaned_name).strip()\n        title = unidecode(html.unescape(title))\n        year = extract_year(base_name)\n        tmdb_id, tvdb_id, imdb_id = extract_ids(base_name)\n        normalized_title = normalize_titles(base_name)\n        files = sorted(\n            [\n                os.path.join(folder_path, file)\n                for file in files\n                if not re.match(r\"^\\.[^.]\", file)\n            ]\n        )\n        is_series = any(season_pattern.search(file) for file in files)\n        is_collection = not year\n        non_season_file = next((f for f in files if not season_pattern.search(f)), None)\n        if non_season_file:\n            media_folder = os.path.splitext(os.path.basename(non_season_file))[0]\n        else:\n            media_folder = (\n                os.path.splitext(os.path.basename(files[0]))[0] if files else \"\"\n            )\n\n        if is_collection:\n            return create_collection(\n                title,\n                tmdb_id,\n                normalized_title,\n                files,\n                parent_folder=None,\n                media_folder=media_folder,\n            )\n        if is_series or tvdb_id:\n            return create_series(\n                title,\n                year,\n                tvdb_id,\n                imdb_id,\n                normalized_title,\n                files,\n                parent_folder=None,\n                media_folder=media_folder,\n            )\n        return create_movie(\n            title,\n            year,\n            tmdb_id,\n            imdb_id,\n            normalized_title,\n            files,\n            parent_folder=None,\n            media_folder=media_folder,\n        )\n    except Exception as exc:\n        raise ValueError(\n            f\"Error parsing file group. Folder: {folder_path}, Base name: {base_name}, Exception: {exc}\"\n        )\n\n\ndef process_files(folder_path: str, logger: Any) -> Optional[List[Dict]]:\n    \"\"\"Determine folder structure and route to the appropriate scanning logic.\n\n    Args:\n      folder_path (str): Path to the folder to scan.\n      logger (Any): Logger instance.\n\n    Returns:\n      Optional[List[Dict]]: List of structured asset dictionaries, or None on failure.\n    \"\"\"\n    asset_folders = _is_asset_folders(folder_path, logger)\n    logger.debug(f\"Folder Path: {folder_path} | Asset Folder: {asset_folders}\")\n    start_time = datetime.datetime.now()\n\n    if not asset_folders:\n        assets_dict = scan_files_in_flat_folder(folder_path, logger)\n    else:\n        assets_dict = scan_files_in_nested_folders(folder_path, logger)\n\n    end_time = datetime.datetime.now()\n    if assets_dict:\n        elapsed_time = (end_time - start_time).total_seconds()\n        item_count = (\n            sum(len(asset.get(\"files\", [])) for asset in assets_dict)\n            if assets_dict\n            else 0\n        )\n        items_per_second = item_count / elapsed_time if elapsed_time > 0 else 0\n        if logger:\n            logger.info(\n                f\"Processed {item_count} files in {elapsed_time:.2f} seconds ({items_per_second:.2f} items/s) \"\n                f\"in folder '{os.path.basename(folder_path.rstrip('/'))}'\"\n            )\n        return assets_dict\n    return None\n\n\ndef _is_asset_folders(folder_path: str, logger: Any) -> bool:\n    \"\"\"Check if the folder contains asset folders.\n\n    Args:\n      folder_path (str): The path to the folder to check.\n      logger (Any): Logger instance for debug output.\n\n    Returns:\n      bool: True if the folder contains asset folders, False otherwise.\n    \"\"\"\n    try:\n        if not os.path.exists(folder_path):\n            return False\n        for item in os.listdir(folder_path):\n            if (\n                (len(item) > 1 and item[0] == \".\" and item[1] != \".\")\n                or item.startswith(\"@\")\n                or item == \"tmp\"\n            ):\n                logger.debug(f\"Skipping hidden item: {item}\")\n                continue\n            if os.path.isdir(os.path.join(folder_path, item)):\n                return True\n        return False\n    except Exception as exc:\n        logger.error(f\"Error checking asset folders in {folder_path}: {exc}\")\n        return False\n\n\ndef process_selected_files(\n    file_paths: List[str], logger: Any, asset_folders: bool = False\n) -> List[Dict]:\n    \"\"\"Group and parse selected file paths into assets_dict.\n\n    Args:\n      file_paths (List[str]): List of file paths.\n      logger (Any): Logger instance.\n      asset_folders (bool): Whether files are grouped in asset folders.\n\n    Returns:\n      List[Dict]: List of structured asset dictionaries.\n    \"\"\"\n    assets_dict = []\n    if asset_folders:\n        folder_groups = defaultdict(list)\n        for file_path in file_paths:\n            if file_path.startswith(\".\"):\n                continue\n            folder_name = os.path.basename(os.path.dirname(file_path))\n            folder_groups[folder_name].append(file_path)\n        for folder_name, files in folder_groups.items():\n            folder_path = os.path.dirname(files[0])\n            base_files = [os.path.basename(f) for f in files]\n            try:\n                assets_dict.append(\n                    parse_folder_group(folder_path, folder_name, base_files)\n                )\n            except Exception as exc:\n                logger.error(\n                    f\"Error parsing folder group '{folder_name}' in folder '{folder_path}': {exc}\"\n                )\n                continue\n    else:\n        groups = defaultdict(list)\n        normalized_map = {}\n        for file_path in file_paths:\n            filename = os.path.basename(file_path)\n            if filename.startswith(\".\"):\n                continue\n            title = filename.rsplit(\".\", 1)[0]\n            title = unidecode(html.unescape(title))\n            title = re.sub(illegal_chars_regex, \"\", title)\n            raw_title = season_pattern.split(title)[0].strip()\n            normalized_title = remove_special_chars.sub(\"\", raw_title.lower())\n            if normalized_title in normalized_map:\n                match_key = normalized_map[normalized_title]\n                groups[match_key].append(file_path)\n            else:\n                groups[raw_title].append(file_path)\n                normalized_map[normalized_title] = raw_title\n        for base_name, files in groups.items():\n            folder = os.path.dirname(files[0]) if files else \"\"\n            base_files = [os.path.basename(f) for f in files]\n            try:\n                assets_dict.append(parse_file_group(folder, base_name, base_files))\n            except Exception as exc:\n                logger.error(\n                    f\"Error parsing file group '{base_name}' in folder '{folder}': {exc}\"\n                )\n                continue\n    return assets_dict\n"
  },
  {
    "path": "util/scheduler.py",
    "content": "from datetime import datetime\nfrom logging import Logger\nfrom typing import Dict\n\nfrom croniter import croniter\nfrom dateutil import tz\n\n\"\"\"Module to determine if the current time matches specified scheduling criteria.\"\"\"\n\nnext_run_times: Dict[str, datetime] = {}\n\n\ndef check_schedule(script_name: str, schedule: str, logger: Logger) -> bool:\n    \"\"\"Check if the current time matches the given schedule for a script.\n\n    Args:\n      script_name: The name of the script being checked.\n      schedule: The scheduling string defining when the script should run.\n      logger: Logger instance for logging debug and error messages.\n\n    Returns:\n      True if the current time matches the schedule, False otherwise.\n    \"\"\"\n    try:\n        now: datetime = datetime.now()\n        try:\n            frequency, data = schedule.split(\"(\")\n        except ValueError:\n            logger.error(\n                f\"Invalid schedule format: {schedule} for script: {script_name}\"\n            )\n            return False\n        data = data[:-1]\n\n        if frequency == \"hourly\":\n            return int(data) == now.minute\n\n        if frequency == \"daily\":\n            times = data.split(\"|\")\n            for time in times:\n                hour, minute = map(int, time.split(\":\"))\n                if now.hour == hour and now.minute == minute:\n                    return True\n\n        if frequency == \"weekly\":\n            days = [day.split(\"@\")[0] for day in data.split(\"|\")]\n            times = [day.split(\"@\")[1] for day in data.split(\"|\")]\n            current_day = now.strftime(\"%A\").lower()\n            for day, time in zip(days, times):\n                hour, minute = map(int, time.split(\":\"))\n                if current_day == day or (\n                    current_day == \"sunday\" and day == \"saturday\"\n                ):\n                    if now.hour == hour and now.minute == minute:\n                        return True\n\n        if frequency == \"monthly\":\n            day_str, time_str = data.split(\"@\")\n            day = int(day_str)\n            hour, minute = map(int, time_str.split(\":\"))\n            if now.day == day and now.hour == hour and now.minute == minute:\n                return True\n\n        if frequency == \"range\":\n            ranges = data.split(\"|\")\n            for start_end in ranges:\n                start, end = start_end.split(\"-\")\n                start_month, start_day = map(int, start.split(\"/\"))\n                end_month, end_day = map(int, end.split(\"/\"))\n                start_date = datetime(now.year, start_month, start_day)\n                end_date = datetime(now.year, end_month, end_day)\n                if start_date <= now <= end_date:\n                    return True\n\n        if frequency == \"cron\":\n            local_tz = tz.tzlocal()\n            local_date = datetime.now(local_tz)\n            current_time = local_date.replace(second=0, microsecond=0)\n            logger.debug(f\"Local time: {current_time}\")\n            next_run = next_run_times.get(script_name)\n            if next_run is None:\n                next_run = croniter(data, local_date).get_next(datetime)\n                next_run_times[script_name] = next_run\n                logger.debug(f\"Next run for {script_name}: {next_run}\")\n            if next_run <= current_time:\n                next_run = croniter(data, local_date).get_next(datetime)\n                next_run_times[script_name] = next_run\n                logger.debug(f\"Next run for {script_name}: {next_run}\\n\")\n                return True\n            logger.debug(\n                f\"Next run time for script {script_name}: {next_run} is in the future\\n\"\n            )\n            return False\n\n        return False\n\n    except ValueError as e:\n        logger.error(f\"Invalid schedule: {schedule} for script: {script_name}\")\n        logger.error(f\"Error: {e}\", exc_info=True)\n        return False\n"
  },
  {
    "path": "util/template/config_template.json",
    "content": "{\n  \"schedule\": {\n    \"border_replacerr\": \"\",\n    \"health_checkarr\": \"\",\n    \"labelarr\": \"\",\n    \"nohl\": \"\",\n    \"sync_gdrive\": \"\",\n    \"poster_cleanarr\": \"\",\n    \"poster_renamerr\": \"\",\n    \"renameinatorr\": \"\",\n    \"unmatched_assets\": \"\",\n    \"upgradinatorr\": \"\",\n    \"jduparr\": \"\"\n  },\n  \"instances\": {\n    \"radarr\": {},\n    \"sonarr\": {},\n    \"plex\": {}\n  },\n  \"notifications\": {\n    \"poster_renamerr\": {},\n    \"poster_cleanarr\": {},\n    \"unmatched_assets\": {},\n    \"health_checkarr\": {},\n    \"labelarr\": {},\n    \"upgradinatorr\": {},\n    \"renameinatorr\": {},\n    \"nohl\": {},\n    \"jduparr\": {},\n    \"main\": {}\n  },\n  \"sync_gdrive\": {\n    \"log_level\": \"info\",\n    \"client_id\": \"\",\n    \"client_secret\": \"\",\n    \"token\": \"\",\n    \"gdrive_sa_location\": \"\",\n    \"gdrive_list\": [{}]\n  },\n  \"poster_renamerr\": {\n    \"log_level\": \"info\",\n    \"dry_run\": false,\n    \"sync_posters\": false,\n    \"action_type\": \"copy\",\n    \"asset_folders\": false,\n    \"print_only_renames\": false,\n    \"run_border_replacerr\": false,\n    \"incremental_border_replacerr\": false,\n    \"source_dirs\": [],\n    \"destination_dir\": \"\",\n    \"instances\": []\n  },\n  \"border_replacerr\": {\n    \"log_level\": \"info\",\n    \"dry_run\": false,\n    \"source_dirs\": [],\n    \"destination_dir\": \"\",\n    \"border_width\": 26,\n    \"skip\": false,\n    \"exclusion_list\": [],\n    \"border_colors\": [],\n    \"holidays\": {}\n  },\n  \"unmatched_assets\": {\n    \"log_level\": \"info\",\n    \"source_dirs\": [],\n    \"instances\": [],\n    \"ignore_root_folders\": [],\n    \n    \"ignore_collections\": []\n  },\n  \"poster_cleanarr\": {\n    \"log_level\": \"info\",\n    \"dry_run\": true,\n    \"source_dirs\": [],\n    \"instances\": [],\n    \n    \"ignore_media\": []\n  },\n  \"upgradinatorr\": {\n    \"log_level\": \"info\",\n    \"dry_run\": false,\n    \"instances_list\": []\n  },\n  \"renameinatorr\": {\n    \"log_level\": \"info\",\n    \"dry_run\": false,\n    \"rename_folders\": true,\n    \"count\": 100,\n    \"radarr_count\": 0,\n    \"sonarr_count\": 0,\n    \"tag_name\": \"\",\n    \"ignore_tag\": \"\",\n    \"enable_batching\": false,\n    \"instances\": []\n  },\n  \"nohl\": {\n    \"log_level\": \"info\",\n    \"dry_run\": false,\n    \"searches\": 10,\n    \"print_files\": false,\n    \"source_dirs\": [],\n    \"exclude_profiles\": [],\n    \"exclude_movies\": [],\n    \"exclude_series\": [],\n    \"instances\": []\n\n  },\n  \"labelarr\": {\n    \"log_level\": \"info\",\n    \"dry_run\": false,\n    \"mappings\": []\n  },\n  \"health_checkarr\": {\n    \"log_level\": \"info\",\n    \"dry_run\": false,\n    \"instances\": []\n  },\n  \"jduparr\": {\n    \"log_level\": \"info\",\n    \"dry_run\": false,\n    \"source_dirs\": []\n  },\n  \"main\": {\n    \"log_level\": \"info\",\n    \"theme\": \"dark\",\n    \"update_notifications\": false\n  }\n}"
  },
  {
    "path": "util/utility.py",
    "content": "import copy\nimport datetime\nimport html\nimport json\nimport math\nimport os\nimport re\nfrom pathlib import Path\nfrom types import SimpleNamespace\nfrom typing import Any, Dict, List, Optional\n\nimport yaml\nfrom plexapi.exceptions import NotFound\nfrom tqdm import tqdm\nfrom unidecode import unidecode\n\nfrom util.constants import illegal_chars_regex\nfrom util.construct import generate_title_variants\nfrom util.normalization import normalize_titles\n\n\ndef print_json(data: Any, logger: Any, module_name: str, type_: str) -> None:\n    \"\"\"Write data as JSON to a debug file and log the action.\n\n    Args:\n        data (Any): Data to write as JSON.\n        logger (Any): Logger instance.\n        module_name (str): Module name for directory path.\n        type_ (str): Type used for filename.\n    \"\"\"\n    log_base = os.getenv(\"LOG_DIR\")\n    if log_base:\n        debug_dir = Path(log_base) / module_name / \"debug\"\n    else:\n        debug_dir = Path(__file__).resolve().parents[1] / \"logs\" / module_name / \"debug\"\n\n    debug_dir.mkdir(parents=True, exist_ok=True)\n\n    assets_file = debug_dir / f\"{type_}.json\"\n    with open(assets_file, \"w\") as f:\n        json.dump(data, f, indent=2)\n    logger.debug(f\"Wrote {type_} to {assets_file}\")\n\n\ndef print_settings(logger: Any, module_config: SimpleNamespace) -> None:\n    \"\"\"Print sanitized settings from module_config in YAML format.\n\n    Args:\n        logger (Any): Logger instance.\n        module_config (SimpleNamespace): Configuration object.\n    \"\"\"\n    logger.debug(create_table([[\"Script Settings\"]]))\n\n    def ns_to_dict(obj: Any) -> Any:\n        if isinstance(obj, SimpleNamespace):\n            return {k: ns_to_dict(v) for k, v in vars(obj).items()}\n        if isinstance(obj, dict):\n            return {k: ns_to_dict(v) for k, v in obj.items()}\n        if isinstance(obj, list):\n            return [ns_to_dict(i) for i in obj]\n        return obj\n\n    raw = {\n        k: v\n        for k, v in vars(module_config).items()\n        if k not in (\"module_name\", \"instances_config\")\n    }\n    sanitized = copy.deepcopy(ns_to_dict(raw))\n\n    def _redact(obj: Any) -> None:\n        if isinstance(obj, dict):\n            for key, val in obj.items():\n                kl = key.lower()\n                if val is None:\n                    continue\n                if \"password\" in kl:\n                    obj[key] = redact_sensitive_info(str(val), password=True)\n                elif \"webhook\" in kl:\n                    obj[key] = redact_sensitive_info(str(val), password=False)\n                else:\n                    _redact(val)\n        elif isinstance(obj, list):\n            for item in obj:\n                if isinstance(item, (dict, list)):\n                    _redact(item)\n\n    _redact(sanitized)\n\n    try:\n        yaml_output = yaml.dump(\n            {getattr(module_config, \"module_name\", \"settings\"): sanitized},\n            sort_keys=False,\n            allow_unicode=True,\n            default_flow_style=False,\n        )\n        logger.debug(\"\\n\" + yaml_output)\n    except Exception:\n        logger.warning(\n            \"Failed to render config as YAML; falling back to key:value lines.\"\n        )\n        for key, value in sanitized.items():\n            display = value if isinstance(value, str) else str(value)\n            logger.debug(f\"{key}: {display}\")\n\n    logger.debug(create_bar(\"-\"))\n\n\ndef create_table(data: List[List[Any]]) -> str:\n    \"\"\"Create a formatted table string from 2D data list.\n\n    Args:\n        data (List[List[Any]]): Data to create the table from.\n\n    Returns:\n        str: Formatted table string.\n    \"\"\"\n    if not data:\n        return \"No data provided.\"\n\n    num_rows = len(data)\n    num_cols = len(data[0])\n\n    col_widths = [\n        max(len(str(data[row][col])) for row in range(num_rows))\n        for col in range(num_cols)\n    ]\n    col_widths = [max(width + 2, 5) for width in col_widths]\n\n    total_width = sum(col_widths) + num_cols - 1\n    min_width = 76\n\n    if total_width < min_width:\n        additional_width = min_width - total_width\n        extra_width_per_col = additional_width // num_cols\n        remainder = additional_width % num_cols\n        for i in range(num_cols):\n            col_widths[i] += extra_width_per_col\n            if remainder > 0:\n                col_widths[i] += 1\n                remainder -= 1\n\n    total_width = sum(col_widths) + num_cols - 1\n\n    table = \"\\n\"\n    table += \"_\" * (total_width + 2) + \"\\n\"\n\n    for row in range(num_rows):\n        table += \"|\"\n        for col in range(num_cols):\n            cell_content = str(data[row][col])\n            padding = col_widths[col] - len(cell_content)\n            left_padding = padding // 2\n            right_padding = padding - left_padding\n            separator = \"|\" if col < num_cols - 1 else \"|\"\n            table += (\n                f\"{' ' * left_padding}{cell_content}{' ' * right_padding}{separator}\"\n            )\n        table += \"\\n\"\n        if row < num_rows - 1:\n            table += \"|\" + \"-\" * total_width + \"|\\n\"\n\n    table += \"‾\" * (total_width + 2)\n    return table\n\n\ndef get_plex_data(\n    plex: Any,\n    library_names: List[str],\n    logger: Any,\n    include_smart: bool,\n    collections_only: bool,\n) -> List[Dict[str, Any]]:\n    \"\"\"Retrieve data from Plex libraries or collections.\n\n    Args:\n        plex (Any): Plex instance.\n        library_names (List[str]): Names of libraries to get data from.\n        logger (Any): Logger instance.\n        include_smart (bool): Whether to include smart collections.\n        collections_only (bool): If True, only retrieve collection data.\n\n    Returns:\n        List[Dict[str, Any]]: List of dictionaries containing Plex data.\n    \"\"\"\n    plex_list: List[Dict[str, Any]] = []\n    collection_names: Dict[str, List[str]] = {}\n    library_data: Dict[str, Any] = {}\n\n    for library_name in library_names:\n        try:\n            library = plex.library.section(library_name)\n        except NotFound:\n            logger.error(\n                f\"Error: Library '{library_name}' not found, check your settings and try again.\"\n            )\n            continue\n\n        if collections_only:\n            if include_smart:\n                collection_names[library_name] = [\n                    c.title for c in library.search(libtype=\"collection\")\n                ]\n            else:\n                collection_names[library_name] = [\n                    c.title for c in library.search(libtype=\"collection\") if not c.smart\n                ]\n        else:\n            library_data[library_name] = library.all()\n\n    if collections_only:\n        libraries = list(collection_names.items())\n        with progress(\n            libraries,\n            desc=\"Libraries\",\n            total=len(libraries),\n            unit=\"library\",\n            logger=logger,\n        ) as outer:\n            for library_name, titles in outer:\n                start_time = datetime.datetime.now()\n                with progress(\n                    titles,\n                    desc=f\"Processing Plex collections in '{library_name}'\",\n                    total=len(titles),\n                    unit=\"collection\",\n                    logger=logger,\n                    leave=False,\n                ) as inner:\n                    for title in inner:\n                        title_unescaped = unidecode(html.unescape(title))\n                        normalized_title = normalize_titles(title_unescaped)\n                        alternate_titles = generate_title_variants(title_unescaped)\n                        folder = illegal_chars_regex.sub(\"\", title_unescaped)\n                        plex_list.append(\n                            {\n                                \"title\": title_unescaped,\n                                \"normalized_title\": normalized_title,\n                                \"location\": library_name,\n                                \"year\": None,\n                                \"folder\": folder,\n                                \"alternate_titles\": alternate_titles[\n                                    \"alternate_titles\"\n                                ],\n                                \"normalized_alternate_titles\": alternate_titles[\n                                    \"normalized_alternate_titles\"\n                                ],\n                            }\n                        )\n                end_time = datetime.datetime.now()\n                elapsed = (end_time - start_time).total_seconds()\n                rate = len(titles) / elapsed if elapsed > 0 else 0\n                logger.debug(\n                    f\"Processed {len(titles)} collections in '{library_name}' in {elapsed:.2f}s ({rate:.2f} items/s)\"\n                )\n\n    return plex_list\n\n\ndef create_bar(middle_text: str) -> str:\n    \"\"\"Create a separation bar with text centered.\n\n    Args:\n        middle_text (str): Text to place in center of bar.\n\n    Returns:\n        str: Formatted separation bar.\n    \"\"\"\n    total_length = 80\n    if len(middle_text) == 1:\n        remaining_length = total_length - len(middle_text) - 2\n        left_side_length = 0\n        right_side_length = remaining_length\n        return f\"\\n{middle_text * left_side_length}{middle_text}{middle_text * right_side_length}\\n\"\n    remaining_length = total_length - len(middle_text) - 4\n    left_side_length = math.floor(remaining_length / 2)\n    right_side_length = remaining_length - left_side_length\n    return f\"\\n{'*' * left_side_length} {middle_text} {'*' * right_side_length}\\n\"\n\n\ndef redact_sensitive_info(text: str, password: bool = False) -> str:\n    \"\"\"Redact sensitive info from text.\n\n    Args:\n        text (str): Text to redact.\n        password (bool): If True, redact entire text.\n\n    Returns:\n        str: Redacted text.\n    \"\"\"\n    if password:\n        return \"[redacted]\"\n\n    text = re.sub(\n        r\"https://discord\\.com/api/webhooks/[^/]+/\\S+\",\n        r\"https://discord.com/api/webhooks/[redacted]\",\n        text,\n    )\n    text = re.sub(\n        r\"\\b(\\w{24})-[a-zA-Z0-9_-]{24}\\.apps\\.googleusercontent\\.com\\b\",\n        r\"[redacted].apps.googleusercontent.com\",\n        text,\n    )\n    text = re.sub(r'(?<=refresh_token\": \")([^\"]+)(?=\")', r\"[redacted]\", text)\n    text = re.sub(r\"(\\b[A-Za-z0-9_-]{33}\\b)\", r\"[redacted]\", text)\n    text = re.sub(r'(?<=access_token\": \")([^\"]+)(?=\")', r\"[redacted]\", text)\n    text = re.sub(r\"GOCSPX-\\S+\", r\"GOCSPX-[redacted]\", text)\n    pattern_client_id = r\"(-i).*?(\\.apps\\.googleusercontent\\.com)\"\n    text = re.sub(\n        pattern_client_id, r\"\\1 [redacted]\\2\", text, flags=re.DOTALL | re.IGNORECASE\n    )\n    pattern_file_arg = r\"(-f)\\s\\S+\"\n    text = re.sub(\n        pattern_file_arg, r\"\\1 [redacted]\", text, flags=re.DOTALL | re.IGNORECASE\n    )\n\n    return text\n\n\ndef progress(\n    iterable: Any,\n    desc: Optional[str] = None,\n    total: Optional[int] = None,\n    unit: Optional[str] = None,\n    logger: Optional[Any] = None,\n    leave: bool = True,\n    **kwargs: Any,\n) -> Any:\n    \"\"\"Wrap tqdm to toggle progress bars based on LOG_TO_CONSOLE env var.\n\n    Args:\n        iterable (Any): Iterable to wrap.\n        desc (Optional[str]): Description for progress bar.\n        total (Optional[int]): Total iterations.\n        unit (Optional[str]): Unit of progress.\n        logger (Optional[Any]): Logger instance.\n        leave (bool): Keep progress bar after completion.\n        **kwargs: Additional tqdm args.\n\n    Returns:\n        tqdm or DummyProgress: Progress bar or dummy context manager.\n    \"\"\"\n    log_console = os.environ.get(\"LOG_TO_CONSOLE\", \"\").lower() in (\"1\", \"true\", \"yes\")\n\n    class DummyProgress:\n        def __init__(self, iterable: Any) -> None:\n            self.iterable = iterable\n\n        def __enter__(self) -> \"DummyProgress\":\n            return self\n\n        def __exit__(self, exc_type, exc_val, exc_tb) -> None:\n            pass\n\n        def __iter__(self):\n            return iter(self.iterable)\n\n        def update(self, n: int = 1) -> None:\n            pass\n\n    if not log_console:\n        return DummyProgress(iterable)\n    return tqdm(iterable, desc=desc, total=total, unit=unit, leave=leave, **kwargs)\n\n\ndef redact_apis(obj: Any) -> None:\n    \"\"\"Recursively redact any 'api' keys in dicts or nested lists.\n\n    Args:\n        obj (Any): Object to redact API keys in-place.\n    \"\"\"\n    if isinstance(obj, dict):\n        for key, value in obj.items():\n            if key.lower() == \"api\":\n                obj[key] = \"REDACTED\"\n            else:\n                redact_apis(value)\n    elif isinstance(obj, list):\n        for item in obj:\n            redact_apis(item)\n\n\ndef get_log_dir(module_name: str) -> str:\n    \"\"\"Return the log directory for a given module.\"\"\"\n    log_base = os.getenv(\"LOG_DIR\")\n    if log_base:\n        log_dir = Path(log_base) / module_name\n    else:\n        log_dir = Path(__file__).resolve().parents[1] / \"logs\" / module_name\n    os.makedirs(log_dir, exist_ok=True)\n    return str(log_dir)\n"
  },
  {
    "path": "util/version.py",
    "content": "import os\nimport re\nimport subprocess\nimport threading\nimport time\nfrom pathlib import Path\n\nimport requests\n\nfrom util.notification import send_notification\n\nBASE = Path(__file__).parents[1] / \"VERSION\"\n\n\ndef get_version() -> str:\n    \"\"\"Get the version string based on environment variables or git information.\"\"\"\n    base_version = BASE.read_text().strip()\n    ci_build = os.getenv(\"BUILD_NUMBER\")\n    ci_branch = os.getenv(\"BRANCH\")\n    if ci_build and ci_branch:\n        return f\"{base_version}.{ci_branch}{ci_build}\"\n\n    try:\n        branch = (\n            subprocess.check_output(\n                [\"git\", \"rev-parse\", \"--abbrev-ref\", \"HEAD\"], stderr=subprocess.DEVNULL\n            )\n            .decode()\n            .strip()\n        )\n        commit_count = (\n            subprocess.check_output(\n                [\"git\", \"rev-list\", \"--count\", \"HEAD\"], stderr=subprocess.DEVNULL\n            )\n            .decode()\n            .strip()\n        )\n        return f\"{base_version}.{branch}{commit_count}\"\n    except Exception:\n        return base_version\n\n\ndef _check_remote_version(local_version, branch, logger):\n    # Fetch remote VERSION file from GitHub\n    raw_url = f\"https://raw.githubusercontent.com/Drazzilb08/daps/{branch}/VERSION\"\n    try:\n        remote_version = requests.get(raw_url, timeout=5)\n        if not remote_version.ok:\n            logger.debug(\n                f\"Could not fetch remote VERSION: {remote_version.status_code}\"\n            )\n            return None, None, False\n        remote_version_str = remote_version.text.strip()\n    except Exception as e:\n        logger.debug(f\"Exception fetching VERSION: {e}\")\n        return None, None, False\n\n    # Get remote build number (commit count)\n    api_url = (\n        f\"https://api.github.com/repos/Drazzilb08/daps/commits?sha={branch}&per_page=1\"\n    )\n    try:\n        resp = requests.get(api_url, timeout=5)\n        if not resp.ok:\n            logger.debug(f\"Could not fetch commit count: {resp.status_code}\")\n            return remote_version_str, None, False\n        link = resp.headers.get(\"Link\")\n        if not link:\n            build_count = 1\n        else:\n            match = re.search(r\"&page=(\\d+)>; rel=\\\"last\\\"\", link)\n            build_count = int(match.group(1)) if match else 1\n    except Exception as e:\n        logger.debug(f\"Exception fetching build count: {e}\")\n        return remote_version_str, None, False\n\n    # Construct remote full version\n    remote_full = f\"{remote_version_str}.{branch}{build_count}\"\n\n    # Compare (mimic your JS logic)\n    update_available = False\n    local_parts = local_version.strip().split(\".\")\n    if len(local_parts) >= 4:\n        local_base = \".\".join(local_parts[:3])\n        local_branch_build = local_parts[3]\n        m = re.match(r\"([a-zA-Z]+)(\\d+)\", local_branch_build)\n        if m:\n            local_branch = m.group(1)\n            local_build = int(m.group(2))\n        else:\n            local_branch = local_branch_build.rstrip(\"0123456789\")\n            local_build = int(local_branch_build[len(local_branch) :] or 0)\n        if remote_version_str == local_base and build_count > local_build:\n            update_available = True\n        elif remote_version_str != local_base:\n            update_available = True\n    return remote_full, build_count, update_available\n\n\ndef start_version_check(config, logger, interval=3600):\n    \"\"\"Starts a background thread to check for version updates.\"\"\"\n\n    def poll():\n        local_version = get_version()\n        local_parts = local_version.strip().split(\".\")\n        if len(local_parts) < 4:\n            return\n        branch_and_build = local_parts[3]\n        m = re.match(r\"([a-zA-Z]+)\", branch_and_build)\n        branch = m.group(1) if m else \"main\"\n        logger.info(f\"[VERSION CHECK] Local version: {local_version}, branch: {branch}\")\n\n        while True:\n            remote_full, build_count, update_available = _check_remote_version(\n                local_version, branch, logger\n            )\n            if update_available:\n                logger.debug(\n                    f\"[VERSION CHECK] Update available. Local: {local_version}, Remote: {remote_full}, Build Count: {build_count}\"\n                )\n                output = {\n                    \"local_version\": local_version,\n                    \"remote_version\": remote_full,\n                    \"color\": \"FF0000\",  # Red hex string (or 0xFF0000 as int, but string is flexible)\n                }\n                # Make sure config.module_name = \"version_check\" or similar for formatting to work\n                config.module_name = \"version_check\"\n                send_notification(logger, \"version_check\", config, output)\n            else:\n                logger.debug(\n                    f\"[VERSION CHECK] No update. Local: {local_version}, Remote: {remote_full}\"\n                )\n            time.sleep(interval)\n\n    thread = threading.Thread(target=poll, daemon=True)\n    thread.start()\n"
  },
  {
    "path": "web/server.py",
    "content": "import copy\nimport multiprocessing\nimport os\nimport time\nfrom pathlib import Path\nfrom threading import Thread\nfrom typing import Any, Dict, List, Optional\n\nimport requests\nimport uvicorn\nimport yaml\nfrom dotenv import load_dotenv\nfrom fastapi import (\n    APIRouter,\n    BackgroundTasks,\n    Depends,\n    FastAPI,\n    HTTPException,\n    Request,\n)\nfrom fastapi.requests import Request as FastAPIRequest\nfrom fastapi.responses import (\n    FileResponse,\n    HTMLResponse,\n    JSONResponse,\n    PlainTextResponse,\n)\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel\n\nfrom util.config import Config\nfrom util.utility import redact_apis\nfrom util.version import get_version\n\nload_dotenv(override=True)\n\nif os.environ.get(\"DOCKER_ENV\"):\n    LOG_BASE_DIR = \"/config/logs\"\nelse:\n    LOG_BASE_DIR = str((Path(__file__).parent.parent / \"logs\").resolve())\n\n\ndef load_config_dict() -> Dict[str, Any]:\n    \"\"\"Loads the configuration dictionary from file.\"\"\"\n    config_path = Config(\"main\").config_path\n    with open(config_path, \"r\") as f:\n        return yaml.safe_load(f)\n\n\ndef save_config_dict(cfg: Dict[str, Any]) -> None:\n    \"\"\"Saves the configuration dictionary to file.\"\"\"\n    config_path = Config(\"main\").config_path\n    with open(config_path, \"w\") as f:\n        yaml.safe_dump(cfg, f, sort_keys=False)\n\n\nclass RunRequest(BaseModel):\n    \"\"\"Request schema for running a module.\"\"\"\n\n    module: str\n\n\nclass CancelRequest(BaseModel):\n    \"\"\"Request schema for canceling a module.\"\"\"\n\n    module: str\n\n\nclass TestInstanceRequest(BaseModel):\n    \"\"\"Request schema for testing a service instance.\"\"\"\n\n    service: str\n    name: str\n    url: str\n    api: Optional[str] = None\n\n\nclass NotificationPayload(BaseModel):\n    \"\"\"Request schema for test notification.\"\"\"\n\n    module: str\n    notifications: Dict[str, Any]\n\n\ndef get_config() -> Dict[str, Any]:\n    \"\"\"Dependency: returns the current configuration.\"\"\"\n    return load_config_dict()\n\n\ndef get_logger(request: Request) -> Any:\n    \"\"\"Dependency: returns the logger from app state.\"\"\"\n    return request.app.state.logger\n\n\n# ==== App and State ====\nrun_processes: Dict[str, multiprocessing.Process] = {}\nrun_time: Dict[str, float] = {}\n\napp = FastAPI()\nrouter = APIRouter()\napp.state.logger = None\n\n# ==== Centralized Error Handler ====\n\n\n@app.exception_handler(Exception)\nasync def handle_exception(request: FastAPIRequest, exc: Exception):\n    \"\"\"Handles uncaught exceptions and logs them.\"\"\"\n    logger = getattr(request.app.state, \"logger\", None)\n    if logger:\n        logger.error(f\"[WEB] Unhandled Exception: {exc}\", exc_info=True)\n    return JSONResponse(status_code=500, content={\"error\": str(exc)})\n\n\n# ==== Helper: Log Route ====\ndef log_route(logger: Any, path: str, method: str = \"GET\") -> None:\n    \"\"\"Logs web route access.\"\"\"\n    logger.debug(f\"[WEB] Serving {method} {path}\")\n\n\n# ==== Routes ====\n@app.get(\"/api/version\", response_model=None)\nasync def get_version_route(\n    request: Request, logger: Any = Depends(get_logger)\n) -> PlainTextResponse:\n    \"\"\"Returns the current version string.\"\"\"\n    try:\n        version = get_version()\n        logger.debug(f\"[WEB] Serving GET /api/version: {version}\")\n    except Exception:\n        version = \"unknown\"\n    return PlainTextResponse(version)\n\n\n@app.post(\"/api/test-notification\", response_model=None)\nasync def test_notification(\n    payload: NotificationPayload, logger: Any = Depends(get_logger)\n) -> Any:\n    \"\"\"Sends a test notification and returns the result.\"\"\"\n    logger.debug(\n        \"[WEB] Serving POST /api/test-notification for module: %s\", payload.module\n    )\n    from util.notification import send_test_notification\n\n    try:\n        results = send_test_notification(payload.dict(), logger)\n        logger.debug(\"[WEB] Test notification results: %s\", results)\n        return results\n    except Exception as e:\n        logger.error(\"[WEB] Test notification failed: %s\", e)\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\napp.mount(\n    \"/web/static\",\n    StaticFiles(directory=Path(__file__).parent / \"static\"),\n    name=\"static\",\n)\n\n\n@app.get(\"/\", response_class=HTMLResponse, response_model=None)\nasync def root() -> HTMLResponse:\n    \"\"\"Serves the main index.html page.\"\"\"\n    html_path = Path(__file__).parent / \"templates\" / \"index.html\"\n    try:\n        return HTMLResponse(content=html_path.read_text(), status_code=200)\n    except Exception as e:\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n@app.get(\"/api/config\", response_model=None)\nasync def get_config_route(\n    config: Dict[str, Any] = Depends(get_config), logger: Any = Depends(get_logger)\n) -> Dict[str, Any]:\n    \"\"\"Returns the current configuration as a dictionary.\"\"\"\n    log_route(logger, \"/api/config\")\n    return config\n\n\n@app.post(\"/api/config\", response_model=None)\nasync def update_config_route(\n    request: Request,\n    logger: Any = Depends(get_logger),\n    config: Dict[str, Any] = Depends(get_config),\n) -> Any:\n    \"\"\"Updates the configuration file with provided values.\"\"\"\n    try:\n        incoming = await request.json()\n        incoming_copy = copy.deepcopy(incoming)\n        if \"instances\" in incoming_copy:\n            redact_apis(incoming_copy[\"instances\"])\n        logger.debug(\"[WEB] Serving POST /api/config with payload: %s\", incoming_copy)\n        current_config = load_config_dict()\n        new_schedule = incoming.get(\"schedule\")\n        new_instances = incoming.get(\"instances\")\n        new_notifications = incoming.get(\"notifications\")\n        if new_schedule is not None:\n            if \"schedule\" not in current_config:\n                current_config[\"schedule\"] = {}\n            for key, value in new_schedule.items():\n                current_config[\"schedule\"][key] = value\n        if new_instances is not None:\n            current_config[\"instances\"] = new_instances\n        if new_notifications is not None:\n            current_config[\"notifications\"] = new_notifications\n        for mod_name, mod_payload in incoming.items():\n            if mod_name in (\"schedule\", \"instances\", \"notifications\"):\n                continue\n            if (\n                \"bash_scripts\" in current_config\n                and mod_name in current_config[\"bash_scripts\"]\n            ):\n                target = current_config[\"bash_scripts\"][mod_name]\n            else:\n                target = current_config.setdefault(mod_name, {})\n            for field, val in mod_payload.items():\n                target[field] = val\n        save_config_dict(current_config)\n        logger.info(\"[WEB] Config entries updated\")\n        return {\"status\": \"success\"}\n    except Exception as e:\n        logger.error(\"[WEB] Config update failed: %s\", e)\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n@app.get(\"/api/list\", response_model=None)\nasync def list_dir(path: str = \"/\") -> Any:\n    \"\"\"Returns subdirectories for a given path.\"\"\"\n    resolved = Path(path).expanduser().resolve()\n    if not resolved.exists() or not resolved.is_dir():\n        try:\n            return JSONResponse(status_code=400, content={\"error\": \"Invalid path\"})\n        except Exception as e:\n            return JSONResponse(status_code=500, content={\"error\": str(e)})\n    dirs = [\n        p.name for p in resolved.iterdir() if p.is_dir() and not p.name.startswith(\".\")\n    ]\n    dirs.sort()\n    return {\"directories\": dirs}\n\n\n@app.get(\"/api/plex/libraries\", response_model=None)\nasync def get_plex_libraries(\n    instance: str,\n    config: Dict[str, Any] = Depends(get_config),\n    logger: Any = Depends(get_logger),\n) -> Any:\n    \"\"\"Returns library names for a specific Plex instance.\"\"\"\n    try:\n        plex_data = config.get(\"instances\", {}).get(\"plex\", {}).get(instance)\n        if not plex_data:\n            return JSONResponse(\n                status_code=404, content={\"error\": \"Plex instance not found\"}\n            )\n        base_url = plex_data.get(\"url\")\n        token = plex_data.get(\"api\")\n        if not base_url or not token:\n            return JSONResponse(\n                status_code=400, content={\"error\": \"Missing Plex API credentials\"}\n            )\n        headers = {\"X-Plex-Token\": token}\n        url = f\"{base_url}/library/sections\"\n        try:\n            logger.debug(\n                \"[WEB] Serving GET /api/plex/libraries for instance: %s\", instance\n            )\n            res = requests.get(url, headers=headers, timeout=5)\n        except requests.exceptions.RequestException as req_exc:\n            logger.error(f\"[WEB] Plex request failed: {req_exc}\")\n            return JSONResponse(\n                status_code=502,\n                content={\"error\": f\"Failed to connect to Plex server: {req_exc}\"},\n            )\n        if not res.ok:\n            return JSONResponse(\n                status_code=res.status_code, content={\"error\": res.text}\n            )\n        xml = res.text\n        import xml.etree.ElementTree as ET\n\n        root = ET.fromstring(xml)\n        libraries = [\n            el.attrib[\"title\"]\n            for el in root.findall(\".//Directory\")\n            if \"title\" in el.attrib\n        ]\n        return libraries\n    except Exception as e:\n        logger.error(f\"[WEB] Unexpected error in /api/plex/libraries: {e}\")\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n@app.post(\"/api/run\", response_model=None)\nasync def run_module(\n    data: RunRequest, background: BackgroundTasks, logger: Any = Depends(get_logger)\n) -> Any:\n    \"\"\"Starts a module process in the background if not already running.\"\"\"\n    from main import list_of_python_modules, run_module\n\n    module = data.module\n    logger.debug(\"[WEB] Serving POST /api/run for module: %s\", module)\n    if module not in list_of_python_modules:\n        logger.error(f\"[WEB] Unknown module: {module}\")\n        return JSONResponse(\n            status_code=400, content={\"error\": f\"Unknown module: {module}\"}\n        )\n    if module in run_processes and run_processes[module].is_alive():\n        logger.error(f\"[WEB] Module {module} is already running\")\n        return JSONResponse(\n            status_code=400, content={\"error\": f\"Module {module} is already running\"}\n        )\n\n    def background_run():\n        start = time.time()\n        logger.info(f\"[WEB] Background starting module: {module}\")\n        run_time[module] = start\n        proc = run_module(module)\n        if proc:\n            run_processes[module] = proc\n        else:\n            logger.error(f\"[WEB] Failed to start module: {module}\")\n\n    background.add_task(background_run)\n    return {\"status\": \"starting\", \"module\": module}\n\n\n@app.get(\"/api/status\", response_model=None)\nasync def module_status(module: str, logger: Any = Depends(get_logger)) -> Any:\n    \"\"\"Queries the running status of a given module.\"\"\"\n    proc = run_processes.get(module)\n    if not proc and getattr(app.state, \"manager\", None):\n        proc = app.state.manager.running_modules.get(module)\n    alive = False\n    if proc:\n        alive = proc.is_alive()\n        if not alive:\n            start_time = run_time.pop(module, None)\n            if start_time is not None:\n                duration = time.time() - start_time\n                hours, rem = divmod(duration, 3600)\n                minutes, seconds = divmod(rem, 60)\n                human_duration = f\"{int(hours)}:{int(minutes):02d}:{int(seconds):02d}\"\n                logger.info(\n                    f\"[WEB] Module: {module} finished in {human_duration} (raw: {duration:.2f} seconds)\"\n                )\n            if proc in run_processes.values():\n                del run_processes[module]\n            elif (\n                getattr(app.state, \"manager\", None)\n                and module in app.state.manager.running_modules\n            ):\n                del app.state.manager.running_modules[module]\n    try:\n        return {\"module\": module, \"running\": alive}\n    except Exception as e:\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n@app.post(\"/api/cancel\", response_model=None)\nasync def cancel_module(data: CancelRequest, logger: Any = Depends(get_logger)) -> Any:\n    \"\"\"Cancels a running module.\"\"\"\n    module = data.module\n    proc = run_processes.get(module)\n    scheduled = False\n    if not proc and getattr(app.state, \"manager\", None):\n        proc = app.state.manager.running_modules.get(module)\n        scheduled = True\n    if not proc:\n        try:\n            return JSONResponse(\n                status_code=400, content={\"error\": \"Module not running\"}\n            )\n        except Exception as e:\n            return JSONResponse(status_code=500, content={\"error\": str(e)})\n    proc.terminate()\n    logger.info(f\"[WEB] Manually cancelled module: {module}\")\n    if scheduled:\n        del app.state.manager.running_modules[module]\n    else:\n        del run_processes[module]\n    return {\"status\": \"cancelled\", \"module\": module}\n\n\n@app.post(\"/api/test-instance\", response_model=None)\nasync def test_instance(\n    data: TestInstanceRequest, logger: Any = Depends(get_logger)\n) -> Any:\n    \"\"\"Tests the connection to a service instance and returns the result.\"\"\"\n    service = data.service\n    name = data.name\n    url = data.url\n    api = data.api\n    if not url:\n        return JSONResponse(status_code=400, content={\"error\": \"Missing URL\"})\n    try:\n        url = url.rstrip(\"/\")\n        if service == \"plex\":\n            headers = {\"X-Plex-Token\": api} if api else {}\n            test_url = f\"{url}/library/sections\"\n        else:\n            headers = {\"X-Api-Key\": api} if api else {}\n            test_url = f\"{url}/api/v3/system/status\"\n        logger.info(f\"[WEB] Testing: {name.upper()} - URL: {test_url}\")\n        resp = requests.get(test_url, headers=headers, timeout=5)\n        if resp.ok:\n            logger.info(\"[WEB] Connection test: OK\")\n            return {\"ok\": True, \"status\": resp.status_code}\n        if resp.status_code == 401:\n            logger.error(\n                \"[WEB] Connection test code 401: Unauthorized - Invalid credentials\"\n            )\n            return JSONResponse(status_code=401, content={\"error\": \"Unauthorized\"})\n        if resp.status_code == 404:\n            logger.error(\"[WEB] Connection test code 404: Not Found - Invalid URL\")\n            return JSONResponse(status_code=404, content={\"error\": \"Not Found\"})\n        logger.error(f\"[WEB] Connection test code {resp.status_code}: {resp.text}\")\n        return JSONResponse(status_code=resp.status_code, content={\"error\": resp.text})\n    except Exception as e:\n        logger.error(f\"[WEB] Connection test failed for {name} ({url}): {e}\")\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n@app.post(\"/api/create-folder\", response_model=None)\nasync def create_folder(path: str, logger: Any = Depends(get_logger)) -> Any:\n    \"\"\"Creates a folder at the given path.\"\"\"\n    resolved = Path(path).expanduser().resolve()\n    try:\n        logger.info(f\"[WEB] Creating folder: {resolved}\")\n        resolved.mkdir(parents=True, exist_ok=False)\n        return {\"status\": \"created\"}\n    except Exception as e:\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n@app.get(\"/pages/{fragment_name}\", response_class=HTMLResponse, response_model=None)\nasync def serve_fragment(fragment_name: str, logger: Any = Depends(get_logger)) -> Any:\n    \"\"\"Serves a named HTML fragment from the fragments directory.\"\"\"\n    html_path = Path(__file__).parent / \"templates\" / \"pages\" / f\"{fragment_name}.html\"\n    if not html_path.exists():\n        raise HTTPException(status_code=404, detail=\"Fragment not found\")\n    try:\n        return HTMLResponse(content=html_path.read_text(), status_code=200)\n    except Exception as e:\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n# ========== Logs API ==========\n@router.get(\"/api/logs\")\nasync def list_logs(logger: Any = Depends(get_logger)) -> Dict[str, List[str]]:\n    \"\"\"Lists available log files for each module.\"\"\"\n    logger.info(\"[WEB] Listing logs in %s\", LOG_BASE_DIR)\n    logs_data: Dict[str, List[str]] = {}\n    if not os.path.exists(LOG_BASE_DIR):\n        logger.error(\"[WEB] Log directory not found: %s\", LOG_BASE_DIR)\n        raise HTTPException(status_code=404, detail=\"Log directory not found.\")\n    for module in os.listdir(LOG_BASE_DIR):\n        if module == \"debug\":\n            logger.debug(f\"[WEB] Skipping {module} folder\")\n            continue\n        module_path = os.path.join(LOG_BASE_DIR, module)\n        if os.path.isdir(module_path):\n            files = sorted(\n                f\n                for f in os.listdir(module_path)\n                if os.path.isfile(os.path.join(module_path, f))\n            )\n            logs_data[module] = files\n    logger.info(\"[WEB] Logs listed: %s\", list(logs_data.keys()))\n    return logs_data\n\n\n@router.get(\"/api/logs/{module}/{filename}\", response_class=PlainTextResponse)\nasync def read_log(\n    module: str, filename: str, logger: Any = Depends(get_logger)\n) -> str:\n    \"\"\"Reads a specific log file and returns its content as plain text.\"\"\"\n    safe_module = os.path.basename(module)\n    safe_filename = os.path.basename(filename)\n    if safe_module == \"debug\":\n        raise HTTPException(status_code=404, detail=\"Log file not found.\")\n    log_path = os.path.join(LOG_BASE_DIR, safe_module, safe_filename)\n    if \"debug\" in os.path.relpath(log_path, LOG_BASE_DIR).split(os.sep):\n        raise HTTPException(status_code=404, detail=\"Log file not found.\")\n    if not os.path.exists(log_path):\n        raise HTTPException(status_code=404, detail=\"Log file not found.\")\n    with open(log_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n        content = f.read()\n    return content\n\n\n@app.post(\"/api/poster-search-stats\", response_model=None)\nasync def poster_search_stats(request: Request, logger: Any = Depends(get_logger)):\n    \"\"\"Returns stats and file list for a given poster location directory.\"\"\"\n    try:\n        data = await request.json()\n        location = data.get(\"location\")\n        logger.debug(\n            f\"[WEB] Serving POST /api/poster-search-stats for location: {location}\"\n        )\n        if not location or not os.path.isdir(location):\n            return JSONResponse(status_code=400, content={\"error\": \"Invalid location\"})\n        total_size = 0\n        poster_files = []\n        for root, dirs, files in os.walk(location):\n            for f in files:\n                fp = os.path.join(root, f)\n                try:\n                    stat = os.stat(fp)\n                    total_size += stat.st_size\n                    rel_path = os.path.relpath(fp, location)\n                    if rel_path.startswith(\"tmp\" + os.sep) or rel_path.startswith(\n                        \"tmp/\"\n                    ):\n                        continue\n                    poster_files.append(rel_path)\n                except Exception as e:\n                    logger.error(f\"SKIPPED FILE: {fp} | ERROR: {e}\")\n                    continue\n        return {\n            \"file_count\": len(poster_files),\n            \"size_bytes\": total_size,\n            \"files\": sorted(poster_files),\n        }\n    except Exception as e:\n        logger.error(f\"poster-search-stats error: {e}\")\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n@app.get(\"/api/preview-poster\")\nasync def preview_poster(location: str, path: str, logger: Any = Depends(get_logger)):\n    \"\"\"\n    Returns the requested poster image file as a response if it exists within location.\n    \"\"\"\n    try:\n        base_dir = Path(location).resolve()\n        file_path = (base_dir / path).resolve()\n        # Security: prevent path traversal\n        if not str(file_path).startswith(str(base_dir)):\n            return JSONResponse(status_code=403, content={\"error\": \"Invalid path\"})\n        if not file_path.exists() or not file_path.is_file():\n            return JSONResponse(status_code=404, content={\"error\": \"File not found\"})\n        # Basic file type check (optional: just for images)\n        if file_path.suffix.lower() not in [\".jpg\", \".jpeg\", \".png\", \".webp\", \".bmp\"]:\n            return JSONResponse(\n                status_code=415, content={\"error\": \"Unsupported file type\"}\n            )\n        logger.debug(f\"[WEB] Serving image preview: {file_path}\")\n        return FileResponse(str(file_path))\n    except Exception as e:\n        logger.error(f\"[WEB] Preview poster error: {e}\")\n        return JSONResponse(status_code=500, content={\"error\": str(e)})\n\n\n# ========== Web Server Startup ==========\ndef start_web_server(logger: Any) -> None:\n    \"\"\"Starts the web server in a background thread and stores logger in app state.\n\n    Args:\n      logger: Logger instance to use for the app.\n    \"\"\"\n    app.state.logger = logger\n    try:\n        app.state.config_data = load_config_dict()\n    except Exception as e:\n        logger.error(f\"[WEB] Failed to load config: {e}\")\n        app.state.config_data = {}\n    PORT = int(os.environ.get(\"PORT\", 8000))\n    HOST = os.environ.get(\"HOST\", \"127.0.0.1\")\n    app.state.logger.info(f\"[WEB] Starting web server on {HOST}:{PORT}\")\n    web_thread = Thread(\n        target=lambda: uvicorn.run(app, host=HOST, port=PORT, log_level=\"warning\"),\n        daemon=True,\n    )\n    web_thread.start()\n    app.include_router(router)\n"
  },
  {
    "path": "web/static/css/base.css",
    "content": "/* ===== Font Imports ===== */\n@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');\n\n/* ======================================================\n    ROOT VARIABLES (DARK THEME DEFAULT)\n====================================================== */\n:root {\n    /* ====== NON-COLOR VARIABLES ====== */\n\n    /* ---------- Layout & Container ---------- */\n    --container-max-width: 70%;\n    --container-padding: 2rem;\n    --container-padding-bottom: 4rem;\n    --container-radius: 8px;\n    --view-frame-padding: 2rem;\n    --card-margin-btm: 1.5rem;\n    --card-padding: 1.5rem;\n    --card-radius: 8px;\n\n    /* ---------- Typography ---------- */\n    --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,\n        'Open Sans', 'Helvetica Neue', sans-serif;\n    --font-size-base: 17px;\n    --font-size-heading: 32px;\n    --font-size-subheading: 20px;\n    --font-size-base-plus: 1.2rem;\n    --line-height: 1.5;\n    --font-weight-base: 400;\n    --font-weight-heading: 600;\n    --heading-font-size-lg: 1.75rem;\n\n    /* ---------- Form & Fields ---------- */\n    --form-padding: 0.6rem 1rem;\n    --form-radius: 6px;\n    --form-font-size: 0.99rem;\n    --form-height: 2.5rem;\n    --field-label-width: 200px;\n    --field-gap: 0.5rem;\n\n    /* ---------- Button ---------- */\n    --btn-padding: 0.5rem 1rem;\n    --btn-radius: 4px;\n    --btn-font-size: 0.9rem;\n\n    /* ---------- Modal & Toasts ---------- */\n    --overlay-blur: 8px;\n    --toast-position-bottom: 3.3rem;\n    --toast-position-right: 1.5rem;\n    --toast-radius: 6px;\n    --toast-font-size: 0.95rem;\n    --toast-padding: 1rem 1.5rem;\n    --modal-max-width: 600px;\n    --modal-content-width: 50%;\n    --modal-header-font-size: 1.5rem;\n    --modal-header-font-weight: 700;\n    --modal-header-margin: 1rem;\n    --modal-footer-gap: 0.75rem;\n    --modal-footer-margin-top: 1rem;\n    --modal-radius: 8px;\n    --modal-padding: 2rem 2.5rem;\n    --modal-btn-radius: 6px;\n    --modal-btn-padding: 0.6rem 1.25rem;\n    --modal-btn-font-size: 1rem;\n    --modal-btn-font-weight: 600;\n    --modal-btn-margin: 0.5rem 0;\n\n    /* ---------- Navigation/Dropdown ---------- */\n    --nav-menu-gap: 2rem;\n    --nav-border-radius: 8px;\n    --nav-dropdown-radius: 4px;\n    --nav-dropdown-bg: transparent;\n    --nav-dropdown-item-padding: 0.75rem 1.25rem;\n    --nav-dropdown-item-radius: 4px;\n    --nav-glass-blur: 26px;\n    --dropdown-shadow: 0 4px 12px var(--shadow);\n\n    /* ---------- Dashboard/Stats ---------- */\n    --dashboard-label-background-clip: text;\n    --dashboard-label-text-fill-color: transparent;\n    --dashboard-label-filter: blur(0.15px) brightness(1.07);\n    --stat-bar-gradient: linear-gradient(90deg, var(--info, #4c7ad1), #b6d0ff);\n\n    /* ---------- Splash/Welcome ---------- */\n    --splash-card-padding: 2rem 3rem;\n    --splash-card-radius: 12px;\n    --splash-icon-size: 4rem;\n    --splash-header-font-size: 2rem;\n    --splash-header-margin-btm: 0.5rem;\n    --splash-p-opacity: 0.8;\n\n    /* ---------- Status & Labels ---------- */\n    --status-font-size: 1rem;\n    --status-margin-top: 1rem;\n\n    /* ---------- Utility/Transform ---------- */\n    --translate-neutral: translateY(0);\n    --padding-standard: 0.75rem 1rem;\n\n    /* ---------- Border/Radius ---------- */\n    --border-radius: 8px;\n    --border-radius-default: 6px;\n\n    /* ---------- Toggle Switch ---------- */\n    --toggle-radius: 24px;\n    --toggle-slider-radius: 50%;\n    --toggle-width: 36px;\n    --toggle-height: 20px;\n    --toggle-slider-size: 14px;\n    --toggle-slider-offset: 3px;\n    --toggle-api-font-size: 1.2rem;\n    --toggle-api-right: 0.75rem;\n    --toggle-api-opacity: 0.7;\n\n    /* ---------- Notification Card ---------- */\n    --notification-fieldset-padding: 0.75rem;\n\n    /* ---------- Log/Log Viewer ---------- */\n    --log-badge-radius: 5px;\n    --log-badge-padding: 5px 10px;\n    --log-badge-font-size: 0.8rem;\n    --log-badge-z: 9999;\n    --log-badge-opacity: 0;\n    --log-badge-transition: opacity 0.5s;\n    --log-spinner-size: 60px;\n    --log-spinner-radius: 50%;\n    --log-spinner-z: 1000;\n    --log-jump-btn-radius: 20px;\n    --log-jump-btn-font-size: 0.85rem;\n    --log-jump-btn-font-weight: bold;\n    --log-jump-btn-padding: 0.4rem 0.9rem;\n    --log-jump-btn-z: 100;\n    --log-toolbar-gap: 0.75rem;\n    --log-controls-radius: 12px;\n    --log-scroll-btn-radius: 20px;\n}\n\n:root[data-theme='dark'] {\n    /* ===== BASE COLORS ===== */\n    --bg: #1e262b;\n    --fg: #e8e8e8;\n    --text-color: #e5e5e5;\n    --heading-color: #ffffff;\n    --fg-secondary: #aaa;\n\n    /* ===== BRAND & ACTION COLORS ===== */\n    --primary: #ff7300;\n    --focus: #ff9500;\n    --accent: #3e7bfa;\n    --accent-dark: #204288;\n    --success: #32d74b;\n    --success-highlight: #30d16e;\n    --error: #ff375f;\n    --error-highlight: #ff3b30;\n    --caution: #ff9f0a;\n    --info: #5ac8fa;\n\n    /* ===== LINKS & HIGHLIGHTS ===== */\n    --link-color: #5ac8fa;\n    --link-hover-color: #007aff;\n    --highlight: #0069f2;\n    --highlight-bg: #ffeaa7;\n    --highlight-color: #222;\n\n    /* ===== BACKGROUNDS ===== */\n    --primary-bg: #141414;\n    --secondary-bg: #1e1e1e;\n    --container-bg: #141414;\n    --view-frame-bg: #141414;\n    --overlay-bg: rgba(0, 0, 0, 0.4);\n\n    /* ===== CARD & CONTAINER ===== */\n    --card-bg: #202228;\n    --card-hover-bg: #272b33;\n    --card-hover-shadow: 0 6px 20px rgba(0, 0, 0, 0.24);\n    --card-shadow: 0 1px 8px rgba(0, 0, 0, 0.12), 0 4px 24px rgba(0, 0, 0, 0.14);\n    --card-shadow: 0 2px 6px var(--shadow);\n    --card-border: 1px solid #26292d;\n    --container-bg: #141414;\n\n    /* ===== INPUTS & FORMS ===== */\n    --form-bg: #2b2e33;\n    --form-shadow: 0 1.5px 8px #0003;\n    --form-focus: #5ac8fa;\n    --form-border: 1px solid var(--shadow);\n    --form-color: var(--fg);\n    --form-invalid-outline: 2px solid #ff9900;\n    --form-invalid-bg: rgba(255, 223, 164, 0.7);\n    --form-invalid-color: #222;\n\n    /* ===== BUTTONS ===== */\n    --btn-bg: #007aff;\n    --btn-hover-bg: #0069f2;\n    --btn-color: var(--text-color);\n\n    /* ===== MODALS & TOASTS ===== */\n    --modal-bg: #24292e;\n    --modal-color: #e8e8e8;\n    --modal-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);\n    --modal-preset-type-color: #ffe6b8;\n    --modal-preset-type-background: rgba(190, 160, 80, 0.11);\n    --modal-preset-content-color: #cfdbee;\n    --modal-preset-content-background: rgba(100, 110, 130, 0.07);\n    --toast-shadow: 0 0 10px rgba(0, 0, 0, 0.4);\n\n    /* ===== Shadows & Misc ===== */\n    --shadow: rgba(0, 0, 0, 0.22);\n    --notification-card-shadow: 0 2px 6px var(--shadow);\n    --splash-card-shadow: 0 8px 24px var(--shadow);\n    --hover-preview-border: #b0b8c6;\n    --img-modal-shadow: #c1c5cb;\n    --loader-bg: #dde2ec;\n\n    /* ===== Navigation ===== */\n    --nav-glass-bg: rgba(32, 34, 40, 0.83);\n    --nav-glass-border: 1px solid rgba(255, 255, 255, 0.07);\n    --nav-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);\n    --nav-dropdown-shadow: 0 4px 12px var(--shadow);\n\n    /* ===== Special Effects & Misc ===== */\n    --poster-list-hover-bg: #e7e9ed;\n    --poster-list-hover-color: #2b6cb0;\n    --gdrive-tooltip-bg: #f4f6fb;\n    --gdrive-tooltip-color: #242628;\n    --gdrive-tooltip-shadow: #b0b8c6;\n    --gdrive-tooltip-red: #d13434;\n    --gdrive-tooltip-highlight: #fdbe44;\n    --copy-btn-active-background: #e6b800;\n    --copy-btn-hover-color: #e6b800;\n\n    /* ===== Labelarr & Custom ===== */\n    --labelarr-label-bg: rgba(210, 180, 90, 0.14);\n    --labelarr-label-color: #a78748;\n    --labelarr-label-empty-color: #85898f;\n    --labelarr-library-bg: rgba(60, 120, 160, 0.09);\n    --labelarr-library-color: #7dc4fa;\n    --labelarr-plex-instance-color: #69d46e;\n    --labelarr-arrow-color: #b0b4bb;\n    --mapping-instance-color: #b0b4bb;\n\n    /* ===== Log Level Colors ===== */\n    --log-error: #ff375f;\n    --log-warning: #ffb020;\n    --log-critical: #b1001d;\n    --log-info: #30d16e;\n    --log-debug: #999fa8;\n\n    /* ===== Tables, Stats, Dashboard ===== */\n    --stats-footer-color: #c9d0d9;\n    --stats-table-hover-bg: #f3f4f7;\n    --stats-table-hover-color: #23272e;\n    --stats-row-error-bg: #f7e9e9;\n    --preset-card-border: rgba(80, 90, 110, 0.13);\n\n    /* ===== Logs & Log Viewer ===== */\n    --log-badge-bg: rgba(0, 0, 0, 0.7);\n    --log-spinner-border: 6px solid var(--card-bg);\n    --log-spinner-border-top: 6px solid var(--primary);\n    --log-jump-btn-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);\n    --log-controls-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);\n    --log-line-hover-bg: rgba(255, 255, 255, 0.03);\n    --log-scroll-btn-border: 1px solid var(--bg);\n\n    /* ===== Dashboard & Header ===== */\n    --daps-header-gradient: linear-gradient(90deg, #a6b5c9 0%, #dce3ec 60%, #f4f8fb 100%);\n    --daps-header-shadow: 0 1px 1px rgba(255, 255, 255, 0.6);\n    --daps-header-hover: blur(0.8px) brightness(1.12) drop-shadow(0 2px 10px #a6b5c980);\n    --daps-header-underline: linear-gradient(90deg, #a6b5c9 0%, #dce3ec 60%, #f4f8fb 100%);\n    --dashboard-label-color: transparent;\n    --dashboard-label-shadow: var(--daps-header-shadow);\n\n    /* ===== Footer & Misc ===== */\n    --viewframe-border-top: rgba(255, 255, 255, 0.1);\n    --footer-update-badge-color: #fff;\n    --update-tooltip-title-color: #fff;\n    --daps-footer-box-shadow: rgba(0, 0, 0, 0.1);\n    --update-tooltip-versions-color: #bbb;\n    --dir-list-li-hover-background: rgba(0, 0, 0, 0.1);\n    --select2-results--option--highlighted-color: #fff;\n\n    /* ===== Toggles ===== */\n    --toggle-off: rgba(255, 255, 255, 0.13);\n    --toggle-on: #3e7bfa;\n    --toggle-thumb-bg: #222;\n    --toggle-thumb-shadow: 0 1.5px 6px rgba(0, 0, 0, 0.34);\n\n    /* ===== Pills ===== */\n    --pill-border: 1px solid var(--shadow);\n    --pill-hover-bg: #282d33;\n    --pill-active-bg: #25282d;\n    --pill-hover-shadow: 0 2px 8px rgba(40, 64, 96, 0.08);\n\n    --terminal-bg: #1a1a1a;\n    --terminal-border: 0.1em solid #333;\n    --terminal-color: #0f0;\n    --terminal-header-bg: #333;\n    --terminal-title-color: #eee;\n    --terminal-control-close: #e33;\n    --terminal-control-min: #ee0;\n    --terminal-control-max: #0b0;\n    --terminal-cursor-color: #0f0;\n}\n\n/* ======================================================\n            LIGHT THEME OVERRIDES\n====================================================== */\n:root[data-theme='light'] {\n    /* ====== BASE COLORS ====== */\n    --bg: #e3e4e8;\n    --fg: #222325;\n    --text-color: #242628;\n    --heading-color: #23272e;\n    --primary: #3e7bfa;\n    --focus: #2a4b80;\n    --accent: #0073e6;\n    /*     --accent-dark: #204288; */\n\n    /* ====== BRAND & ACTION COLORS ====== */\n    --success: #32b982;\n    --success-highlight: #49e3a2;\n    --error: #e35c67;\n    --error-highlight: #f37c83;\n    --caution: #ffc857;\n    --info: #4c7ad1;\n\n    /* ====== LINKS & HIGHLIGHTS ====== */\n    --link-color: #2b6cb0;\n    --link-hover-color: #417dbe;\n    --highlight: #ff7300;\n    --highlight-bg: #ffeaa7;\n    --highlight-color: #222;\n\n    /* ====== BACKGROUNDS ====== */\n    --primary-bg: #edeef0;\n    --secondary-bg: #dee1e7;\n    --container-bg: #edeef0;\n    --view-frame-bg: #edeef0;\n\n    /* ====== CARD & CONTAINER ====== */\n    --card-bg: #f5f6f7;\n    --card-hover-bg: #e7e9ed;\n    --card-hover-shadow: 0 6px 20px rgba(60, 72, 90, 0.05);\n    --card-shadow: 0 1px 8px rgba(60, 72, 90, 0.06), 0 4px 24px rgba(60, 72, 90, 0.09);\n\n    /* ====== INPUTS & FORMS ====== */\n    --form-bg: #f2f2f4;\n    --form-shadow: 0 1.5px 8px #bbb3;\n    --form-focus: #4c7ad1;\n    --form-padding: 0.6rem 1rem;\n    --form-border: 1px solid var(--shadow);\n    --form-radius: 6px;\n    --form-font-size: 0.99rem;\n    --form-height: 2.5rem;\n    --form-color: var(--fg);\n    --form-invalid-outline: 2px solid #ff9900;\n    --form-invalid-bg: rgba(255, 223, 164, 0.7);\n    --form-invalid-color: #222;\n\n    /* ====== BUTTONS ====== */\n    --btn-bg: #4c7ad1;\n    --btn-hover-bg: #4166a0;\n    --btn-color: var(--text-color);\n\n    /* ====== MODALS & TOASTS ====== */\n    --overlay-blur: 8px;\n    --overlay-bg: rgba(240, 240, 245, 0.1);\n    --modal-bg: #f1f2f4;\n    --modal-color: #23272e;\n    --modal-shadow: 0 8px 32px rgba(60, 72, 90, 0.08);\n    --toast-shadow: 0 0 10px rgba(60, 72, 90, 0.11);\n\n    /* ====== DASHBOARD & DAPS HEADER ====== */\n    --daps-header-gradient: linear-gradient(90deg, #343741 0%, #51545c 70%, #b3bac7 100%);\n    --daps-header-shadow: 0 2px 5px rgba(60, 62, 70, 0.13), 0 1.5px 6px rgba(90, 90, 90, 0.09);\n    --daps-header-hover: blur(0.8px) brightness(1.11) drop-shadow(0 2px 10px #34374160);\n    --daps-header-underline: linear-gradient(90deg, #282a32 0%, #51545c 70%, #b3bac7 100%);\n    --dashboard-label-color: #454852;\n    --dashboard-label-background-clip: initial;\n    --dashboard-label-text-fill-color: #454852;\n    --dashboard-label-shadow: none;\n    --dashboard-label-filter: none;\n\n    /* ====== TABLES & STATS ====== */\n    --stats-footer-color: #362f26;\n    --stats-table-hover-bg: #acacac;\n    --stats-table-hover-color: #f5faff;\n    --stats-row-error-bg: #37242a;\n    --stat-bar-gradient: linear-gradient(90deg, var(--info, #339af0), #1565c0);\n\n    /* ====== SPECIAL EFFECTS & MISC ====== */\n    --splash-particle-color: rgb(0, 0, 0, 0.5);\n    --poster-list-hover-bg: #222c;\n    --poster-list-hover-color: #ffe06f;\n    --gdrive-tooltip-bg: #232c3b;\n    --gdrive-tooltip-color: #e6ecfa;\n    --gdrive-tooltip-shadow: #0006;\n    --gdrive-tooltip-red: #e75b5b;\n    --gdrive-tooltip-highlight: #ffd166;\n    --copy-btn-hover-color: #ffe06f;\n    --loader-bg: #e0e6ed;\n    --img-modal-shadow: #1117;\n    --hover-preview-border: #444;\n\n    /* ====== NOTIFICATION CARD ====== */\n    --notification-card-shadow: 0 2px 6px #c1c5cb;\n\n    /* ===== Toggles ===== */\n    --toggle-off: #d3dae6;\n    --toggle-on: #3e7bfa;\n    --toggle-thumb-bg: #fff;\n    --toggle-thumb-shadow: 0 1.5px 4px rgba(60, 72, 90, 0.18);\n\n    /* ===== Navigation ===== */\n    --nav-glass-bg: rgba(245, 246, 247, 0.83);\n    --nav-glass-border: 1px solid rgba(30, 40, 50, 0.07);\n    --nav-shadow: 0 2px 6px rgba(60, 72, 90, 0.13);\n    --nav-dropdown-shadow: 0 4px 12px rgba(60, 72, 90, 0.09);\n\n    /* ====== LOGS & LOG VIEWER ====== */\n    --log-badge-bg: rgba(30, 40, 50, 0.06);\n    /*     --log-badge-shadow: 0 2px 6px #c1c5cb; */\n    --log-jump-btn-shadow: 0 2px 6px #c1c5cb;\n    --log-line-hover-bg: rgba(0, 0, 0, 0.018);\n\n    /* ====== SPLASH / WELCOME ====== */\n    --splash-card-shadow: 0 8px 24px #c1c5cb;\n\n    /* ====== SHADOWS & BORDERS ====== */\n    --shadow: rgba(60, 72, 90, 0.09);\n    /*     --box-shadow: 0 2px 8px var(--shadow); */\n    --card-border: 1px solid #c1c5cb;\n    --pill-border: 1.5px solid #dde2ec;\n    --pill-hover-bg: #e9eaed;\n    --pill-active-bg: #d8dade;\n    --pill-hover-shadow: 0 2px 8px #c1c5cb;\n\n    /* ====== LABELARR & LIBRARY CUSTOM ====== */\n    --labelarr-label-bg: rgba(200, 200, 200, 0.13);\n    --labelarr-label-color: #967540;\n    --labelarr-label-empty-color: #bcbec4;\n    --labelarr-library-bg: rgba(60, 110, 180, 0.07);\n    --labelarr-library-color: #4c7ad1;\n    --labelarr-arrow-color: #9fa3aa;\n    --labelarr-plex-instance-color: #5ea455;\n\n    --viewframe-border-top: rgba(0, 0, 0, 0.07);\n    --footer-update-badge-color: #23272e;\n    --update-tooltip-title-color: #23272e;\n    --daps-footer-box-shadow: rgba(0, 0, 0, 0.06);\n    --update-tooltip-versions-color: #868e96;\n    --preset-card-border: rgba(30, 40, 60, 0.1);\n    --dir-list-li-hover-background: rgba(50, 60, 80, 0.07);\n    --select2-results--option--highlighted-color: #23272e;\n    --modal-preset-type-color: #a37614;\n    --modal-preset-type-background: rgba(225, 200, 120, 0.19);\n    --modal-preset-content-color: #3a4c67;\n    --modal-preset-content-background: rgba(160, 175, 200, 0.13);\n\n    --terminal-bg: #1a1a1a;\n    --terminal-border: 1px solid #c1c5cb;\n    --terminal-color: #0f0;\n    --terminal-header-bg: #dee1e7;\n    --terminal-title-color: #23272e;\n    --terminal-control-close: #e33;\n    --terminal-control-min: #ffc400;\n    --terminal-control-max: #43a047;\n    --terminal-cursor-color: #0f0;\n}\n"
  },
  {
    "path": "web/static/css/common.css",
    "content": "/* ======================================================\n   TOAST NOTIFICATIONS\n====================================================== */\n#toast-container {\n    position: fixed;\n    bottom: var(--toast-position-bottom);\n    right: var(--toast-position-right);\n\n    display: flex;\n    flex-direction: column;\n    align-items: flex-end;\n    gap: 0.75rem;\n    z-index: 9999;\n}\n\n.toast {\n    background: var(--primary-bg);\n    color: var(--text-color);\n    padding: var(--toast-padding);\n    border-radius: var(--toast-radius);\n    box-shadow: var(--toast-shadow);\n    opacity: 0;\n    transform: translateY(20px);\n    transition: opacity 0.4s ease, transform 0.4s ease;\n    font-size: var(--toast-font-size);\n    font-weight: 600;\n}\n\n.toast.show {\n    opacity: 1;\n    transform: translateY(0);\n}\n\n.toast.success {\n    background: var(--success);\n    color: var(--primary-bg);\n}\n\n.toast.error {\n    background: var(--error);\n    color: var(--primary-bg);\n}\n\n.toast.info {\n    background: var(--info);\n    color: var(--primary-bg);\n}\n\n/* ======================================================\n   PASSWORD WRAPPER (API Key Input)\n====================================================== */\n.password-wrapper {\n    position: relative;\n    display: flex;\n    align-items: center;\n    width: 100%;\n}\n\n.password-wrapper input {\n    flex: 1 1 auto;\n    width: 100%;\n    padding-right: 2rem;\n    background: var(--form-bg);\n    color: var(--fg);\n    border: var(--form-border);\n    border-radius: var(--form-radius);\n}\n\n.password-wrapper input.masked-input {\n    -webkit-text-security: disc;\n}\n\n.password-wrapper .toggle-password {\n    position: absolute;\n    top: 50%;\n    right: 0.5rem;\n    transform: translateY(-50%);\n    cursor: pointer;\n    font-size: 1rem;\n    opacity: 0.7;\n    transition: opacity 0.2s, color 0.2s;\n}\n\n.password-wrapper .toggle-password:hover {\n    color: var(--focus);\n    opacity: 1;\n}\n\n/* ======================================================\n   BASE LAYOUT (BODY, CONTAINER, VIEW FRAME)\n====================================================== */\nbody {\n    font: var(--font-size-base) / var(--line-height) var(--font);\n    color: var(--text-color);\n    background: var(--primary-bg);\n    margin: 0;\n    padding: 0;\n    display: flex;\n    flex-direction: column;\n    min-height: 100vh;\n    font-size: 1.1rem;\n    position: relative;\n    overflow: hidden;\n}\n\n.container {\n    max-width: var(--container-max-width);\n    margin: 0 auto;\n    padding: var(--container-padding);\n    width: 100%;\n}\n\n.view-frame {\n    padding: var(--view-frame-padding, 2rem);\n}\n\n.view-frame.fade-in {\n    animation: pageFadeIn 0.3s ease-out forwards;\n}\n\n/* ======================================================\n   CARD COMPONENT\n====================================================== */\n.card {\n    background: var(--card-bg);\n    border: var(--card-border, 1px solid rgba(255, 255, 255, 0.1));\n    border-radius: var(--card-radius);\n    box-shadow: var(--card-shadow);\n    padding: var(--card-padding, 1.5rem);\n    margin-bottom: var(--card-margin-btm);\n    transition: transform 0.2s ease, box-shadow 0.2s ease;\n}\n\n.card:hover {\n    background: var(--card-hover-bg);\n    box-shadow: var(--card-shadow);\n    filter: brightness(1.01);\n}\n\n/* ======================================================\n   TYPOGRAPHY (HEADINGS)\n====================================================== */\nh1 {\n    font-size: var(--font-size-heading);\n    font-weight: var(--font-weight-heading);\n    margin-bottom: var(--card-margin-btm);\n    color: var(--heading-color);\n}\n\nh2 {\n    font-size: var(--font-size-subheading);\n    font-weight: var(--font-weight-heading);\n    margin-bottom: 0.75rem;\n}\n\n/* ======================================================\n   INPUT ERROR STATE\n====================================================== */\n.input-invalid {\n    outline: var(--form-invalid-outline) !important;\n    background: var(--form-invalid-bg) !important;\n    color: var(--form-invalid-color) !important;\n    transition: background 0.5s, outline 0.5s;\n}\n\n/* ======================================================\n   TOGGLE SWITCH\n====================================================== */\n.toggle-row {\n    display: flex;\n    align-items: flex-start;\n    gap: 0.75rem;\n}\n\n.toggle-switch {\n    position: relative;\n    display: inline-block;\n    width: var(--toggle-width);\n    height: var(--toggle-height);\n    color: var(--primary);\n}\n\n.toggle-switch input {\n    opacity: 0;\n    width: 0;\n    height: 0;\n}\n\n.slider {\n    position: absolute;\n    cursor: pointer;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background-color: var(--toggle-off);\n    transition: 0.2s;\n    border-radius: var(--toggle-radius);\n}\n\n.slider:before {\n    position: absolute;\n    content: '';\n    height: var(--toggle-slider-size);\n    width: var(--toggle-slider-size);\n    left: var(--toggle-slider-offset);\n    bottom: var(--toggle-slider-offset);\n    background-color: var(--toggle-thumb-bg);\n    transition: 0.2s;\n    border-radius: var(--toggle-slider-radius);\n}\n\n.toggle-switch input:checked + .slider {\n    background-color: var(--toggle-on);\n}\n\n.toggle-switch input:checked + .slider:before {\n    transform: translateX(16px);\n}\n\n/* ======================================================\n   BOX SIZING RESET\n====================================================== */\n*,\n*::before,\n*::after {\n    box-sizing: border-box;\n}\n\n/* ======================================================\n   SCROLLBAR GLOBAL (HIDE)\n====================================================== */\nhtml,\nbody {\n    scrollbar-width: none;\n    -ms-overflow-style: none;\n    overflow: hidden;\n}\n\nhtml::-webkit-scrollbar,\nbody::-webkit-scrollbar {\n    width: 0;\n    height: 0;\n}\n\n/* ======================================================\n   FORM CONTROLS (Input, Select, Textarea)\n====================================================== */\n.input,\n.select {\n    display: block;\n    width: 100%;\n    padding: var(--form-padding);\n    height: var(--form-height);\n    background: var(--form-bg);\n    color: var(--form-color);\n    border: var(--form-border);\n    box-shadow: var(--form-shadow);\n    border-radius: var(--form-radius);\n    font-size: var(--form-font-size);\n    box-sizing: border-box;\n    transition: border-color 0.2s, box-shadow 0.2s;\n    appearance: none;\n    -webkit-appearance: none;\n    -moz-appearance: none;\n}\n\n/* Arrow icon for selects */\n.select {\n    background-image: url(\"data:image/svg+xml;charset=UTF-8,%3Csvg viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23ff7300'/%3E%3C/svg%3E\");\n    background-repeat: no-repeat;\n    background-position: right 0.75rem center;\n    background-size: 0.65rem auto;\n}\n\n.input:focus,\n.select:focus {\n    outline: none;\n    border-color: var(--form-focus);\n    box-shadow: 0 0 0 3px var(--form-focus);\n}\n\n.textarea {\n    display: block;\n    width: 100%;\n    padding: var(--form-padding);\n    background: var(--form-bg);\n    color: var(--form-color);\n    border: var(--form-border);\n    border-radius: var(--form-radius);\n    font-size: 1rem;\n    line-height: 1.2;\n    box-sizing: border-box;\n    resize: vertical;\n    min-height: 4rem;\n}\n\n.textarea:focus {\n    outline: none;\n    border-color: var(--form-focus);\n    box-shadow: 0 0 0 3px var(--form-focus);\n}\n\n/* ======================================================\n   ANIMATIONS\n====================================================== */\n@keyframes pageFadeIn {\n    from {\n        opacity: 0;\n        transform: translateY(10px);\n    }\n\n    to {\n        opacity: 1;\n        transform: var(--translate-neutral);\n    }\n}\n\n/* ======================================================\n   LINK STYLING\n====================================================== */\na {\n    color: var(--link-color);\n    text-decoration: none;\n    transition: color 0.2s ease;\n}\n\na:hover,\na:focus {\n    color: var(--link-hover-color);\n    text-decoration: underline;\n}\n\n/* ======================================================\n   BUTTONS\n====================================================== */\n.btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    min-width: 100px;\n    padding: var(--btn-padding);\n    background: var(--btn-bg);\n    color: var(--btn-color);\n    text-align: center;\n    font-size: var(--btn-font-size, 0.95rem);\n    font-weight: 600;\n    border: none;\n    border-radius: var(--btn-radius, 4px);\n    cursor: pointer;\n    transition: background 0.2s, transform 0.1s ease;\n}\n\n.btn:hover {\n    background: var(--btn-hover-bg);\n    transform: translateY(-1px);\n}\n\n.btn:active {\n    transform: translateY(1px);\n}\n\n.btn--success {\n    background: var(--success);\n    color: var(--primary-bg);\n}\n\n.btn--success:hover {\n    background: var(--success-highlight);\n    transform: translateY(-1px);\n}\n\n.btn--success:active {\n    transform: translateY(1px);\n}\n\n.btn--cancel {\n    background: var(--error);\n    color: var(--primary-bg);\n}\n\n.btn--cancel:hover {\n    background: var(--error-highlight);\n    transform: translateY(-1px);\n}\n\n.btn--cancel:active {\n    transform: translateY(1px);\n}\n\n.btn-container {\n    display: flex;\n    gap: 0.75rem;\n    margin-left: auto;\n    align-items: center;\n}\n\n.btn--remove-item {\n    background: var(--error);\n    min-width: 40px;\n}\n\n/* ======================================================\n   GLOBAL HELP COMPONENTS\n====================================================== */\n\n/* Help wrapper: spacing reset, no border or shadow */\n.help {\n    margin: 1rem 0;\n    padding: 0;\n    background: none;\n    border-left: none;\n    box-shadow: none;\n}\n\n/* Help button: subtle, inline-flex, icon and text */\n.help-toggle {\n    background: none;\n    color: var(--link-color);\n    font-weight: 500;\n    font-size: 0.98rem;\n    border: none;\n    padding: 0;\n    display: flex;\n    align-items: center;\n    cursor: pointer;\n    opacity: 0.85;\n    transition: color 0.18s, opacity 0.18s;\n}\n\n/* SVG icon next to label */\n.help-icon {\n    margin-right: 0.18em;\n    vertical-align: -0.1em;\n    flex-shrink: 0;\n}\n\n/* Remove unwanted pseudo content */\n.help-toggle::before {\n    content: none;\n}\n\n/* Help button hover/focus: just increase opacity */\n.help-toggle:hover,\n.help-toggle:focus {\n    opacity: 1;\n}\n\n/* Expandable help content: transitions for smooth expand/collapse */\n.help-content {\n    font: var(--font);\n    word-wrap: break-word;\n    white-space: pre-line;\n    max-height: 0;\n    overflow: hidden;\n    transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s, border-width 0.3s,\n        margin-top 0.3s;\n    opacity: 0;\n    background: var(--card-bg);\n    border-left: 0 solid var(--accent);\n    padding: 0 1rem;\n    border-radius: var(--border-radius);\n    margin-top: 0;\n}\n\n/* Show expanded help content */\n.help-content.show {\n    max-height: 500px;\n    opacity: 1;\n    border-left: 4px solid var(--accent);\n    padding: 1rem;\n    margin-top: 0.5rem;\n}\n\n/* Help label: spacing and color */\n.help-label {\n    margin-left: 0.2em;\n    font-weight: 500;\n    font-size: 0.98em;\n    color: var(--link-color);\n    opacity: 0.88;\n}\n\n/* ======================================================\n   SEARCH INPUTS (Schedule/Notifications)\n====================================================== */\n#poster-search-input,\n#schedule-search,\n#notifications-search {\n    width: 100%;\n    font-size: 1rem;\n    margin-bottom: 1.5rem;\n    margin-top: 1.5rem;\n    box-sizing: border-box;\n}\n"
  },
  {
    "path": "web/static/css/index.css",
    "content": "/* ======================================================\n   SPLASH / NOTIFICATIONS PAGE LAYOUT\n====================================================== */\n.splash-container {\n    z-index: 1;\n    position: relative;\n    overflow: hidden;\n    width: 100%;\n    height: 100%;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    text-align: center;\n}\n\n/* ======================================================\n   SPLASH PARTICLE ANIMATION\n====================================================== */\n#splash-particles {\n    position: absolute;\n    width: 100%;\n    height: 100%;\n    top: 0;\n    left: 0;\n    z-index: 0;\n    pointer-events: none;\n}\n\n/* ======================================================\n   SPLASH CARD\n====================================================== */\n.splash-card {\n    background: var(--card-bg);\n    padding: var(--splash-card-padding);\n    border-radius: var(--splash-card-radius);\n    box-shadow: var(--splash-card-shadow);\n    animation: fadeIn 0.6s ease-out forwards;\n}\n\n/* ======================================================\n   SPLASH CARD HEADER\n====================================================== */\n.splash-icon {\n    font-size: var(--splash-icon-size);\n    margin-bottom: 1rem;\n}\n\n.splash-card h1 {\n    font-size: var(--splash-header-font-size);\n    margin-bottom: var(--splash-header-margin-btm);\n    color: var(--primary);\n}\n\n/* ======================================================\n   SPLASH CARD PARAGRAPH / SETTINGS FIELDS\n====================================================== */\n.splash-card p {\n    font-size: var(--font-size-base-plus);\n    color: var(--fg);\n    opacity: var(--splash-p-opacity);\n}\n\n/* ======================================================\n   SPLASH TYPING INDICATOR\n====================================================== */\n.splash-typing::after {\n    content: '|';\n    animation: blink 1s infinite;\n}\n\n/* ======================================================\n   SPLASH KEYFRAMES\n====================================================== */\n@keyframes fadeIn {\n    from {\n        opacity: 0;\n        transform: scale(0.95);\n    }\n\n    to {\n        opacity: 1;\n        transform: scale(1);\n    }\n}\n\n@keyframes blink {\n    0%,\n    100% {\n        opacity: 1;\n    }\n\n    50% {\n        opacity: 0;\n    }\n}\n\n/* ======================================================\n   UPDATE TOOLTIP & DASHBOARD HEADER/FOOTER\n====================================================== */\n\n/* Tooltip container for update badge */\n.has-update-tooltip {\n    position: relative;\n}\n\n/* Tooltip itself */\n.update-tooltip {\n    min-width: 225px;\n    background: var(--card-bg);\n    color: var(--fg);\n    border-radius: var(--card-radius);\n    padding: var(--card-padding);\n    font-size: 1.06em;\n    font-weight: 500;\n    box-shadow: var(--card-hover-shadow, 0 6px 24px 0 rgba(0, 0, 0, 0.3));\n    border: var(--card-border);\n    position: absolute;\n    left: 50%;\n    transform: translateX(-50%);\n    bottom: 2.4em;\n    z-index: 99999;\n    pointer-events: none;\n    opacity: 0;\n    transition: opacity 0.18s, box-shadow 0.2s;\n    text-align: left;\n    white-space: nowrap;\n    display: block;\n    filter: drop-shadow(0 4px 15px var(--error, #f443362c));\n}\n\n.has-update-tooltip:hover .update-tooltip,\n.has-update-tooltip:focus .update-tooltip {\n    opacity: 1;\n    pointer-events: auto;\n    display: block;\n    box-shadow: var(--card-hover-shadow, 0 10px 30px 0 rgba(244, 67, 54, 0.13));\n}\n\n.update-tooltip-title {\n    font-size: 1.1em;\n    font-weight: 600;\n    color: var(--update-tooltip-title-color);\n    letter-spacing: 0.02em;\n}\n\n.update-tooltip-versions {\n    color: var(--update-tooltip-versions-color);\n    font-size: 0.94em;\n}\n\n/* Footer (fixed, with card background, rounded) */\n.daps-footer {\n    position: fixed;\n    bottom: 0;\n    right: 0;\n    padding: var(--padding-standard);\n    background: var(--card-bg);\n    color: var(--fg);\n    font-size: 0.85rem;\n    border-top-left-radius: var(--card-radius);\n    box-shadow: -2px -2px 5px var(--daps-footer-box-shadow);\n    display: flex;\n    gap: 1rem;\n    align-items: center;\n    z-index: 1000;\n}\n\n/* Footer version label */\n.footer-version {\n    font-weight: 500;\n}\n\n/* Update badge in footer (hidden by default) */\n.footer-update-badge {\n    background: var(--error);\n    color: var(--footer-update-badge-color);\n    border-radius: 12px;\n    font-size: 0.82em;\n    padding: 2px 8px 2px 8px;\n    margin-left: 1em;\n    font-weight: 500;\n    vertical-align: middle;\n    cursor: pointer;\n    position: relative;\n}\n\n/* Footer external links (GitHub, Discord) */\n.footer-link {\n    display: flex;\n    align-items: center;\n    gap: 0.4rem;\n    color: var(--link-color);\n    text-decoration: none;\n}\n\n.daps-header-gradient {\n    text-align: center;\n    margin: 1.5rem auto 1rem;\n    font-size: 2.5rem;\n    font-weight: 600;\n    background: var(--daps-header-gradient);\n    -webkit-background-clip: text;\n    background-clip: text;\n    -webkit-text-fill-color: transparent;\n    text-shadow: var(--daps-header-shadow);\n    letter-spacing: 0.045em;\n    transition: filter 0.2s, letter-spacing 0.18s;\n    filter: blur(0.15px) brightness(1.07);\n}\n\n.daps-header-gradient:hover {\n    filter: var(--daps-header-hover);\n    letter-spacing: 0.09em;\n}\n\n.daps-header-gradient .daps {\n    font-variant: small-caps;\n    font-size: 1.11em;\n    letter-spacing: 0.17em;\n}\n\n.daps-header-gradient .dashboard-label {\n    font-weight: 600;\n    font-size: 0.92em;\n    margin-left: 0.14em;\n    position: relative;\n    display: inline-block;\n    padding-bottom: 2px;\n    color: var(--dashboard-label-color);\n    background: none !important;\n    -webkit-background-clip: var(--dashboard-label-background-clip);\n    background-clip: var(--dashboard-label-background-clip);\n    -webkit-text-fill-color: var(--dashboard-label-text-fill-color);\n    text-shadow: var(--dashboard-label-shadow);\n    filter: var(--dashboard-label-filter);\n    opacity: 0.98;\n}\n\n.daps-header-gradient .dashboard-label::after {\n    content: '';\n    position: absolute;\n    left: 20%;\n    right: 20%;\n    bottom: 0;\n    height: 2px;\n    background: var(--daps-header-underline);\n    opacity: 0.22;\n    border-radius: 2px;\n    transform: scaleX(0);\n    transition: transform 0.23s cubic-bezier(0.68, -0.55, 0.27, 1.55);\n    pointer-events: none;\n}\n\n.daps-header-gradient:hover .dashboard-label::after {\n    transform: scaleX(1);\n}\n\n.daps-header-link {\n    display: block;\n    text-decoration: none !important;\n    color: inherit !important;\n    cursor: pointer;\n}\n"
  },
  {
    "path": "web/static/css/instances.css",
    "content": "/* ======================================================\n   API KEY WRAPPER\n====================================================== */\n.api-wrapper {\n    position: relative;\n    width: 100%;\n}\n\n.api-wrapper input.masked-input,\n.api-wrapper input {\n    padding-right: 2.5rem;\n    box-sizing: border-box;\n}\n\n.api-wrapper .toggle-api {\n    position: absolute;\n    top: 50%;\n    right: var(--toggle-api-right);\n    transform: translateY(-50%);\n    cursor: pointer;\n    user-select: none;\n    font-size: var(--toggle-api-font-size);\n    opacity: var(--toggle-api-opacity);\n    line-height: 1;\n}\n\n/* Masked Input */\n.masked-input {\n    font-family: 'text-security-disc', sans-serif;\n    -webkit-text-security: disc;\n}\n\n/* ======================================================\n   INSTANCE BUTTONS (Test, Remove, Add)\n====================================================== */\n.instance-btn {\n    min-width: 130px;\n}\n\n/* ======================================================\n   INSTANCE CARD ANIMATIONS\n====================================================== */\n#instancesForm .card {\n    opacity: 0;\n    transform: translateY(24px) scale(0.98);\n    transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n}\n\n#instancesForm .card.show-card {\n    opacity: 1;\n    transform: translateY(0) scale(1);\n}\n\n#instancesForm .card.removing {\n    opacity: 0 !important;\n    transform: translateY(-32px) scale(0.94) !important;\n    pointer-events: none;\n    transition: opacity 0.25s cubic-bezier(0.71, 0, 0.32, 1),\n        transform 0.25s cubic-bezier(0.71, 0, 0.32, 1);\n}\n"
  },
  {
    "path": "web/static/css/layout.css",
    "content": "/* ======================================================\n   GENERAL CONTAINER\n====================================================== */\n.container {\n    position: relative;\n    z-index: 1;\n    background: var(--container-bg);\n    padding: var(--container-padding);\n    border-radius: var(--container-radius);\n    padding-top: 0;\n    width: 100%;\n    max-width: var(--container-max-width);\n    margin: 0 auto;\n}\n\n/* ======================================================\n   MAIN VIEW FRAME\n====================================================== */\n#viewFrame {\n    width: 100%;\n    height: 80vh;\n    background: var(--view-frame-bg);\n    border-radius: var(--border-radius);\n    padding: var(--view-frame-padding, 2rem);\n    overflow-y: auto;\n    border-top: 1px solid var(--viewframe-border-top);\n    border-top-left-radius: var(--border-radius);\n    border-top-right-radius: var(--border-radius);\n    transition: opacity 0.3s ease, transform 0.3s ease;\n    scroll-behavior: smooth;\n    overscroll-behavior: contain;\n    scrollbar-width: none;\n    -ms-overflow-style: none;\n    scrollbar-color: transparent transparent;\n}\n\n#viewFrame::-webkit-scrollbar {\n    width: 0;\n    height: 0;\n}\n\n/* ======================================================\n   CONTAINER FOR IFRAMES\n====================================================== */\n.container-iframe {\n    background: var(--container-bg);\n    padding: var(--container-padding);\n    width: 100%;\n    max-width: 100%;\n    scrollbar-width: none;\n    -ms-overflow-style: none;\n    padding-bottom: var(--container-padding-bottom);\n    padding-top: 0;\n}\n\n/* ======================================================\n   CONTENT SECTION\n====================================================== */\n.content {\n    border-top: none;\n    margin-top: 0;\n    flex: 1;\n    margin: 0;\n    padding: 0;\n}\n\n/* ======================================================\n   PAGE HEADINGS\n====================================================== */\nh1 {\n    text-align: center;\n    margin-bottom: var(--card-margin-btm);\n    font-size: var(--heading-font-size-lg);\n    color: var(--heading-color);\n}\n\n/* ======================================================\n   PAGE LAYOUTS\n====================================================== */\n\n/* Schedule Form Fields */\n#scheduleForm .field {\n    position: relative;\n    display: grid;\n    grid-template-columns: var(--field-label-width) 1fr auto;\n    align-items: start;\n    gap: 1.5rem;\n    margin-bottom: 0.5rem;\n    padding-bottom: 0.75rem;\n}\n\n/* Instances Form Fields */\n#instancesForm .field {\n    position: relative;\n    display: grid;\n    grid-template-columns: 1fr 1fr 1fr auto;\n    grid-template-rows: auto auto auto;\n    align-items: start;\n    gap: var(--field-gap);\n}\n\n/* Notifications Form Fields */\n#notificationsForm .field {\n    position: relative;\n    display: flex;\n    gap: var(--field-gap);\n    margin-bottom: 0.13em;\n    padding-bottom: 0;\n    padding-top: 0;\n    transition: margin-bottom 0.24s, padding-bottom 0.24s;\n}\n\n/* Settings Form Fields */\n#settingsForm .field {\n    position: relative;\n    display: grid;\n    grid-template-columns: var(--field-label-width) 1fr auto;\n    align-items: start;\n    gap: var(--field-gap);\n    margin-bottom: 0.5rem;\n    padding-bottom: 0.75rem;\n}\n"
  },
  {
    "path": "web/static/css/logs.css",
    "content": "/* ======================================================\n   LOG VIEWER: AUTO-SCROLL BADGE & SPINNER\n====================================================== */\n\n/* ===== Auto-Scroll Badge ===== */\n.log-scroll-badge {\n    position: absolute;\n    bottom: 3rem;\n    right: 20px;\n    background: var(--log-badge-bg);\n    color: var(--heading-color);\n    padding: var(--log-badge-padding);\n    border-radius: var(--log-badge-radius);\n    font-size: var(--log-badge-font-size);\n    display: none;\n    z-index: var(--log-badge-z);\n    transition: var(--log-badge-transition);\n    opacity: var(--log-badge-opacity);\n}\n\n/* ===== Spinner ===== */\n.log-spinner {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    width: var(--log-spinner-size);\n    height: var(--log-spinner-size);\n    margin: calc(var(--log-spinner-size) / -2) 0 0 calc(var(--log-spinner-size) / -2);\n    border: var(--log-spinner-border);\n    border-top: var(--log-spinner-border-top);\n    border-radius: var(--log-spinner-radius);\n    animation: spin 1s linear infinite;\n    z-index: var(--log-spinner-z);\n}\n\n@keyframes spin {\n    from {\n        transform: rotate(0deg);\n    }\n\n    to {\n        transform: rotate(360deg);\n    }\n}\n\n/* ======================================================\n   LOG OUTPUT CONTAINER & LINES\n====================================================== */\n\n/* ===== Main Log Output Container ===== */\n.log-output {\n    width: 100%;\n    height: 600px;\n    overflow: hidden;\n}\n\n/* ===== Log Lines & Animation ===== */\n.log-line {\n    opacity: 1;\n    transition: opacity 0.3s, transform 0.3s;\n}\n\n.log-line.new-line {\n    animation: fadeInLine 0.4s ease-out;\n}\n\n.log-line:hover {\n    background: var(--log-line-hover-bg);\n}\n\n@keyframes fadeInLine {\n    from {\n        opacity: 0;\n        transform: translateY(10px);\n    }\n\n    to {\n        opacity: 1;\n        transform: var(--translate-neutral);\n    }\n}\n\n/* ======================================================\n   SCROLL/JUMP BUTTONS\n====================================================== */\n\n/* ===== Jump to Bottom Button ===== */\n.jump-to-bottom {\n    position: absolute;\n    bottom: 1rem;\n    right: 1rem;\n    background: var(--primary);\n    color: var(--bg);\n    padding: var(--log-jump-btn-padding);\n    border-radius: var(--log-jump-btn-radius);\n    cursor: pointer;\n    font-weight: var(--log-jump-btn-font-weight);\n    font-size: var(--log-jump-btn-font-size);\n    box-shadow: var(--log-jump-btn-shadow);\n    display: none;\n    z-index: var(--log-jump-btn-z);\n    transition: all 0.2s ease;\n    border: var(--log-scroll-btn-border);\n}\n\n.jump-to-bottom:hover {\n    background: var(--focus);\n    transform: translateY(-1px);\n}\n\n/* ===== Scroll-to-Top & Scroll-to-Bottom Buttons ===== */\n.scroll-output-container {\n    position: relative;\n    flex-grow: 1;\n    overflow: hidden;\n    min-height: 0;\n}\n\n.scroll-to-top,\n.scroll-to-bottom {\n    position: absolute;\n    right: 1rem;\n    background: var(--card-bg);\n    color: var(--link-color);\n    padding: 0.4rem 0.9rem;\n    border-radius: var(--log-scroll-btn-radius);\n    font-weight: bold;\n    font-size: 0.85rem;\n    border: var(--log-scroll-btn-border);\n    z-index: 1000;\n    display: none;\n    cursor: pointer;\n}\n\n.scroll-to-top {\n    top: 1rem;\n}\n\n.scroll-to-bottom {\n    bottom: 1rem;\n}\n\n/* ======================================================\n   LOG CONTROLS & TOOLBAR\n====================================================== */\nbody.logs-open {\n    overflow: hidden;\n}\n\n.log-controls.log-toolbar {\n    display: flex;\n    flex-direction: row;\n    align-items: flex-end;\n    justify-content: flex-start;\n    gap: 1.1rem;\n    padding: 1rem 1.5rem;\n    background: var(--card-bg);\n    border-radius: var(--log-controls-radius);\n    box-shadow: var(--log-controls-shadow);\n    max-width: 95%;\n    margin: 1.5rem auto 1rem;\n    flex-wrap: nowrap;\n    overflow-x: auto;\n}\n\n.log-toolbar {\n    gap: var(--log-toolbar-gap);\n}\n\n/* ======================================================\n   LOG LEVELS (COLORS)\n====================================================== */\n.log-error {\n    color: var(--log-error);\n}\n\n.log-warning {\n    color: var(--log-warning);\n}\n\n.log-critical {\n    color: var(--log-critical);\n    font-weight: bold;\n}\n\n.log-info {\n    color: var(--log-info);\n}\n\n.log-debug {\n    color: var(--log-debug);\n}\n"
  },
  {
    "path": "web/static/css/modals.css",
    "content": "/* ======================================================\n   PAGE & MODAL OVERLAYS\n====================================================== */\n#pageOverlay,\n.overlay {\n    position: fixed;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background: var(--overlay-bg);\n    backdrop-filter: blur(var(--overlay-blur));\n    opacity: 0;\n    pointer-events: none;\n    transition: opacity 0.3s ease;\n    z-index: 400;\n}\n\nbody.modal-open #pageOverlay,\nbody.modal-open .overlay {\n    opacity: 1;\n    pointer-events: auto;\n}\n\n/* ======================================================\n   BASE MODAL\n====================================================== */\n.modal {\n    position: fixed;\n    top: 0;\n    left: 0;\n    width: 100vw;\n    height: 100vh;\n    background: var(--overlay-bg);\n    z-index: 1000;\n    backdrop-filter: blur(var(--overlay-blur));\n    padding-top: 0;\n    overflow-y: auto;\n    opacity: 0;\n    pointer-events: none;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    transform: scale(0.97);\n    transition: opacity 0.44s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        transform 0.48s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n}\n\n.modal.show {\n    opacity: 1;\n    pointer-events: auto;\n    transform: scale(1);\n}\n\n/* ======================================================\n   MODAL CONTENT\n====================================================== */\n.modal-content {\n    background: var(--card-bg);\n    color: var(--fg);\n    border-radius: var(--modal-radius);\n    padding: var(--modal-padding);\n    width: var(--modal-content-width);\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n    box-shadow: var(--dropdown-shadow);\n}\n\n.modal-content h2 {\n    margin: 0;\n    text-align: center;\n    font-size: var(--modal-header-font-size);\n    font-weight: var(--modal-header-font-weight);\n    margin-bottom: var(--modal-header-margin);\n}\n\n/* ======================================================\n   MODAL FOOTER\n====================================================== */\n.modal-footer {\n    display: flex;\n    justify-content: flex-end;\n    gap: var(--modal-footer-gap);\n    margin-top: var(--modal-footer-margin-top);\n}\n\n/* ======================================================\n   DIRECTORY MODAL\n====================================================== */\n#dir-list {\n    list-style: none;\n    padding: 0;\n    max-height: 300px;\n    overflow-y: auto;\n    background: var(--form-bg);\n    border: var(--form-border);\n    border-radius: var(--form-radius);\n    margin: 1rem 0;\n    color: white;\n}\n\n#dir-list li {\n    padding: var(--form-padding);\n    cursor: pointer;\n    display: flex;\n    align-items: center;\n    color: var(--fg);\n}\n\n#dir-list li:hover {\n    background: var(--dir-list-li-hover-background);\n}\n\n#dir-modal .modal-content .h2 {\n    font-size: 1.5rem;\n    margin-bottom: 1rem;\n    color: var(--fg);\n}\n\n/* ======================================================\n   DIRECTORY BREADCRUMB\n====================================================== */\n#dir-breadcrumb {\n    font-size: 0.9rem;\n    margin-bottom: 0.5rem;\n}\n\n/* ======================================================\n   UNSAVED CHANGES MODAL\n====================================================== */\n#unsavedModal {\n    position: fixed;\n    top: 0;\n    left: 0;\n    width: 100vw;\n    height: 100vh;\n    background: var(--overlay-bg);\n    z-index: 10000;\n    backdrop-filter: blur(var(--overlay-blur));\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    opacity: 0;\n    pointer-events: none;\n    transform: scale(0.97);\n    transition: opacity 0.44s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        transform 0.48s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n    font-family: var(--font);\n}\n\n#unsavedModal.show {\n    opacity: 1;\n    pointer-events: auto;\n    transform: scale(1);\n}\n\n#unsavedModal .modal-content {\n    background: var(--modal-bg);\n    color: var(--modal-color);\n    padding: var(--modal-padding);\n    border-radius: var(--modal-radius);\n    box-shadow: var(--modal-shadow);\n    max-width: var(--modal-max-width);\n    width: 25%;\n    text-align: center;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n}\n\n#unsavedModal .modal-content p {\n    font-size: 1.25rem;\n    margin-bottom: 1.5rem;\n}\n\n#unsavedModal button {\n    padding: var(--modal-btn-padding);\n    font-size: var(--modal-btn-font-size);\n    border: none;\n    border-radius: var(--modal-btn-radius);\n    cursor: pointer;\n    margin: var(--modal-btn-margin);\n    font-weight: var(--modal-btn-font-weight);\n    transition: background 0.2s ease, transform 0.1s ease;\n    display: block;\n}\n\n#unsavedModal .modal-content button {\n    width: 100%;\n    max-width: 240px;\n}\n\n#unsavedModal .save-btn {\n    background: var(--success);\n    color: var(--primary-bg);\n}\n\n#unsavedModal .discard-btn {\n    background: var(--caution);\n    color: var(--primary-bg);\n}\n\n#unsavedModal .cancel-btn {\n    background: var(--error);\n    color: var(--primary-bg);\n}\n\n#unsavedModal button:hover {\n    transform: translateY(-1px);\n}\n\n/* ======================================================\n   HOLIDAY MODAL FIELD LAYOUT\n====================================================== */\n#holiday-modal .modal-content .field {\n    display: flex;\n    flex-direction: column;\n    grid-template-columns: none !important;\n}\n\n#holiday-modal .modal-content .field > label,\n#holiday-modal .modal-content .field > input,\n#holiday-modal .modal-content .field > select,\n#holiday-modal .modal-content .field > button {\n    width: 100%;\n}\n\n/* ======================================================\n   LABELARR MODAL PILL OVERRIDES\n====================================================== */\n#labelarr-modal {\n    --pill-padding: 0.5rem 1rem;\n    --pill-radius: 4px;\n    --pill-font-size: 1rem;\n    --pill-font-weight: 400;\n}\n\n/* ======================================================\n   SCHEDULE RANGE FIELD\n====================================================== */\n.schedule-range {\n    display: flex;\n    gap: 0.5rem;\n    align-items: center;\n}\n\n/* ======================================================\n   SELECT2 CUSTOM STYLING\n====================================================== */\n.select2-container .select2-selection--single {\n    background: var(--form-bg);\n    color: var(--form-color, var(--fg));\n    border: var(--form-border);\n    border-radius: var(--form-radius);\n    font-size: var(--form-font-size);\n    height: var(--form-height);\n    min-height: var(--form-height);\n    box-shadow: var(--input-shadow, none);\n    display: flex;\n    align-items: center;\n    padding-left: 1rem;\n    transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n.select2-container--default .select2-selection--single:focus,\n.select2-container--default .select2-selection--single.select2-selection--focus {\n    border-color: var(--form-focus);\n    box-shadow: 0 0 0 3px var(--form-focus);\n    outline: none;\n}\n\n.select2-container--default .select2-selection--single .select2-selection__rendered {\n    color: var(--form-color, var(--fg));\n    font-size: var(--form-font-size);\n    line-height: var(--form-height);\n    padding-left: 0;\n    font-weight: 500;\n}\n\n.select2-dropdown {\n    background: var(--form-bg);\n    color: var(--form-color, var(--fg));\n    border: var(--form-border);\n    border-radius: var(--form-radius);\n    font-size: var(--form-font-size);\n    box-shadow: var(--dropdown-shadow);\n}\n\n.select2-results__option {\n    background: none;\n    color: var(--form-color, var(--fg));\n    padding: 0.55em 1em;\n}\n\n.select2-results__option--highlighted {\n    background: var(--primary);\n    color: var(--select2-results--option--highlighted-color);\n}\n\n.select2-container--default .select2-selection--single .select2-selection__arrow {\n    background-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23ff7300'/%3E%3C/svg%3E\");\n    background-repeat: no-repeat;\n    background-position: right 0.85em center;\n    background-size: 0.65em auto;\n    width: 2em;\n    height: 100%;\n    border: none;\n}\n\n.select2-container--default .select2-selection__arrow b {\n    display: none;\n}\n\n/* ======================================================\n   PRESET CARD (MODAL CARD-LIKE ELEMENT)\n====================================================== */\n.preset-card {\n    background: var(--card-bg, #23272f);\n    color: var(--fg, #dbe4ee);\n    border-radius: 10px;\n    padding: 1.2rem 1.8rem 1.2rem 1.5rem;\n    margin: 0.5rem 0 1.1rem 0;\n    box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.03) 0px 1px 3px;\n    border: 1px solid var(--preset-card-border);\n    font-size: 1.06rem;\n    transition: box-shadow 0.15s;\n    line-height: 1.6;\n}\n\n.preset-label {\n    color: var(--accent, #82a6d7);\n    font-weight: 500;\n    margin-right: 0.25em;\n}\n\n.preset-field {\n    margin-bottom: 0.45em;\n    display: flex;\n    align-items: flex-start;\n    gap: 0.4em;\n    font-size: 1em;\n}\n\n.preset-type {\n    color: var(--modal-preset-type-color);\n    background: var(--modal-preset-type-background);\n    border-radius: 3.5px;\n    font-weight: 500;\n    padding: 0.05em 0.65em 0.08em;\n    margin-left: 0.2em;\n    font-size: 0.98em;\n}\n\n.preset-content {\n    margin: 0.3em 0 0.5em 1em;\n    color: var(--modal-preset-content-color);\n    font-size: 1em;\n    line-height: 1.5;\n    padding-left: 0.7em;\n    background: var(--modal-preset-content-background);\n    border-radius: 2px;\n}\n"
  },
  {
    "path": "web/static/css/navigation.css",
    "content": "/* ======================================================\n   NAVIGATION BAR\n====================================================== */\nnav {\n    border-bottom: none;\n    margin-bottom: 0;\n    box-shadow: var(--nav-shadow);\n    position: sticky;\n    top: 0;\n    z-index: 100;\n    background: var(--primary-bg);\n}\n\n/* ======================================================\n   TOP MENU\n====================================================== */\n.menu {\n    display: flex;\n    justify-content: center;\n    gap: var(--nav-menu-gap);\n    background: var(--nav-glass-bg);\n    align-items: center;\n    list-style: none;\n    padding: 0;\n    margin: 0;\n    backdrop-filter: blur(var(--nav-glass-blur, 10px));\n    -webkit-backdrop-filter: blur(var(--nav-glass-blur, 10px));\n    border-bottom: var(--nav-glass-border, 1px solid rgba(255, 255, 255, 0.07));\n    box-shadow: var(--nav-shadow);\n    border-radius: var(--nav-border-radius);\n}\n\n.menu a,\n.dropdown-toggle {\n    color: var(--text-color);\n    font-size: var(--font-size-base);\n    font-weight: var(--font-weight-base);\n    text-decoration: none;\n    position: relative;\n    padding: 0.5rem 0;\n    background: none;\n    border: none;\n    border-radius: var(--nav-border-radius);\n    transition: color 0.2s;\n    vertical-align: middle;\n}\n\n/* Only show underline for top-level horizontal menu items, not dropdowns */\n.menu > li > a.active::after,\n.menu > li > a:hover::after {\n    content: '';\n    position: absolute;\n    bottom: -4px;\n    left: 0;\n    right: 0;\n    height: 2px;\n    background: var(--highlight);\n    border-radius: 1px;\n    transition: background 0.18s;\n}\n\n.menu > li > .dropdown-toggle.active::after {\n    content: '';\n    position: absolute;\n    bottom: -4px;\n    left: 0;\n    right: 0;\n    height: 2px;\n    background: var(--highlight);\n    border-radius: 1px;\n    transition: background 0.18s;\n}\n\n/* Prevent underline on dropdown menu links */\n.dropdown-menu li a::after {\n    display: none !important;\n    content: none !important;\n}\n\n.dropdown-toggle.active::after {\n    content: '';\n    position: absolute;\n    bottom: -4px;\n    left: 0;\n    right: 0;\n    height: 2px;\n}\n\n.dropdown-menu li a.active {\n    background: var(--primary);\n    color: var(--bg);\n    font-weight: 600;\n    opacity: 1;\n    border-left: 3px solid var(--highlight);\n}\n\n/* ======================================================\n   DROPDOWN WRAPPER\n====================================================== */\n.dropdown {\n    position: relative;\n    display: inline-block;\n}\n\n/* ======================================================\n   DROPDOWN TOGGLE BUTTON\n====================================================== */\n.dropdown-toggle {\n    background: none;\n    border: none;\n    color: var(--text-color);\n    font-size: var(--font-size-base);\n    font-weight: var(--font-weight-base);\n    padding: 0.5rem 0 0.5rem 0;\n    margin-bottom: 0;\n    position: relative;\n    cursor: pointer;\n    border-radius: var(--nav-border-radius);\n    transition: color 0.2s;\n    display: inline-flex;\n    align-items: center;\n}\n\n/* ======================================================\n   DROPDOWN MENU\n====================================================== */\n.dropdown-menu {\n    position: absolute;\n    top: 100%;\n    left: 50%;\n    transform: translate(-50%, -8px) scale(0.95);\n    opacity: 0;\n    pointer-events: none;\n    list-style: none;\n    margin: 0;\n    padding: 0;\n    background: var(--nav-glass-bg);\n    backdrop-filter: blur(var(--nav-glass-blur, 22px)) saturate(180%);\n    -webkit-backdrop-filter: blur(var(--nav-glass-blur, 22px)) saturate(180%);\n    border: var(--nav-glass-border, 1.5px solid rgba(255, 255, 255, 0.13));\n    box-shadow: var(--nav-dropdown-shadow), 0 2px 20px 0 rgba(30, 38, 43, 0.09) inset;\n    border-radius: var(--nav-dropdown-radius);\n    min-width: 10rem;\n    overflow: hidden;\n    z-index: 100;\n    width: 12rem;\n    transition: opacity 150ms ease-in, transform 200ms ease-out;\n}\n\n.dropdown.open .dropdown-menu {\n    opacity: 1;\n    pointer-events: auto;\n    transform: translate(-50%, 0) scale(1);\n    width: 14rem;\n}\n\n.dropdown-menu li {\n    margin: 0;\n}\n\n/* ======================================================\n   DROPDOWN MENU ITEMS\n====================================================== */\n.dropdown-menu li a {\n    white-space: nowrap;\n    display: block;\n    padding: var(--nav-dropdown-item-padding);\n    background: var(--nav-glass-bg);\n    color: var(--fg);\n    text-decoration: none;\n    transform: translateX(0);\n    opacity: 0.95;\n    border-radius: var(--nav-dropdown-item-radius);\n    transition: background 150ms ease, color 150ms ease, transform 150ms ease, opacity 150ms ease;\n}\n\n.dropdown-menu li a:hover {\n    background: var(--primary);\n    color: var(--bg);\n    transform: translateX(2px);\n    opacity: 1;\n    border-left: 3px solid var(--highlight);\n}\n\n.dropdown-menu li:first-child a,\n.dropdown-menu li:last-child a {\n    border-radius: var(--nav-dropdown-item-radius);\n}\n\n/* ======================================================\n   SETTINGS PANEL\n====================================================== */\n.settings-panel {\n    background: var(--card-bg);\n    padding: 1rem;\n    border-radius: var(--border-radius-default);\n    box-shadow: 0 6px 12px var(--shadow);\n    display: flex;\n    flex-direction: column;\n    gap: 0.5rem;\n    min-width: 14rem;\n}\n\n/* ======================================================\n   SETTINGS PANEL TITLE\n====================================================== */\n.settings-panel .panel-title {\n    font-size: 1.1rem;\n    font-weight: 700;\n    margin-bottom: 0.5rem;\n    padding-bottom: 0.5rem;\n    /* border-bottom: 1px solid var(--shadow); */\n    text-align: center;\n}\n\n/* ======================================================\n   SETTINGS PANEL ITEMS\n====================================================== */\n.settings-panel li {\n    list-style: none;\n    margin: 0;\n}\n\n.settings-panel li a {\n    display: block;\n    padding: 0.75rem 1.25rem;\n    background: var(--card-bg);\n    color: var(--fg);\n    border-radius: var(--border-radius-default);\n    text-decoration: none;\n    font-weight: 500;\n    text-align: center;\n    transition: background 200ms, border 200ms, color 200ms;\n}\n\n.settings-panel li a:hover,\n.settings-panel li a.active {\n    color: var(--bg);\n}\n"
  },
  {
    "path": "web/static/css/notifications.css",
    "content": "/* ======================================================\n   NOTIFICATIONS CARD\n====================================================== */\n#notificationsForm .card {\n    margin-bottom: var(--card-margin-btm);\n    opacity: 0;\n    transform: translateY(24px) scale(0.98);\n    transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n}\n\n#notificationsForm .card.show-card {\n    opacity: 1;\n    transform: translateY(0) scale(1);\n}\n\n/* ======================================================\n   TOGGLE ROWS (FIELD GROUPS)\n====================================================== */\n#notificationsForm .field.toggle-row--expanded {\n    margin-bottom: 0.44em;\n    padding-bottom: 0.38em;\n    z-index: 2;\n}\n\n#notificationsForm .field.toggle-row {\n    margin-bottom: 0.08em;\n    margin-top: 0.04em;\n    padding-left: 0.07em;\n    gap: var(--field-gap, 0.6em);\n}\n\n/* ======================================================\n   NOTIFICATION TOGGLE GROUP\n====================================================== */\n.notification-toggle-group {\n    display: flex;\n    flex-direction: column;\n    gap: 0.18em;\n    margin-top: 0.24em;\n    margin-bottom: 0.37em;\n}\n\n/* ======================================================\n   TEST BUTTON (VISIBLE ONLY IF ENABLED)\n====================================================== */\n#notificationsForm .field.toggle-row .btn--test.enabled {\n    display: inline-flex;\n}\n\n/* ======================================================\n   CARD HEADER\n====================================================== */\n.card-header {\n    padding-bottom: 0.3em;\n    margin-bottom: 0.5em;\n    font-size: 1.2em;\n    font-weight: bold;\n    color: var(--primary);\n}\n\n/* ======================================================\n   NOTIFICATION FIELDSET (COLLAPSIBLE EXPAND/COLLAPSE)\n====================================================== */\n.notification-fieldset {\n    display: flex;\n    flex-direction: column;\n    border-radius: var(--card-radius, 8px);\n    margin: 0.24em 0 0.52em 0;\n    padding: var(--notification-fieldset-padding, 1em 1.3em 1.1em 1.2em);\n    opacity: 0;\n    max-height: 0;\n    pointer-events: none;\n    transform: translateY(18px) scale(0.98);\n    transition: opacity 0.33s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        max-height 0.33s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        padding 0.17s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        transform 0.33s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n    overflow: hidden;\n    box-shadow: var(--notification-card-shadow, 0 2px 10px rgba(0, 0, 0, 0.13));\n    background-color: var(--card-bg);\n}\n\n.notification-fieldset.expanded {\n    opacity: 1;\n    max-height: 2000px;\n    pointer-events: auto;\n    transform: translateY(0) scale(1);\n    animation: notificationFieldsetIn 0.33s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n    margin-bottom: 1.3em !important;\n}\n\n.notification-fieldset.expanded .notification-field-container:last-child {\n    margin-bottom: 1.3em;\n}\n\n.notification-fieldset:not(.expanded) {\n    animation: notificationFieldsetOut 0.27s cubic-bezier(0.71, 0, 0.32, 1);\n}\n\n/* ======================================================\n   FIELDSET LEGEND\n====================================================== */\n.fieldset-legend {\n    margin-top: 0.6em;\n    margin-bottom: 0.95em;\n    text-align: left;\n    font-weight: 600;\n    /* color: var(--primary); */\n    font-size: 1.11em;\n}\n\n/* ======================================================\n   FIELD CONTAINER\n====================================================== */\n.notification-field-container {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    margin-bottom: 0.39em;\n}\n\n.notification-field-container label {\n    font-size: 1em;\n    font-weight: 500;\n    margin-bottom: 0.27em;\n    margin-right: 0;\n}\n\n.notification-field-container:last-child {\n    margin-bottom: 0;\n}\n\n/* ======================================================\n   TEST BUTTON (GLOBAL)\n====================================================== */\n.btn--test {\n    display: none;\n    margin-left: auto;\n}\n\n/* ======================================================\n   FIELDSET ANIMATIONS\n====================================================== */\n@keyframes notificationFieldsetIn {\n    from {\n        opacity: 0;\n        transform: translateY(18px) scale(0.98);\n    }\n\n    to {\n        opacity: 1;\n        transform: translateY(0) scale(1);\n    }\n}\n\n@keyframes notificationFieldsetOut {\n    from {\n        opacity: 1;\n        transform: translateY(0) scale(1);\n    }\n\n    to {\n        opacity: 0;\n        transform: translateY(-11px) scale(0.96);\n    }\n}\n"
  },
  {
    "path": "web/static/css/poster_search.css",
    "content": "/* ======================================================\n   STATS HEADER & TITLE\n====================================================== */\n.stats-title {\n    font-weight: var(--font-weight-heading);\n    font-size: 1.15em;\n    margin-bottom: 0.3em;\n    margin-left: 0.2em;\n}\n\n/* ======================================================\n   STATS TABLE\n====================================================== */\n.stats-table {\n    width: 100%;\n    border-collapse: separate;\n    border-spacing: 0;\n    margin-bottom: 0.7em;\n    background: var(--card-bg);\n    border-radius: var(--card-radius);\n    box-shadow: var(--card-shadow);\n    font-size: 1.03em;\n    table-layout: fixed;\n}\n\n.stats-table th,\n.stats-table td {\n    padding: 0.41em 0.55em;\n    text-align: left;\n    font-family: var(--font);\n    vertical-align: middle;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n}\n\n.stats-table th {\n    color: var(--link-color);\n    font-weight: var(--font-weight-heading);\n    background: var(--secondary-bg);\n    border-bottom: 1.5px solid var(--shadow);\n    letter-spacing: 0.02em;\n    font-size: 1.07em;\n}\n\n.stats-table td {\n    color: var(--text-color);\n    background: var(--card-bg);\n    font-size: 0.99em;\n    transition: background 0.12s;\n}\n\n.stats-table tr {\n    border-bottom: 1px solid var(--shadow);\n}\n\n.stats-table tr:last-child {\n    border-bottom: none;\n}\n\n.stats-table tr:nth-child(even) td {\n    background: var(--secondary-bg);\n}\n\n.stats-table tr:hover td {\n    background: var(--stats-table-hover-bg, #222c);\n    color: var(--stats-table-hover-color, #f5faff);\n}\n\n.stats-table td:first-child {\n    min-width: 145px;\n    max-width: 210px;\n    width: 30%;\n    font-weight: var(--font-weight-heading);\n}\n\n.stats-table td:nth-child(2),\n.stats-table th:nth-child(2) {\n    width: 15%;\n    text-align: right;\n}\n\n.stats-table td:nth-child(3),\n.stats-table th:nth-child(3) {\n    width: 18%;\n    text-align: right;\n}\n\n.stats-table td:nth-child(4),\n.stats-table th:nth-child(4) {\n    width: 17%;\n    text-align: right;\n    min-width: 110px;\n}\n\n/* Error rows */\n.gdrive-row-error td,\n.gdrive-row-error {\n    background: var(--stats-row-error-bg, #37242a) !important;\n    color: var(--error, #ff4545) !important;\n}\n\n/* ======================================================\n   STAT BAR\n====================================================== */\n.stat-bar-bg {\n    background: var(--secondary-bg);\n    border-radius: var(--border-radius-default);\n    width: 80px;\n    height: 11px;\n    display: inline-block;\n    vertical-align: middle;\n    margin-right: 0.5em;\n    position: relative;\n    overflow: hidden;\n}\n\n.stat-bar-inner {\n    background: var(--stat-bar-gradient, linear-gradient(90deg, var(--info, #339af0), #1565c0));\n    height: 100%;\n    border-radius: var(--border-radius-default);\n}\n\n.stat-bar-percent {\n    color: var(--text-color);\n    font-size: 0.97em;\n    margin-left: 0.2em;\n}\n\n/* ======================================================\n   FOLDER/NAME HIGHLIGHT\n====================================================== */\n.gdrive-name,\n.result-folder {\n    color: var(--link-color);\n    font-weight: var(--font-weight-heading);\n    font-size: 1.08em;\n    font-family: var(--font);\n}\n\n/* ======================================================\n   TABLE FOOTER\n====================================================== */\n.stats-footer {\n    margin-top: 0.7em;\n    font-size: 1.03em;\n    font-weight: 500;\n    color: var(--stats-footer-color, #c9d0d9);\n}\n\n/* ======================================================\n   SEARCH TOGGLE ROW & LABELS\n====================================================== */\n.poster-search-toggle-row {\n    display: flex;\n    align-items: center;\n    gap: 0.5em;\n    margin-bottom: 1.1em;\n    padding-left: 0.1em;\n    color: var(--text-color);\n}\n\n.poster-search-label,\n.poster-search-scope-label {\n    font-size: var(--font-size-base);\n    font-weight: 500;\n    color: var(--text-color);\n    line-height: 1.25;\n}\n\n.poster-search-label {\n    margin-right: 0.2em;\n    min-width: 75px;\n    text-align: right;\n}\n\n.poster-search-scope-label {\n    margin-left: 0.2em;\n    min-width: 115px;\n    text-align: left;\n}\n\n.poster-search-toggle-row .toggle-switch {\n    margin: 0 2px;\n}\n\n/* ======================================================\n   POSTER SEARCH RESULTS LIST\n====================================================== */\n.poster-list {\n    list-style: none;\n    margin: 0 0 0.25em 0;\n    padding: 0;\n    background: none;\n    border-radius: 0;\n    border: none;\n}\n\n.poster-list li {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 0.07em 0.2em 0.07em 1em;\n    min-height: 1.7em;\n    border-bottom: 1px solid var(--shadow);\n    font-size: var(--font-size-base);\n    line-height: var(--line-height);\n    background: none;\n    margin: 0;\n    border-radius: 0;\n    transition: background 0.15s;\n    font-family: var(--font);\n    font-weight: var(--font-weight-base);\n}\n\n.poster-list li:last-child {\n    border-bottom: none;\n}\n\n.highlight {\n    background: var(--highlight-bg, #ffeaa7);\n    color: var(--highlight-color, #222);\n    font-weight: 500;\n    border-radius: 2px;\n    padding: 0 2px;\n}\n\n.poster-list li.img-preview-link:hover {\n    background: var(--poster-list-hover-bg, #222c);\n    color: var(--poster-list-hover-color, #ffe06f);\n}\n\n.poster-file-label {\n    flex: 1 1 auto;\n    cursor: pointer;\n    min-width: 0;\n    word-break: break-all;\n    font-size: var(--font-size-base);\n    font-weight: 500;\n}\n\n/* ======================================================\n   COPY BUTTON\n====================================================== */\n.copy-btn {\n    flex: 0 0 auto;\n    background: none;\n    border: none;\n    cursor: pointer;\n    font-size: var(--font-size-base);\n    margin-left: 0.35em;\n    color: var(--link-color);\n    opacity: 0.73;\n    vertical-align: middle;\n    transition: color 0.2s, opacity 0.2s;\n    display: flex;\n    align-items: center;\n    padding: 0.1em 0.25em;\n    border-radius: var(--border-radius-default);\n    font-family: var(--font);\n    font-weight: var(--font-weight-base);\n}\n\n.copy-btn:active {\n    background: var(--copy-btn-active-background);\n}\n\n.copy-btn span {\n    vertical-align: middle;\n    line-height: 1.2;\n    font-family: var(--font);\n}\n\n.copy-btn:hover {\n    color: var(--copy-btn-hover-color, #ffe06f);\n    opacity: 1;\n}\n\n.copy-btn-copied {\n    color: var(--success, #5af176);\n    display: none;\n    align-items: center;\n}\n\n.copy-btn .material-icons {\n    font-family: 'Material Icons', var(--font) !important;\n    font-size: 1.18em !important;\n    font-style: normal;\n    font-weight: normal;\n    line-height: 1;\n    margin-right: 2px;\n    opacity: 0.85;\n    position: relative;\n    top: 2px;\n}\n\n.copy-btn-default,\n.copy-btn-copied {\n    display: inline-flex;\n    align-items: center;\n}\n\n/* ======================================================\n   TOOLTIP (GDRIVE ETC)\n====================================================== */\n.gdrive-tooltip-wrapper {\n    position: relative;\n    display: inline-block;\n}\n\n.gdrive-tooltip-content {\n    display: none;\n    position: absolute;\n    border-radius: var(--border-radius);\n    left: 50%;\n    top: 120%;\n    transform: translateX(-50%);\n    min-width: 240px;\n    background: var(--gdrive-tooltip-bg, #232c3b);\n    color: var(--gdrive-tooltip-color, #e6ecfa);\n    padding: 0.82em 0.82em;\n    border-radius: 7px;\n    box-shadow: 0 6px 24px var(--gdrive-tooltip-shadow, #0006);\n    font: var(--font);\n    font-size: 0.99em;\n    font-weight: 500;\n    line-height: 1.4;\n    z-index: 9000;\n    opacity: 0;\n    pointer-events: none;\n    transition: opacity 0.18s cubic-bezier(0.7, 1.5, 0.7, 1);\n    white-space: normal;\n}\n\n.gdrive-tooltip-wrapper:hover .gdrive-tooltip-content,\n.gdrive-tooltip-wrapper:focus-within .gdrive-tooltip-content {\n    display: block;\n    opacity: 1;\n    pointer-events: auto;\n}\n\n.gdrive-name.gdrive-tooltip-red {\n    color: var(--gdrive-tooltip-red, #e75b5b);\n    cursor: help;\n}\n\n.gdrive-tooltip-content b {\n    color: var(--gdrive-tooltip-highlight, #ffd166);\n    font-weight: 700;\n}\n\n.gdrive-custom-badge {\n    font-size: 0.89em;\n    color: var(--link-color);\n    margin-left: 0.3em;\n    font-weight: 500;\n    opacity: 0.95;\n}\n\n.gdrive-sort-row {\n    display: flex;\n    align-items: center;\n    gap: 0.7em;\n    margin-bottom: 0.4em;\n}\n\n.gdrive-sort-label {\n    color: var(--link-color);\n    font-size: 1em;\n    font-weight: 500;\n}\n\n.gdrive-sort-select {\n    max-width: 240px;\n}\n\n/* ======================================================\n   LOADER SPINNER\n====================================================== */\n/* === Terminal Loader Spinner === */\n@keyframes blinkCursor {\n    50% {\n        border-right-color: transparent;\n    }\n}\n\n@keyframes typeAndDelete {\n    0%,\n    10% {\n        width: 0;\n    }\n\n    45%,\n    85% {\n        width: 11.5em;\n    }\n\n    90%,\n    100% {\n        width: 0;\n    }\n}\n\n.poster-search-loader-modal {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    background: var(--overlay-bg);\n    backdrop-filter: blur(1px);\n    z-index: 12000;\n    border-radius: var(--container-radius);\n    min-height: 340px;\n}\n\n.terminal-loader {\n    background: var(--terminal-bg);\n    border: var(--terminal-border);\n    color: var(--terminal-color);\n    /* font-family: \"Courier New\", Courier, monospace; */\n    font-size: 1em;\n    padding: 1.5em 1.5em;\n    width: 14em;\n    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);\n    border-radius: 4px;\n    position: relative;\n    overflow: hidden;\n    box-sizing: border-box;\n}\n\n.terminal-header {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    height: 1.5em;\n    background: var(--terminal-header-bg);\n    border-top-left-radius: 4px;\n    border-top-right-radius: 4px;\n    padding: 0 0.4em;\n    box-sizing: border-box;\n}\n\n.terminal-controls {\n    float: right;\n}\n\n.control {\n    display: inline-block;\n    width: 0.6em;\n    height: 0.6em;\n    margin-left: 0.4em;\n    border-radius: 50%;\n    background-color: #777;\n}\n\n.control.close {\n    background-color: var(--terminal-control-close);\n}\n\n.control.minimize {\n    background-color: var(--terminal-control-min);\n}\n\n.control.maximize {\n    background-color: var(--terminal-control-max);\n}\n\n.terminal-title {\n    float: left;\n    line-height: 1.5em;\n    color: var(--terminal-title-color);\n}\n\n.text {\n    display: inline-block;\n    white-space: nowrap;\n    overflow: hidden;\n    color: var(--terminal-color);\n    border-right: 0.2em solid var(--terminal-cursor-color);\n    animation: typeAndDelete 4s steps(19) infinite, blinkCursor 0.5s step-end infinite alternate;\n    margin-top: 1.5em;\n}\n\n/* ======================================================\n   IMAGE PREVIEW MODAL\n====================================================== */\n#img-preview-modal {\n    display: none;\n    position: fixed;\n    z-index: 12000;\n    inset: 0;\n    justify-content: center;\n    align-items: center;\n}\n\n#img-preview-modal.show {\n    display: flex;\n}\n\n.img-modal-bg {\n    position: absolute;\n    left: 0;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    background: var(--overlay-bg);\n}\n\n.img-modal-content {\n    position: relative;\n    z-index: 1;\n    background: var(--card-bg);\n    border-radius: var(--border-radius);\n    padding: var(--card-padding);\n    box-shadow: 0 8px 32px var(--shadow);\n    max-width: 80vw;\n    max-height: 80vh;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n}\n\n.img-modal-close {\n    position: absolute;\n    right: 0.9em;\n    top: 0.6em;\n    font-size: 2em;\n    background: none;\n    color: var(--text-color);\n    border: none;\n    cursor: pointer;\n    z-index: 2;\n}\n\n.img-modal-img {\n    max-width: 68vw;\n    max-height: 62vh;\n    margin-bottom: 1em;\n    border-radius: var(--border-radius-default);\n    box-shadow: 0 2px 12px var(--img-modal-shadow, #1117);\n    background: var(--card-bg);\n}\n\n.img-modal-caption {\n    color: var(--text-color);\n    font-size: 1.02em;\n    word-break: break-all;\n    text-align: center;\n    margin-top: 0.5em;\n    opacity: 0.8;\n}\n\n/* ======================================================\n   HOVER PREVIEW IMAGE\n====================================================== */\n.hover-preview {\n    pointer-events: none;\n    position: fixed;\n    border: 1px solid var(--hover-preview-border, #444);\n    background: var(--card-bg);\n    max-width: 200px;\n    max-height: 200px;\n    border-radius: var(--border-radius-default);\n    z-index: 13000;\n    display: none;\n    box-shadow: 0 2px 14px var(--shadow);\n    transition: left 0.13s cubic-bezier(0.3, 1.1, 0.6, 1), top 0.13s cubic-bezier(0.3, 1.1, 0.6, 1);\n}\n\n.poster-search-btn-row {\n    margin: 1.5rem 0;\n}\n\n.poster-search-toggle-btn {\n    margin-bottom: 0.8em;\n}\n\n.poster-search-spinner {\n    display: none;\n    text-align: center;\n    margin-bottom: 1.3em;\n}\n\n.poster-search-results {\n    margin-top: 1.5em;\n}\n"
  },
  {
    "path": "web/static/css/schedule.css",
    "content": "/* ======================================================\n   RUN BUTTON\n====================================================== */\n.run-btn.running {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    text-align: center;\n    background: var(--success);\n    color: var(--primary-bg);\n    cursor: default;\n}\n\n.run-btn.running.cancel-hover {\n    background: var(--error) !important;\n    color: var(--primary-bg) !important;\n    cursor: pointer;\n}\n\n/* ======================================================\n   STATUS DISPLAY\n====================================================== */\n#status {\n    margin-top: var(--status-margin-top, 1rem);\n    font-size: var(--status-font-size, 1rem);\n    font-weight: 600;\n    text-align: center;\n}\n\n#status.error {\n    color: var(--error);\n}\n\n/* ======================================================\n   RUN BUTTON SPINNER\n====================================================== */\n.run-btn.running::after {\n    content: '';\n    display: inline-block;\n    width: 1em;\n    height: 1em;\n    margin-left: 0.5rem;\n    border: 2px solid var(--primary-bg);\n    border-top: 2px solid var(--btn-hover-bg);\n    border-radius: 50%;\n    animation: spin 0.8s linear infinite;\n    vertical-align: middle;\n}\n\n@keyframes spin {\n    to {\n        transform: rotate(360deg);\n    }\n}\n\n/* ======================================================\n   SCHEDULE FORM CARD ANIMATION\n====================================================== */\n#scheduleForm .card {\n    opacity: 0;\n    transform: translateY(24px) scale(0.98);\n    transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n}\n\n#scheduleForm .card.show-card {\n    opacity: 1;\n    transform: translateY(0) scale(1);\n}\n"
  },
  {
    "path": "web/static/css/settings.css",
    "content": "/* ======================================================\n   SETTINGS PANEL LAYOUT & CORE FIELDS\n====================================================== */\n\n.label {\n    margin: 0;\n    font-size: 1rem;\n    font-weight: 600;\n    color: var(--fg);\n    align-self: flex-start;\n}\n\n.field-control {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    gap: 0.75rem;\n}\n\n.subfield-list {\n    width: 100%;\n}\n\n#settingsForm .subfield {\n    display: flex;\n    align-items: center;\n    gap: 0.5em;\n    margin-top: 0.15em;\n}\n\n.field-hint {\n    margin-top: 0.25em;\n    font-size: 0.95em;\n    color: var(--fg-secondary);\n}\n\n.field-hint-warning-text {\n    color: var(--error);\n}\n\n/* ======================================================\n   BUTTONS & ACTIONS\n====================================================== */\n.field > button.add-control-btn {\n    margin-top: 0.75rem;\n    align-self: start;\n    grid-column: 1;\n    grid-row: 2;\n    position: relative;\n    left: 0;\n}\n\n.setting-entry-actions {\n    display: flex;\n    flex-direction: row;\n    gap: 0.5rem;\n    margin-left: auto;\n    align-self: center;\n}\n\n.remove-directory {\n    min-width: 0;\n    min-height: 20px;\n    padding: 0.25rem 0.5rem;\n}\n\n/* ======================================================\n   DRAG & DROP/ENTRY HOVER\n====================================================== */\n.draggable.drag-over {\n    border: 2px dashed var(--primary-bg);\n    transform: translateY(5px);\n}\n\n/* ======================================================\n   ENTRY CARDS & LAYOUTS\n====================================================== */\n.card.setting-entry {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 1.5rem;\n    align-items: flex-end;\n}\n\n/* ======================================================\n   TABLES (UPGRADINATORR)\n====================================================== */\n.upgradinatorr-table {\n    width: 100%;\n    max-width: 100vw;\n    border-collapse: separate;\n    border-spacing: 0;\n    margin-top: 0.5rem;\n    background: var(--card-bg);\n    border-radius: 12px;\n    box-shadow: 0 2px 6px var(--shadow);\n    overflow: hidden;\n    table-layout: auto;\n    font-size: 1rem;\n}\n\n.upgradinatorr-table th,\n.upgradinatorr-table td {\n    padding: 0.5rem 1.2rem;\n    border-bottom: 1px solid var(--shadow);\n    vertical-align: middle;\n}\n\n.upgradinatorr-table th {\n    background: var(--table-header);\n    font-weight: 700;\n    color: var(--fg);\n    text-align: center;\n    font-size: 1.08rem;\n    letter-spacing: 0.01em;\n}\n\n.upgradinatorr-table td {\n    background: var(--card-bg);\n    font-size: 1rem;\n}\n\n.upgradinatorr-table tr:last-child td {\n    border-bottom: none;\n}\n\n.upgradinatorr-table td:last-child {\n    text-align: center;\n    vertical-align: middle;\n    white-space: nowrap;\n}\n\n.upgradinatorr-table tr:hover td {\n    background: var(--card-hover-bg);\n    transition: background 0.15s;\n}\n\n.upgradinatorr-table {\n    border-radius: 12px;\n    overflow: hidden;\n}\n\n.upgradinatorr-table thead tr:first-child th:first-child {\n    border-top-left-radius: 12px;\n}\n\n.upgradinatorr-table thead tr:first-child th:last-child {\n    border-top-right-radius: 12px;\n}\n\n.upgradinatorr-table tr:last-child td:first-child {\n    border-bottom-left-radius: 12px;\n}\n\n.upgradinatorr-table tr:last-child td:last-child {\n    border-bottom-right-radius: 12px;\n}\n\n/* ======================================================\n   PLEX INSTANCE CARD & LIBRARIES\n====================================================== */\n.card.plex-instance-card {\n    width: 100% !important;\n    flex-grow: 1;\n    align-self: stretch;\n    box-sizing: border-box;\n    border-radius: 10px;\n    display: flex;\n    flex-direction: column;\n    margin-bottom: 2rem;\n}\n\n.plex-instance-header {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n}\n\n.plex-instance-header h3 {\n    margin: 0;\n    font-size: 1.25rem;\n    color: var(--fg);\n}\n\n.plex-libraries {\n    display: grid;\n    grid-template-columns: repeat(3, 1fr);\n    gap: 0.75rem;\n    overflow: hidden;\n    opacity: 0;\n    margin-top: 1rem;\n    transition: max-height 0.4s ease, opacity 0.4s ease;\n}\n\n.plex-libraries.open {\n    opacity: 1;\n}\n\n/* ======================================================\n   BORDER COLOR PICKER\n====================================================== */\n#border-colors-container {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 1rem;\n    margin: 1rem 0;\n    min-height: 3rem;\n}\n\n#border-colors-container .subfield {\n    display: inline-flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n#border-colors-container .subfield input[type='color'] {\n    width: 2rem;\n    height: 2rem;\n    padding: 0;\n    border: none;\n    background: none;\n}\n\n/* ======================================================\n   HOLIDAY COLOR SWATCHES/CARDS\n====================================================== */\n.holiday-swatch {\n    display: inline-block;\n    width: 1rem;\n    height: 1rem;\n    border-radius: 2px;\n    margin: 0 0.25rem;\n    vertical-align: middle;\n    border: 1px solid var(--fg);\n}\n\n.holiday-card {\n    background: var(--card-bg);\n    padding: var(--card-padding);\n    border-radius: var(--card-radius);\n    box-shadow: var(--card-shadow);\n    margin-bottom: 1rem;\n}\n\n.holiday-header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    margin-bottom: 0.75rem;\n}\n\n.holiday-app {\n    font-weight: 600;\n    color: var(--fg);\n}\n\n.holiday-labels {\n    font-style: italic;\n    color: var(--fg-secondary, #aaa);\n    margin-left: 1rem;\n}\n\n/* ======================================================\n   LABELARR MAPPING CARDS\n====================================================== */\n.labelarr-mapping-card {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 1.7rem;\n    padding: 1.15rem 1.7rem;\n    background: var(--card-bg);\n    border-radius: 8px;\n    border: var(--card-border);\n    margin-bottom: 1rem;\n    box-shadow: 0 1px 6px var(--shadow);\n}\n\n.labelarr-mapping-left {\n    display: flex;\n    flex-direction: column;\n    align-items: flex-start;\n    min-width: 180px;\n    gap: 0.45em;\n}\n\n.mapping-app {\n    font-size: 1.13em;\n    color: var(--fg);\n    font-weight: 700;\n    letter-spacing: 0.01em;\n}\n\n.mapping-instance {\n    font-size: 1em;\n    color: var(--mapping-instance-color);\n}\n\n.mapping-instance span {\n    color: var(--fg);\n    font-weight: 500;\n}\n\n.mapping-labels {\n    margin-top: 0.1em;\n    display: flex;\n    gap: 0.5em;\n    flex-wrap: wrap;\n}\n\n.labelarr-label {\n    background: var(--labelarr-label-bg);\n    color: var(--labelarr-label-color);\n    font-weight: 500;\n    font-size: 0.99em;\n    border-radius: 5px;\n    padding: 0.16em 0.75em;\n    letter-spacing: 0.01em;\n}\n\n.labelarr-label-empty {\n    color: var(--labelarr-label-empty-color);\n}\n\n.labelarr-mapping-center {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    min-width: 40px;\n}\n\n.labelarr-arrow {\n    font-size: 1.5em;\n    color: var(--labelarr-arrow-color);\n}\n\n.labelarr-mapping-right {\n    display: flex;\n    flex-direction: row;\n    flex-wrap: wrap;\n    gap: 0.7em;\n    min-width: 130px;\n    max-width: 380px;\n}\n\n.labelarr-plex-target {\n    display: flex;\n    align-items: center;\n    flex-wrap: wrap;\n    gap: 0.4em;\n    margin-bottom: 0.15em;\n}\n\n.labelarr-plex-instance {\n    color: var(--labelarr-plex-instance-color);\n    font-weight: 600;\n    font-size: 1.06em;\n}\n\n.labelarr-library {\n    background: var(--labelarr-library-bg);\n    color: var(--labelarr-library-color);\n    border-radius: 4px;\n    padding: 0.1em 0.8em;\n    font-size: 0.99em;\n    margin-left: 0.32em;\n}\n\n.labelarr-mapping-actions {\n    display: flex;\n    flex-direction: row;\n    align-items: center;\n    justify-content: center;\n    gap: 0.75em;\n    min-width: 120px;\n}\n\n.labelarr-mapping-left,\n.labelarr-mapping-center,\n.labelarr-mapping-right,\n.labelarr-mapping-actions {\n    min-width: 0;\n}\n\n/* ======================================================\n   LIBRARY PILL COMPONENTS & ACTIONS\n====================================================== */\n.library-actions {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    flex-wrap: wrap;\n    gap: 0.5rem;\n}\n\n.library-actions > div {\n    display: flex;\n    gap: 0.5rem;\n}\n\n.library-pill {\n    display: inline-flex;\n    align-items: center;\n    gap: 0.7em;\n    padding: 0.48em 1.1em;\n    background: var(--card-bg);\n    border: var(--pill-border);\n    border-radius: var(--pill-radius);\n    font-size: 1rem;\n    font-weight: 500;\n    color: var(--fg);\n    cursor: pointer;\n    margin-bottom: 0.45rem;\n    transition: border-color 0.18s, background 0.2s, box-shadow 0.18s;\n    user-select: none;\n    min-width: 0;\n    min-height: 2.2em;\n}\n\n.library-pill input[type='checkbox'] {\n    margin-right: 0.7em;\n    accent-color: var(--primary);\n    width: 1.15em;\n    height: 1.15em;\n}\n\n.library-pill:hover,\n.library-pill:focus-within {\n    border-color: var(--focus);\n    background: var(--pill-hover-bg);\n    box-shadow: var(--pill-hover-shadow);\n}\n\n.library-pill:active {\n    background: var(--pill-active-bg);\n}\n\n.library-pill input[type='checkbox']:checked + span,\n.library-pill input[type='checkbox']:checked {\n    font-weight: 600;\n}\n\n.library-pill input[type='checkbox']:focus {\n    outline: 2px solid var(--focus);\n    outline-offset: 2px;\n}\n\n.library-pill input[type='checkbox']:checked ~ * {\n    color: var(--focus);\n}\n\n/* Animate settings cards (top-level .card, and GDrive sync .card.setting-entry) */\n#settingsForm .card,\n#settingsForm .card.setting-entry {\n    opacity: 0;\n    transform: translateY(24px) scale(0.98);\n    transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n}\n\n#settingsForm .card.show-card,\n#settingsForm .card.setting-entry.show-card {\n    opacity: 1;\n    transform: translateY(0) scale(1);\n}\n\n/* Animate all fields inside cards */\n#settingsForm .field {\n    opacity: 0;\n    transform: translateY(20px) scale(0.98);\n    transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98),\n        transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98);\n}\n\n#settingsForm .field.show-field {\n    opacity: 1;\n    transform: translateY(0) scale(1);\n}\n"
  },
  {
    "path": "web/static/js/common.js",
    "content": "const DAPS = {\n    bindSaveButton,\n    setSaveButtonState,\n    markDirty,\n    isDirty: false,\n    skipDirtyCheck: false,\n    showUnsavedModal,\n    humanize,\n    showToast,\n};\n\nfunction bindSaveButton(saveBtn, buildPayloadFn, key, postSave) {\n    if (!saveBtn) return;\n    saveBtn.type = 'button';\n    saveBtn.onclick = async () => {\n        await saveSection(buildPayloadFn, key, postSave);\n    };\n}\nfunction setSaveButtonState(saveBtn, state, label = 'Save') {\n    if (!saveBtn) return;\n    if (state === 'saving') {\n        saveBtn.disabled = true;\n        saveBtn.textContent = 'Saving...';\n        saveBtn.classList.remove('btn--success');\n    } else if (state === 'success') {\n        saveBtn.disabled = true;\n        saveBtn.textContent = 'Saved!';\n        saveBtn.classList.add('btn--success');\n        setTimeout(() => {\n            saveBtn.disabled = false;\n            saveBtn.textContent = label;\n            saveBtn.classList.remove('btn--success');\n        }, 2000);\n    } else {\n        saveBtn.disabled = false;\n        saveBtn.textContent = label;\n        saveBtn.classList.remove('btn--success');\n    }\n}\nfunction markDirty() {\n    DAPS.isDirty = true;\n}\n\nasync function saveSection(buildPayload, key, postSave, saveBtn) {\n    if (!saveBtn) saveBtn = document.getElementById('saveBtn');\n    setSaveButtonState(saveBtn, 'saving');\n    const payload = await buildPayload();\n    if (!payload || typeof payload[key] === 'undefined') {\n        setSaveButtonState(saveBtn, 'default');\n        return;\n    }\n    try {\n        const res = await fetch('/api/config', {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify(payload),\n        });\n        if (!res.ok) throw res;\n        DAPS.isDirty = false;\n        showToast(`✅ ${key.charAt(0).toUpperCase() + key.slice(1)} updated!`, 'success');\n        setSaveButtonState(saveBtn, 'success');\n        if (typeof postSave === 'function') postSave();\n    } catch (err) {\n        let msg = err.statusText || 'Save failed';\n        try {\n            const data = await err.json();\n            msg = data.error || msg;\n        } catch {}\n        showToast(`❌ ${msg}`, 'error');\n        setSaveButtonState(saveBtn, 'default');\n    }\n}\n\nfunction showUnsavedModal() {\n    return new Promise((resolve) => {\n        let modal = document.getElementById('unsavedModal');\n        if (!modal) {\n            modal = document.createElement('div');\n            modal.id = 'unsavedModal';\n            modal.innerHTML = `\n                <div class=\"modal-content\">\n                <p>You have unsaved changes. What would you like to do?</p>\n                <button class=\"save-btn\">Save</button>\n                <button class=\"discard-btn\">Discard</button>\n                <button class=\"cancel-btn\">Cancel</button>\n                </div>`;\n            document.body.appendChild(modal);\n        }\n        modal.classList.add('show');\n        requestAnimationFrame(() => {\n            modal.classList.add('show');\n            document.body.classList.add('modal-open');\n        });\n\n        const saveBtn = modal.querySelector('.save-btn');\n        const discardBtn = modal.querySelector('.discard-btn');\n        const cancelBtn = modal.querySelector('.cancel-btn');\n\n        function cleanup(choice) {\n            modal.classList.remove('show');\n            document.body.classList.remove('modal-open');\n            setTimeout(() => {\n                modal.classList.remove('show');\n            }, 250);\n            resolve(choice);\n        }\n        saveBtn.addEventListener(\n            'click',\n            async function handler() {\n                setSaveButtonState(saveBtn, 'saving', 'Save');\n                const pageSaveBtn = document.getElementById('saveBtn');\n                if (pageSaveBtn) {\n                    pageSaveBtn.click();\n                    DAPS.isDirty = false;\n                } else {\n                    console.warn('No Save button found for this page.');\n                }\n                setSaveButtonState(saveBtn, 'success', 'Save');\n                saveBtn.removeEventListener('click', handler);\n                setTimeout(() => cleanup('save'), 700);\n            },\n            { once: true }\n        );\n        discardBtn.addEventListener('click', () => cleanup('discard'), { once: true });\n        cancelBtn.addEventListener('click', () => cleanup('cancel'), { once: true });\n    });\n}\n\nfunction humanize(key) {\n    return key.replace(/_/g, ' ').replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\nfunction showToast(message, type = 'info', timeout = 3000) {\n    const container = document.getElementById('toast-container');\n    if (!container) return;\n    const toast = document.createElement('div');\n    toast.className = `toast ${type}`;\n    toast.textContent = message;\n    toast.addEventListener('click', () => {\n        toast.classList.remove('show');\n        setTimeout(() => {\n            if (toast.parentNode === container) container.removeChild(toast);\n        }, 300);\n    });\n    container.appendChild(toast);\n    setTimeout(() => {\n        toast.classList.add('show');\n    }, 100);\n    setTimeout(() => {\n        toast.classList.remove('show');\n        setTimeout(() => {\n            if (toast.parentNode === container) container.removeChild(toast);\n        }, 500);\n    }, timeout);\n}\n\nexport { DAPS, showToast, humanize, showUnsavedModal };\n"
  },
  {
    "path": "web/static/js/help_content.js",
    "content": "export const HELP_CONTENT = {\n    schedule: [\n        'Options:',\n        'hourly(XX)',\n        'Examples: hourly(00) or hourly(18) – Will perform the action every hour at the specified time',\n        '',\n        'daily(XX:XX)',\n        'Examples: daily(12:23) or daily(18:15) – Will perform the action every day at the specified time',\n        'Examples: daily(10:18|12:23) – Will perform the action every day at the specified times',\n        '',\n        'weekly(day_of_week@XX:XX)',\n        'Examples: weekly(monday@12:00) or weekly(monday@18:15) – Will perform the action on the specified day of the week at the specified time',\n        'Examples: weekly(monday@12:23)',\n        '',\n        'monthly(day_of_month@XX:XX)',\n        'Examples: monthly(15@12:00) or monthly(15@18:15) – Will perform the action on the specified day of the month at the specified time',\n        '',\n        'cron(<cron_expression>)',\n        'Examples: cron(0 0 * * *) – Will perform the action every day at midnight',\n        'Examples: cron(*/5 * * * *) – Will perform the action every 5 minutes',\n        'Examples: cron(0 */3 * * *) – Will perform the action every 3rd hour',\n        [\n            'Please visit ',\n            { type: 'link', text: 'https://crontab.guru/', url: 'https://crontab.guru/' },\n            ' for more information on cron expressions',\n        ],\n        '',\n        'Note: You cannot use both cron and human-readable expressions in the same schedule.',\n        '',\n        'Schedule only supports the following options: hourly, daily, weekly, monthly, cron',\n    ],\n    settings: [\n        {\n            gdrive_sync: [\n                'Sync GDrive posters/assets to your local collection. Each entry represents a GDrive connection.',\n                \"name: Friendly name for this GDrive (e.g., 'Main Posters').\",\n                'id: The unique GDrive folder or shared drive ID.',\n                'location: Local directory to sync assets to (destination folder).',\n                'token: Paste the service account token or OAuth JSON here.',\n                {\n                    type: 'link',\n                    text: 'rclone configuration wiki',\n                    url: 'https://github.com/Drazzilb08/daps/wiki/rclone-configuration',\n                },\n            ],\n            poster_renamerr: [\n                'Organizes and renames poster files for Kometa/Plex.',\n                'source_dirs: One or more folders to scan for posters, priority is:',\n                '  • Top = Lowest priority',\n                '  • Bottom = Highest priority',\n                'destination_dir: Where renamed/organized posters are moved.',\n                'Asset Folders: This setting MUST be the same to what you have set in Kometa',\n                \"Print Only Renames: Print each file as it's processed.\",\n                'Run Border Replacerr: Run border_replacer after renaming posters.',\n                'Incremental Border Replacerr: Border replacerr will only run on posters that have been renamed.',\n                'Instances: List the Radarr/Sonarr instances you wish to use as source for renaming of posters,',\n                'Plex is used for collections only and not as a source for Movies/TV Shows.',\n            ],\n            poster_cleanarr: [\n                'Ignore Media: List of media to ignore during cleaning of posters from your assets directory.',\n                'Source Dirs: Folders to scan for posters to clean, typically your Kometa assets directory.',\n            ],\n            unmatched_assets: [\n                'Finds assets/posters not matched to any item in your media library.',\n                'source_dirs: Folders to search for unmatched assets. Typically your assets directory.',\n            ],\n            border_replacerr: [\n                'Adds or replaces borders on posters. Supports holiday presets and custom colors.',\n                \"Source/Destination Dirs: These fields is not required if you're planning on running border_replacerr in line with poster_renaemrr.\",\n                'border_colors: Array of colors (HEX codes) for the border.',\n                'skip: Skips running border replacerr until a Holiday',\n                'exclusion_list: List of items to exclude from border replacement.',\n                'holiday_name: Label for this border/holiday.',\n                \"schedule: When this border should be active (see 'schedule' help).\",\n                'destination_dir: Output directory for processed posters.',\n            ],\n            health_checkarr: [\n                'Scans for media deleted from TMDB/TVDB and removes them from Sonarr/Radarr.',\n                'data_dir: Root folder for media scan.',\n                \"print_files: Print each item as it's processed.\",\n            ],\n            labelarr: [\n                'Syncs Radarr/Sonarr tags/labels to Plex.',\n                'app_type: Radarr or Sonarr.',\n                'app_instance: Name of the Radarr/Sonarr config instance.',\n                'labels: Comma-separated list of tags to sync.',\n                'plex_instances: List of Plex servers/libraries to sync with.',\n            ],\n            upgradinatorr: [\n                'Automatically triggers upgrades/searches to maximize quality in Radarr/Sonarr.',\n                'instance: Name of the Radarr/Sonarr server.',\n                'count: Max number of searches per run.',\n                'tag_name: The tag used to mark an item as having been searched for upgrades.',\n                'ignore_tag: Do not upgrade media with this tag.',\n                'unattended: If true, skip confirmation.',\n                'season_monitored_threshold: Minimum monitored percentage per season (Sonarr only).',\n            ],\n            renameinatorr: [\n                'Triggers Radarr/Sonarr rename jobs.',\n                'tag_name: The tag that will be used to mark as been renamed.',\n                'Count: The maximum global number of renames to perform.',\n                'Radarr Count: The maximum number of renames to perform in Radarr.',\n                'Sonarr Count: The maximum number of renames to perform in Sonarr.',\n                'instance: Server to run renames on.',\n            ],\n            nohl: [\n                'Scans for non-hardlinked files and can auto-resolve them.',\n                'source_dirs: One or more directories to scan.',\n                'mode (per folder):',\n                '  • Resolve: Delete+search to restore missing hardlinks automatically.',\n                '  • Scan: Only log/report non-hardlinked files, do not resolve.',\n            ],\n            jduparr: [\n                'Runs jdupes to find/remove duplicate files.',\n                'source_dirs: Folders to deduplicate.',\n            ],\n        },\n    ],\n};\n"
  },
  {
    "path": "web/static/js/helper.js",
    "content": "import { HELP_CONTENT } from './help_content.js';\nimport { humanize } from './common.js';\n\nexport const moduleOrder = [\n    'sync_gdrive',\n    'poster_renamerr',\n    'poster_cleanarr',\n    'unmatched_assets',\n    'border_replacerr',\n    'renameinatorr',\n    'upgradinatorr',\n    'nohl',\n    'labelarr',\n    'health_checkarr',\n    'jduparr',\n    'main',\n];\n\nexport const NOTIFICATION_LIST = [\n    'poster_renamerr',\n    'unmatched_assets',\n    'renameinatorr',\n    'upgradinatorr',\n    'nohl',\n    'labelarr',\n    'health_checkarr',\n    'jduparr',\n    'main'\n];\n\nexport const NOTIFICATION_DEFINITIONS = {\n    email: {\n        label: 'Email',\n        fields: [\n            {\n                key: 'smtp_server',\n                label: 'SMTP Server',\n                type: 'text',\n                dataType: 'string',\n                required: true,\n                placeholder: 'smtp.gmail.com',\n            },\n            {\n                key: 'smtp_port',\n                label: 'SMTP Port',\n                type: 'number',\n                dataType: 'int',\n                required: true,\n                placeholder: '587',\n            },\n            {\n                key: 'username',\n                label: 'Username',\n                type: 'text',\n                dataType: 'string',\n                required: true,\n                placeholder: 'user@example.com',\n            },\n            {\n                key: 'password',\n                label: 'Password',\n                type: 'password',\n                dataType: 'string',\n                required: true,\n                placeholder: 'yourpassword or app password on gmail',\n            },\n            {\n                key: 'from',\n                label: 'From',\n                type: 'email',\n                dataType: 'string',\n                required: true,\n                placeholder: 'noreply@example.com',\n            },\n            {\n                key: 'to',\n                label: 'Recipients',\n                type: 'textarea',\n                dataType: 'list',\n                required: true,\n                placeholder: 'admin@example.com\\nsupport@example.com',\n            },\n            {\n                key: 'use_tls',\n                label: 'Use TLS',\n                type: 'checkbox',\n                dataType: 'bool',\n                required: false,\n            },\n        ],\n    },\n    discord: {\n        label: 'Discord',\n        fields: [\n            {\n                key: 'webhook',\n                label: 'Webhook URL',\n                type: 'text',\n                dataType: 'string',\n                required: true,\n                placeholder: 'https://discord.com/api/webhooks/...',\n            },\n        ],\n    },\n    notifiarr: {\n        label: 'Notifiarr',\n        fields: [\n            {\n                key: 'webhook',\n                label: 'Webhook URL',\n                type: 'text',\n                dataType: 'string',\n                required: true,\n                placeholder: 'https://notifiarr.com/api/...',\n            },\n            {\n                key: 'channel_id',\n                label: 'Channel ID',\n                type: 'text',\n                dataType: 'string',\n                required: true,\n                placeholder: '123456789012345678',\n            },\n        ],\n    },\n};\n\nexport const NOTIFICATION_TYPES_PER_MODULE = {\n    unmatched_assets: ['email'],\n    main: ['discord', 'notifiarr'],\n};\n\nexport function renderHelp(sectionName) {\n    function animateHeight(element, open = true, duration = 350) {\n        if (!element) return;\n        const startHeight = element.offsetHeight;\n\n        element.style.height = startHeight + 'px';\n        element.style.overflow = 'hidden';\n        element.style.transition = `height ${duration}ms cubic-bezier(.44,1.13,.73,.98)`;\n\n        const targetHeight = open ? element.scrollHeight : 0;\n\n        void element.offsetHeight;\n\n        element.style.height = targetHeight + 'px';\n\n        function afterTransition() {\n            element.style.transition = '';\n            element.style.height = open ? '' : '0px';\n            element.style.overflow = open ? '' : 'hidden';\n            element.removeEventListener('transitionend', afterTransition);\n        }\n\n        element.addEventListener('transitionend', afterTransition);\n    }\n    if (!HELP_CONTENT || !sectionName) return null;\n\n    let entry = HELP_CONTENT[sectionName];\n\n    if (\n        !entry &&\n        HELP_CONTENT.settings &&\n        Array.isArray(HELP_CONTENT.settings) &&\n        HELP_CONTENT.settings[0][sectionName]\n    ) {\n        entry = HELP_CONTENT.settings[0][sectionName];\n    }\n\n    if (!entry) return null;\n\n    const wrapper = document.createElement('div');\n    wrapper.className = 'help';\n    const toggle = document.createElement('button');\n    toggle.type = 'button';\n    toggle.className = 'help-toggle';\n    toggle.setAttribute('aria-label', `Show help for ${humanize(sectionName)}`);\n    toggle.innerHTML = `\n    <svg class=\"help-icon\" width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n        <circle cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" fill=\"none\" stroke-width=\"2\"/>\n        <path d=\"M12 16v-2a3 3 0 1 0-3-3\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\"/>\n        <circle cx=\"12\" cy=\"18\" r=\"1\" fill=\"currentColor\"/>\n    </svg>\n    <span class=\"help-label\">Show help for ${humanize(sectionName)}?</span>\n    `;\n\n    const content = document.createElement('pre');\n    content.className = 'help-content';\n    content.innerHTML = Array.isArray(entry)\n        ? entry\n              .map((line) =>\n                  Array.isArray(line)\n                      ? `<div>${line\n                            .map((part) => (typeof part === 'string' ? part : renderHelpLink(part)))\n                            .join('')}</div>`\n                      : `<div>${typeof line === 'string' ? line : renderHelpLink(line)}</div>`\n              )\n              .join('')\n        : entry;\n\n    let isToggling = false;\n    toggle.addEventListener('click', () => {\n        if (isToggling) return;\n        isToggling = true;\n        const isOpen = content.classList.toggle('show');\n        if (isOpen) {\n            content.style.maxHeight = content.scrollHeight + 'px';\n\n            content.addEventListener('transitionend', function handler(e) {\n                if (e.propertyName === 'max-height' && content.classList.contains('show')) {\n                    content.style.maxHeight = 'none'; // \"auto\" sizing from now on\n                    content.removeEventListener('transitionend', handler);\n                    isToggling = false;\n                }\n            });\n        } else {\n            content.style.maxHeight = content.scrollHeight + 'px'; // (in case it was 'none')\n\n            void content.offsetHeight;\n            content.style.maxHeight = '0px';\n            content.addEventListener('transitionend', function handler(e) {\n                if (e.propertyName === 'max-height' && !content.classList.contains('show')) {\n                    isToggling = false;\n                    content.removeEventListener('transitionend', handler);\n                }\n            });\n        }\n    });\n\n    wrapper.appendChild(toggle);\n    wrapper.appendChild(content);\n    return wrapper;\n}\n\nfunction renderHelpLink(item) {\n    if (item && item.type === 'link' && item.url) {\n        return `<a href=\"${item.url}\" target=\"_blank\" rel=\"noopener noreferrer\">${\n            item.text || item.url\n        }</a>`;\n    }\n    return '';\n}\n\nexport async function fetchConfig() {\n    try {\n        const res = await fetch('/api/config');\n        if (!res.ok) throw new Error('Failed to fetch config');\n        return await res.json();\n    } catch (err) {\n        console.error('Error loading config:', err);\n        return {};\n    }\n}\nexport async function fetchStats(location) {\n    if (!location)\n        return {\n            error: true,\n            file_count: 0,\n            size_bytes: 0,\n            files: [],\n        };\n    try {\n        const res = await fetch('/api/poster-search-stats', {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n            },\n            body: JSON.stringify({\n                location,\n            }),\n        });\n        if (!res.ok) {\n            return {\n                error: true,\n                file_count: 0,\n                size_bytes: 0,\n                files: [],\n            };\n        }\n        return await res.json();\n    } catch (err) {\n        return {\n            error: true,\n            file_count: 0,\n            size_bytes: 0,\n            files: [],\n        };\n    }\n}\n"
  },
  {
    "path": "web/static/js/index.js",
    "content": "import { fetchConfig } from './helper.js';\n\nfunction parseVersionString(ver) {\n    if (!ver) return {};\n    const parts = ver.trim().split('.');\n    if (parts.length < 4) return {};\n    const version = parts.slice(0, 3).join('.');\n    const branchAndBuild = parts[3];\n\n    const m = branchAndBuild.match(/^([a-zA-Z]+)(\\d+)$/);\n    let branch, build;\n    if (m) {\n        branch = m[1];\n        build = parseInt(m[2], 10);\n    } else {\n        branch = branchAndBuild.replace(/(\\d+)$/, '');\n        const buildMatch = branchAndBuild.match(/(\\d+)$/);\n        build = buildMatch ? parseInt(buildMatch[1], 10) : null;\n    }\n    return {\n        version,\n        branch,\n        build,\n        full: ver.trim(),\n    };\n}\n\nasync function getRemoteBuildCount(owner, repo, branch) {\n    const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits?sha=${branch}&per_page=1`;\n    try {\n        const response = await fetch(apiUrl);\n        if (!response.ok) return null;\n        const link = response.headers.get('Link');\n        if (!link) return 1; // If only one commit\n        const match = link.match(/&page=(\\d+)>; rel=\"last\"/);\n        if (match) return parseInt(match[1], 10);\n        return 1;\n    } catch {\n        return null;\n    }\n}\n\nasync function mainVersionCheck() {\n    const localVerStr = await fetch('/api/version')\n        .then((r) => r.text())\n        .catch(() => null);\n    const local = parseVersionString(localVerStr);\n    if (!local.version || !local.branch || local.build === null) {\n        document.getElementById('version').textContent = 'Version: ' + (localVerStr || 'unknown');\n        return;\n    }\n\n    const remoteVersion = await fetch(\n        `https://raw.githubusercontent.com/Drazzilb08/daps/${local.branch}/VERSION`\n    )\n        .then((r) => (r.ok ? r.text() : null))\n        .catch(() => null);\n\n    const remoteBuild = await getRemoteBuildCount('Drazzilb08', 'daps', local.branch);\n    let remoteFull = '';\n    let updateAvailable = false;\n    if (remoteVersion && remoteBuild !== null) {\n        remoteFull = `${remoteVersion.trim()}.${local.branch}${remoteBuild}`;\n        if (remoteVersion.trim() === local.version && remoteBuild > local.build) {\n            updateAvailable = true;\n        } else if (remoteVersion.trim() !== local.version) {\n            updateAvailable = true;\n        }\n    }\n    document.getElementById('version').textContent = 'Version: ' + local.full;\n    const badge = document.getElementById('update-badge');\n    if (updateAvailable) {\n        badge.style.display = '';\n        badge.title = ''; // Use custom tooltip\n        badge.onclick = () => window.open('https://github.com/Drazzilb08/daps/releases', '_blank');\n        document.getElementById('tooltip-current-version').innerText = local.full;\n        document.getElementById('tooltip-latest-version').innerText = remoteFull;\n    } else {\n        badge.style.display = 'none';\n    }\n}\n\nexport function setTheme() {\n    fetchConfig()\n        .then((config) => {\n            let theme =\n                config && config.main && typeof config.main.theme === 'string'\n                    ? config.main.theme.toLowerCase()\n                    : 'light';\n\n            function applySystemTheme() {\n                const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n                document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');\n                try {\n                    localStorage.setItem('theme', isDark ? 'dark' : 'light');\n                } catch {}\n            }\n\n            if (window._themeMediaListener) {\n                window\n                    .matchMedia('(prefers-color-scheme: dark)')\n                    .removeEventListener('change', window._themeMediaListener);\n                window._themeMediaListener = null;\n            }\n\n            if (theme === 'auto') {\n                applySystemTheme();\n                window._themeMediaListener = applySystemTheme;\n                window\n                    .matchMedia('(prefers-color-scheme: dark)')\n                    .addEventListener('change', window._themeMediaListener);\n            } else {\n                document.documentElement.setAttribute(\n                    'data-theme',\n                    theme === 'dark' ? 'dark' : 'light'\n                );\n                try {\n                    localStorage.setItem('theme', theme);\n                } catch {}\n            }\n        })\n        .catch((err) => {\n            console.error('Failed to fetch config:', err);\n            document.documentElement.setAttribute('data-theme', 'light');\n            try {\n                localStorage.setItem('theme', 'light');\n            } catch {}\n        });\n}\n\nfunction showSplashScreen() {\n    const viewFrame = document.getElementById('viewFrame');\n    if (!viewFrame) return;\n    viewFrame.innerHTML = `\n      <div class=\"splash-container\">\n        <canvas id=\"splash-particles\" style=\"display:none;\"></canvas>\n        <div class=\"splash-card\">\n          <div class=\"splash-icon\">🚀</div>\n          <h1 class=\"splash-title\">Welcome to DAPS</h1>\n          <p>Select one of the options above to get started.</p>\n        </div>\n      </div>\n    `;\n    viewFrame.classList.add('splash-mask', 'fade-in');\n\n    const canvas = document.getElementById('splash-particles');\n    if (canvas) animateSplashParticles(canvas);\n\n    const title = document.querySelector('.splash-title');\n    if (title) {\n        const text = title.textContent;\n        title.textContent = '';\n        let idx = 0;\n        const typer = setInterval(() => {\n            title.textContent += text[idx++];\n            if (idx === text.length) {\n                clearInterval(typer);\n                title.classList.add('splash-typing');\n            }\n        }, 75);\n    }\n\n    const icon = document.querySelector('.splash-icon');\n    if (icon) {\n        icon.classList.add('pulse');\n    }\n}\n\nfunction animateSplashParticles(canvas) {\n    canvas.style.display = 'block';\n    const ctx = canvas.getContext('2d');\n    function resizeCanvas() {\n        canvas.width = window.innerWidth;\n        canvas.height = window.innerHeight;\n    }\n    resizeCanvas();\n    window.addEventListener('resize', resizeCanvas);\n\n    const particles = Array.from({ length: 60 }, () => ({\n        x: Math.random() * canvas.width,\n        y: Math.random() * canvas.height,\n        r: Math.random() * 2 + 1,\n        dx: (Math.random() - 0.5) * 0.5,\n        dy: (Math.random() - 0.5) * 0.5,\n    }));\n\n    function animateParticlesFrame() {\n        ctx.clearRect(0, 0, canvas.width, canvas.height);\n        ctx.fillStyle = getComputedStyle(document.documentElement)\n            .getPropertyValue('--splash-particle-color')\n            .trim();\n        particles.forEach((p) => {\n            ctx.beginPath();\n            ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);\n            ctx.fill();\n            p.x += p.dx;\n            p.y += p.dy;\n            if (p.x < 0 || p.x > canvas.width) p.dx *= -1;\n            if (p.y < 0 || p.y > canvas.height) p.dy *= -1;\n        });\n        requestAnimationFrame(animateParticlesFrame);\n    }\n    animateParticlesFrame();\n}\n\nsetTheme();\nmainVersionCheck();\nshowSplashScreen();\n"
  },
  {
    "path": "web/static/js/instances.js",
    "content": "import { fetchConfig } from './helper.js';\nimport { buildInstancesPayload } from './payload.js';\n\nimport { DAPS } from './common.js';\nconst { bindSaveButton, showToast, humanize, markDirty } = DAPS;\n\nexport async function loadInstances() {\n    const config = await fetchConfig();\n    const instances = config.instances || {};\n    const form = document.getElementById('instancesForm');\n    if (!form) return;\n    form.innerHTML = '';\n    for (const [service, items] of Object.entries(instances)) {\n        const section = document.createElement('div');\n        section.className = 'category';\n        const h2 = document.createElement('h2');\n        h2.textContent = humanize(service);\n        section.appendChild(h2);\n        const listDiv = document.createElement('div');\n        for (const [name, settings] of Object.entries(items)) {\n            const entry = createEntry(service, name, settings);\n            listDiv.appendChild(entry);\n        }\n        const addBtn = document.createElement('button');\n        addBtn.type = 'button';\n        addBtn.className = 'instance-btn btn';\n        addBtn.textContent = `+ Add ${humanize(service)}`;\n        addBtn.addEventListener('click', () => {\n            const newEntry = createEntry(\n                service,\n                '',\n                {\n                    url: '',\n                    api: '',\n                },\n                true\n            );\n            listDiv.appendChild(newEntry);\n            setTimeout(() => newEntry.classList.add('show-card'), 10);\n        });\n        section.appendChild(listDiv);\n        section.appendChild(addBtn);\n        form.appendChild(section);\n    }\n\n    document.querySelectorAll('.card').forEach((el, i) => {\n        setTimeout(() => el.classList.add('show-card'), i * 80);\n    });\n\n    const saveBtn = document.getElementById('saveBtn');\n    bindSaveButton(saveBtn, buildInstancesPayload, 'instances');\n}\n\n/**\n * Creates a DOM element representing an instance entry for a given service.\n *\n * @param {string} service - The service name.\n * @param {string} name - The instance name.\n * @param {Object} settings - The instance settings containing url and api key.\n * @param {boolean} [isNew=false] - Whether the entry is a newly added one.\n * @returns {HTMLElement} The DOM element representing the instance entry.\n */\nfunction createEntry(service, name, settings, isNew = false) {\n    const card = document.createElement('div');\n    card.className = 'card';\n\n    const field = document.createElement('div');\n    field.className = 'field';\n\n    const nameLabel = document.createElement('label');\n    nameLabel.textContent = 'Name';\n    const urlLabel = document.createElement('label');\n    urlLabel.textContent = 'URL';\n    const apiLabel = document.createElement('label');\n    apiLabel.textContent = 'API Key';\n\n    field.appendChild(nameLabel); // col 1\n    field.appendChild(urlLabel); // col 2\n    field.appendChild(apiLabel); // col 3\n    field.appendChild(document.createElement('div')); // col 4 (empty)\n\n    const nameInput = document.createElement('input');\n    nameInput.type = 'text';\n    nameInput.name = `${service}__name`;\n    nameInput.value = name;\n    nameInput.required = true;\n    nameInput.placeholder = 'Instance Name';\n    nameInput.className = 'input';\n    field.appendChild(nameInput);\n\n    const urlInput = document.createElement('input');\n    urlInput.type = 'text';\n    urlInput.name = `${service}__url`;\n    urlInput.value = settings.url || '';\n    urlInput.placeholder = 'Instance URL';\n    urlInput.className = 'input';\n    field.appendChild(urlInput);\n\n    const apiWrap = document.createElement('div');\n    apiWrap.className = 'password-wrapper';\n    const apiInput = document.createElement('input');\n    apiInput.type = 'text';\n    apiInput.name = `${service}__api`;\n    apiInput.value = settings.api || '';\n    apiInput.className = 'input masked-input';\n    apiInput.autocomplete = 'off';\n    apiInput.placeholder = 'Paste API Key here';\n    const toggle = document.createElement('span');\n    toggle.className = 'toggle-password';\n    toggle.textContent = '👁️';\n    toggle.addEventListener('click', () => {\n        const masked = apiInput.classList.toggle('masked-input');\n        toggle.textContent = masked ? '👁️' : '🙈';\n    });\n    apiWrap.appendChild(apiInput);\n    apiWrap.appendChild(toggle);\n    field.appendChild(apiWrap);\n\n    const btnContainer = document.createElement('div');\n    btnContainer.className = 'btn-container';\n\n    const testBtn = document.createElement('button');\n    testBtn.type = 'button';\n    testBtn.textContent = 'Test';\n    testBtn.className = 'btn run-btn';\n    testBtn.addEventListener('click', async () => {\n        testBtn.classList.remove('btn--success', 'btn--cancel', 'error');\n        testBtn.textContent = 'Testing...';\n        testBtn.classList.add('running');\n        testBtn.disabled = true;\n        const res = await fetch('/api/test-instance', {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n                service,\n                name: nameInput.value.trim(),\n                url: urlInput.value.trim(),\n                api: apiInput.value.trim(),\n            }),\n        });\n        if (res.ok) {\n            showToast(`✅ ${nameInput.value.trim()} connection successful`, 'success');\n            testBtn.textContent = 'Success';\n            testBtn.classList.remove('running');\n            testBtn.classList.add('btn--success');\n        } else {\n            const err = await res.json();\n            showToast(\n                `❌ ${nameInput.value.trim()} test failed: ${err.error || res.statusText}`,\n                'error'\n            );\n            testBtn.textContent = 'Fail';\n            testBtn.classList.remove('running');\n            testBtn.classList.add('btn--cancel', 'error');\n        }\n        setTimeout(() => {\n            testBtn.textContent = 'Test';\n            testBtn.classList.remove('btn--success', 'btn--cancel', 'error', 'running');\n            testBtn.disabled = false;\n        }, 2500);\n    });\n    btnContainer.appendChild(testBtn);\n\n    const removeBtn = document.createElement('button');\n    removeBtn.type = 'button';\n    removeBtn.textContent = '✖';\n    removeBtn.className = 'btn btn--cancel remove-instance';\n    removeBtn.addEventListener('click', () => {\n        const instanceName = nameInput.value || '<unnamed>';\n        if (confirm(`Are you sure you want to remove instance \"${instanceName}\"?`)) {\n            markDirty();\n            card.classList.add('removing');\n            setTimeout(() => card.remove(), 350);\n        }\n    });\n    btnContainer.appendChild(removeBtn);\n\n    field.appendChild(btnContainer); // col 4, row 2\n\n    for (let i = 0; i < 4; ++i) field.appendChild(document.createElement('div'));\n\n    card.appendChild(field);\n\n    if (isNew) setTimeout(() => nameInput.focus(), 50);\n\n    [nameInput, urlInput, apiInput].forEach((input) =>\n        input.addEventListener('keydown', (e) => {\n            if (e.key === 'Enter') testBtn.click();\n        })\n    );\n\n    return card;\n}\n"
  },
  {
    "path": "web/static/js/logs.js",
    "content": "import { humanize } from './common.js';\nimport { moduleOrder } from './helper.js';\n\nlet term = null; // xterm.js instance\nlet fitAddon = null; // xterm-addon-fit instance\nlet currentFullLogText = '';\nlet lastWrittenLineCount = 0;\nlet lastRenderedFileKey = null;\n\nexport function buildLogControls() {\n    const controlsDiv = document.createElement('div');\n    controlsDiv.className = 'log-controls log-toolbar';\n\n    const moduleSelect = document.createElement('select');\n    moduleSelect.className = 'select module-select';\n    moduleSelect.innerHTML = `<option value=\"\">Select Module</option>`;\n\n    const logfileSelect = document.createElement('select');\n    logfileSelect.className = 'select logfile-select';\n    logfileSelect.disabled = true;\n    logfileSelect.innerHTML = `<option value=\"\">Select Log File</option>`;\n\n    const searchInput = document.createElement('input');\n    searchInput.type = 'text';\n    searchInput.className = 'input search-logs';\n    searchInput.placeholder = 'Search logs...';\n\n    const clearBtn = document.createElement('button');\n    clearBtn.className = 'clear-search btn';\n    clearBtn.textContent = 'Clear';\n\n    const downloadBtn = document.createElement('button');\n    downloadBtn.className = 'download-log btn';\n    downloadBtn.textContent = 'Download';\n\n    controlsDiv.appendChild(moduleSelect);\n    controlsDiv.appendChild(logfileSelect);\n    controlsDiv.appendChild(searchInput);\n    controlsDiv.appendChild(clearBtn);\n    controlsDiv.appendChild(downloadBtn);\n\n    return controlsDiv;\n}\n\nfunction ensureLogControls() {\n    const scrollContainer = document.getElementById('scroll-output-container');\n    if (!scrollContainer) return;\n\n    if (!document.getElementById('log-empty-msg')) {\n        const msg = document.createElement('div');\n        msg.id = 'log-empty-msg';\n        msg.textContent = 'No logs available.';\n        msg.style.display = 'none';\n        scrollContainer.appendChild(msg);\n    }\n\n    if (!document.getElementById('scroll-to-top')) {\n        const btn = document.createElement('button');\n        btn.id = 'scroll-to-top';\n        btn.className = 'scroll-to-top';\n        btn.innerHTML = '↑ Top';\n        btn.style.display = 'none';\n        btn.onclick = () => term && term.scrollToTop && term.scrollToTop();\n        scrollContainer.appendChild(btn);\n    }\n\n    if (!document.getElementById('scroll-to-bottom')) {\n        const btn = document.createElement('button');\n        btn.id = 'scroll-to-bottom';\n        btn.className = 'scroll-to-bottom';\n        btn.innerHTML = '↓ Bottom';\n        btn.style.display = 'none';\n        btn.onclick = () => term && term.scrollToBottom && term.scrollToBottom();\n        scrollContainer.appendChild(btn);\n    }\n}\n\nfunction escapeRegex(text) {\n    return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nconst ANSI_COLORS = {\n    RESET: '\\x1b[0m',\n    RED: '\\x1b[31m',\n    GREEN: '\\x1b[32m',\n    YELLOW: '\\x1b[33m',\n    BLUE: '\\x1b[34m',\n    MAGENTA: '\\x1b[35m',\n    CYAN: '\\x1b[36m',\n    WHITE: '\\x1b[37m',\n    BRIGHT_RED: '\\x1b[91m',\n    HIGHLIGHT: '\\x1b[7m',\n};\n\nfunction applyLogLevelAnsiColors(line) {\n    if (line.includes('CRITICAL')) return `${ANSI_COLORS.BRIGHT_RED}${line}${ANSI_COLORS.RESET}`;\n    else if (line.includes('ERROR')) return `${ANSI_COLORS.RED}${line}${ANSI_COLORS.RESET}`;\n    else if (line.includes('WARNING')) return `${ANSI_COLORS.YELLOW}${line}${ANSI_COLORS.RESET}`;\n    else if (line.includes('INFO')) return `${ANSI_COLORS.GREEN}${line}${ANSI_COLORS.RESET}`;\n    else if (line.includes('DEBUG')) return `${ANSI_COLORS.CYAN}${line}${ANSI_COLORS.RESET}`;\n    return line;\n}\n\nfunction renderToXTerm(text, options = {}) {\n    ensureLogControls();\n    if (!term) return;\n    if (!text || !text.trim()) {\n        term.clear();\n        return;\n    }\n    const { isFiltered = false, forceClear = false, fileKey = null } = options;\n    const lines = text.split('\\n');\n    if (isFiltered || forceClear || fileKey !== lastRenderedFileKey) {\n        term.clear();\n        lines.forEach((line) => {\n            let processedLine = line;\n            if (!isFiltered) {\n                processedLine = applyLogLevelAnsiColors(line);\n            }\n            term.writeln(processedLine);\n        });\n        lastWrittenLineCount = lines.length;\n        if (fileKey) lastRenderedFileKey = fileKey;\n    } else {\n        for (let i = lastWrittenLineCount; i < lines.length; i++) {\n            let processedLine = lines[i];\n            processedLine = applyLogLevelAnsiColors(processedLine);\n            term.writeln(processedLine);\n        }\n        lastWrittenLineCount = lines.length;\n    }\n    setTimeout(handleXTermScroll, 25);\n}\n\nfunction handleXTermScroll() {\n    if (!term) return;\n    const topBtn = document.getElementById('scroll-to-top');\n    const botBtn = document.getElementById('scroll-to-bottom');\n\n    const viewportY = term.buffer.active.viewportY;\n    const maxScroll = term.buffer.active.length - term.rows;\n    if (topBtn) topBtn.style.display = viewportY > 0 ? 'block' : 'none';\n    if (botBtn) botBtn.style.display = viewportY < maxScroll ? 'block' : 'none';\n}\n\nexport async function loadLogs() {\n    let currentModule = null;\n    let currentFile = null;\n    let activeLoadSessionId = Symbol();\n\n    if (term) {\n        term.dispose();\n        term = null;\n    }\n    if (fitAddon) {\n        fitAddon.dispose();\n        fitAddon = null;\n    }\n    if (window._activeLogsDestroy) window._activeLogsDestroy();\n\n    const containerIframe = document.querySelector('.container-iframe');\n    if (!containerIframe) return;\n\n    const oldControls = containerIframe.querySelector('.log-controls');\n    if (oldControls) oldControls.remove();\n\n    const controlsDiv = buildLogControls();\n    containerIframe.insertBefore(controlsDiv, containerIframe.firstChild);\n\n    document.body.classList.add('logs-open');\n    document.documentElement.classList.add('logs-open');\n\n    const moduleSelect = document.querySelector('.module-select');\n    const logfileSelect = document.querySelector('.logfile-select');\n    const searchInput = document.querySelector('.search-logs');\n    searchInput.placeholder = 'Filter logs (Ctrl/CMD+F)';\n    const clearBtn = document.querySelector('.clear-search');\n    const downloadBtn = document.querySelector('.download-log');\n    const logOutput = document.querySelector('.log-output');\n\n    if (!logOutput) return;\n    logOutput.innerHTML = '';\n\n    term = new Terminal({\n        cursorBlink: true,\n        convertEol: true,\n        wordWrap: false,\n        scrollback: 100000, // Show much more history\n        theme: { background: '#1e1e1e', foreground: '#d4d4d4' },\n    });\n    fitAddon = new FitAddon.FitAddon();\n    term.loadAddon(fitAddon);\n    term.open(logOutput);\n    try {\n        fitAddon.fit();\n    } catch (e) {\n        console.error('Error fitting terminal:', e);\n    }\n    const handleResize = () => {\n        if (fitAddon)\n            try {\n                fitAddon.fit();\n            } catch (e) {}\n    };\n    window.addEventListener('resize', handleResize);\n\n    let refreshInterval = null;\n    function setRefreshTask(callback, delay = 1000) {\n        if (refreshInterval) clearInterval(refreshInterval);\n        refreshInterval = setInterval(callback, delay);\n    }\n\n    let filterTimeout;\n\n    async function loadModules() {\n        const res = await fetch('/api/logs');\n        const data = await res.json();\n        const availableModules = Object.keys(data);\n        const orderedModules = (moduleOrder || []).filter((m) => availableModules.includes(m));\n        for (const module of orderedModules) {\n            const opt = document.createElement('option');\n            opt.value = module;\n            opt.textContent = humanize?.(module) || module;\n            moduleSelect.appendChild(opt);\n        }\n        const preselectedModule =\n            window._preselectedLogModule ||\n            new URLSearchParams(window.location.search).get('module');\n        window._preselectedLogModule = null;\n        if (preselectedModule) {\n            moduleSelect.value = preselectedModule;\n            loadLogFiles(preselectedModule);\n        }\n    }\n\n    async function loadLogFiles(moduleName) {\n        logfileSelect.innerHTML = '<option value=\"\">Select Log File</option>';\n        logfileSelect.disabled = true;\n        if (!moduleName) return;\n        const res = await fetch('/api/logs');\n        const data = await res.json();\n        const files = data[moduleName] || [];\n        let defaultLog = null;\n        for (const file of files) {\n            const opt = document.createElement('option');\n            opt.value = file;\n            opt.textContent = file;\n            logfileSelect.appendChild(opt);\n            if (file === `${moduleName}.log`) defaultLog = file;\n        }\n        logfileSelect.disabled = false;\n        if (defaultLog) {\n            logfileSelect.value = defaultLog;\n            loadLogContent(moduleName, defaultLog);\n            setRefreshTask(() => loadLogContent(moduleName, defaultLog));\n        }\n    }\n\n    async function loadLogContent(moduleName, fileName) {\n        const requestKey = `${moduleName}/${fileName}`;\n        currentModule = moduleName;\n        currentFile = fileName;\n        const sessionId = Symbol();\n        activeLoadSessionId = sessionId;\n        if (!moduleName || !fileName) {\n            currentFullLogText = '';\n            renderToXTerm('', { forceClear: true, fileKey: null });\n            return;\n        }\n\n        let spinner = null;\n        let spinnerTimeout = setTimeout(() => {\n            spinner = document.querySelector('.log-spinner');\n            if (!spinner) {\n                spinner = document.createElement('div');\n                spinner.className = 'log-spinner';\n                logOutput.appendChild(spinner);\n            }\n        }, 250); // Only show spinner if >250ms\n\n        try {\n            const res = await fetch(`/api/logs/${moduleName}/${fileName}`);\n            const text = await res.text();\n            clearTimeout(spinnerTimeout);\n            spinner = document.querySelector('.log-spinner');\n            if (spinner) spinner.remove();\n\n            if (\n                activeLoadSessionId !== sessionId ||\n                moduleName !== currentModule ||\n                fileName !== currentFile\n            ) {\n                return;\n            }\n            currentFullLogText = text;\n            const fileKeyForRender = `${moduleName}/${fileName}`;\n            const searchValue = searchInput.value.trim();\n            if (searchValue) filterLogs();\n            else renderToXTerm(currentFullLogText, { fileKey: fileKeyForRender });\n        } catch (e) {\n            clearTimeout(spinnerTimeout);\n            spinner = document.querySelector('.log-spinner');\n            if (spinner) spinner.remove();\n            throw e;\n        }\n    }\n\n    function filterLogs() {\n        if (!term) return;\n        const search = searchInput.value.toLowerCase();\n        if (!search) {\n            renderToXTerm(currentFullLogText, { forceClear: true, fileKey: lastRenderedFileKey });\n            return;\n        }\n        const searchRegex = new RegExp(`(${escapeRegex(search)})`, 'gi');\n        const filteredLines = currentFullLogText\n            .split('\\n')\n            .filter((line) => line.toLowerCase().includes(search));\n        const highlightedAndColoredLines = filteredLines.map((line) => {\n            let processedLine = applyLogLevelAnsiColors(line);\n            return processedLine.replace(\n                searchRegex,\n                (match) => `${ANSI_COLORS.HIGHLIGHT}${match}${ANSI_COLORS.RESET}`\n            );\n        });\n        renderToXTerm(highlightedAndColoredLines.join('\\n'), {\n            isFiltered: true,\n            fileKey: lastRenderedFileKey,\n        });\n    }\n\n    moduleSelect.addEventListener('change', (e) => {\n        lastWrittenLineCount = 0;\n        lastRenderedFileKey = null;\n        if (refreshInterval) clearInterval(refreshInterval);\n        refreshInterval = null;\n        loadLogFiles(e.target.value);\n    });\n\n    logfileSelect.addEventListener('change', (e) => {\n        lastWrittenLineCount = 0;\n        lastRenderedFileKey = null;\n        if (refreshInterval) clearInterval(refreshInterval);\n        refreshInterval = null;\n        const selectedFile = e.target.value;\n        loadLogContent(moduleSelect.value, selectedFile);\n        setRefreshTask(() => loadLogContent(moduleSelect.value, selectedFile));\n    });\n\n    searchInput.addEventListener('input', () => {\n        clearTimeout(filterTimeout);\n        filterTimeout = setTimeout(() => {\n            filterLogs();\n        }, 150);\n    });\n\n    clearBtn.addEventListener('click', () => {\n        searchInput.value = '';\n        filterLogs();\n    });\n\n    document.addEventListener('keydown', function (e) {\n        if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {\n            e.preventDefault();\n            searchInput.focus();\n            searchInput.select();\n        }\n    });\n\n    downloadBtn.addEventListener('click', () => {\n        if (!moduleSelect.value || !logfileSelect.value) return;\n        const link = document.createElement('a');\n        link.href = `/api/logs/${moduleSelect.value}/${logfileSelect.value}`;\n        link.download = logfileSelect.value;\n        document.body.appendChild(link);\n        link.click();\n        document.body.removeChild(link);\n    });\n\n    window.addEventListener('popstate', () => {\n        if (refreshInterval) clearInterval(refreshInterval);\n        refreshInterval = null;\n    });\n    window.addEventListener('beforeunload', () => {\n        if (refreshInterval) clearInterval(refreshInterval);\n        refreshInterval = null;\n    });\n\n    window._activeLogsDestroy = () => {\n        if (refreshInterval) clearInterval(refreshInterval);\n        refreshInterval = null;\n        activeLoadSessionId = null;\n        window.removeEventListener('resize', handleResize);\n        if (term) {\n            term.dispose();\n            term = null;\n        }\n        if (fitAddon) {\n            fitAddon.dispose();\n            fitAddon = null;\n        }\n        const scrollContainer = document.querySelector('.scroll-output-container');\n        if (scrollContainer) {\n            const classListToRemove = ['scroll-to-top', 'scroll-to-bottom', 'log-empty-msg'];\n            for (const className of classListToRemove) {\n                const el = scrollContainer.querySelector(`.${className}`);\n                if (el && el.parentNode === scrollContainer) {\n                    scrollContainer.removeChild(el);\n                }\n            }\n        }\n    };\n\n    if (term && typeof term.onScroll === 'function') {\n        term.onScroll(handleXTermScroll);\n    }\n    setTimeout(handleXTermScroll, 250);\n    loadModules();\n}\n"
  },
  {
    "path": "web/static/js/main.js",
    "content": "// Core system scripts\nimport './payload.js';\nimport './navigation.js';\nimport './common.js';\nimport './helper.js';\n\n// Pages\nimport './index.js';\nimport './schedule.js';\nimport './instances.js';\nimport './notifications.js';\nimport './poster_search.js';\nimport './settings.js';\nimport './logs.js';\n"
  },
  {
    "path": "web/static/js/navigation.js",
    "content": "import { loadSchedule } from './schedule.js';\nimport { loadInstances } from './instances.js';\nimport { loadLogs } from './logs.js';\nimport { loadNotifications } from './notifications.js';\nimport { loadSettings } from './settings.js';\nimport { initPosterSearch } from './poster_search.js';\nimport { moduleOrder } from './helper.js';\nimport { DAPS, humanize, showUnsavedModal } from './common.js';\n\nexport const PAGE_LOADERS = {\n    schedule: loadSchedule,\n    instances: loadInstances,\n    logs: loadLogs,\n    notifications: loadNotifications,\n    settings: loadSettings,\n    poster_search: initPosterSearch,\n};\n\nconst EDITABLE_PAGES = [\n    '/pages/settings',\n    '/pages/instances',\n    '/pages/schedule',\n    '/pages/notifications',\n];\n\nfunction isEditablePage(currentUrl) {\n    return EDITABLE_PAGES.some((page) => currentUrl && currentUrl.includes(page));\n}\n\nfunction highlightNav(frag, url) {\n    document\n        .querySelectorAll('.menu a, .dropdown-toggle, .dropdown-menu li a, .dropdown')\n        .forEach((el) => {\n            el.classList.remove('active');\n        });\n\n    if (!frag || frag === 'index' || !PAGE_LOADERS.hasOwnProperty(frag)) {\n        return;\n    }\n\n    const linkIdMap = {\n        schedule: 'link-schedule',\n        instances: 'link-instances',\n        notifications: 'link-notifications',\n        logs: 'link-logs',\n        poster_search: 'link-poster-search',\n    };\n\n    if (frag in linkIdMap) {\n        document.getElementById(linkIdMap[frag])?.classList.add('active');\n    }\n\n    if (frag === 'settings') {\n        const dropdown = document.querySelector('.dropdown');\n        dropdown?.classList.add('active');\n        const settingsToggle = document.querySelector('.dropdown-toggle');\n        settingsToggle?.classList.add('active');\n\n        const moduleParam = new URL(url, window.location.origin).searchParams.get('module_name');\n        if (moduleParam) {\n            const moduleLink = document.querySelector(\n                `#settings-dropdown li a[href*=\"module_name=${moduleParam}\"]`\n            );\n            moduleLink?.classList.add('active');\n        }\n    }\n}\n\nexport async function navigateTo(link) {\n    document.querySelectorAll('.dropdown').forEach((d) => d.classList.remove('open'));\n\n    const viewFrame = document.getElementById('viewFrame');\n    if (!viewFrame) return;\n    viewFrame.classList.remove('fade-in');\n    viewFrame.classList.add('fade-out');\n    viewFrame.classList.remove('splash-mask');\n    let url = typeof link === 'string' ? link : link.href;\n\n    let frag = '';\n    if (/\\/pages\\/([a-zA-Z0-9_\\-]+)/.test(url)) {\n        frag = url.match(/\\/pages\\/([a-zA-Z0-9_\\-]+)/)[1];\n    } else if (/\\/([a-zA-Z0-9_\\-]+)$/.test(url)) {\n        frag = url.match(/\\/([a-zA-Z0-9_\\-]+)$/)[1];\n    }\n    frag = frag.replace(/-/g, '_').replace(/\\.html$/, '');\n\n    if (viewFrame) viewFrame.dataset.currentUrl = url;\n    window.currentFragmentUrl = url;\n\n    highlightNav(frag, url);\n\n    try {\n        const response = await fetch(url);\n        const html = await response.text();\n        const parser = new DOMParser();\n        const doc = parser.parseFromString(html, 'text/html');\n        let bodyContent = doc.body ? doc.body.innerHTML : html;\n        bodyContent = bodyContent.replace(/<script[^>]*>/g, '').replace(/<\\/script>/g, '');\n\n        setTimeout(async () => {\n            viewFrame.innerHTML = bodyContent;\n            document.body.classList.remove('logs-open');\n            viewFrame.classList.remove('fade-out');\n            viewFrame.classList.add('fade-in');\n\n            if (PAGE_LOADERS[frag]) {\n                if (frag === 'settings') {\n                    const params = new URLSearchParams(url.split('?')[1] || '');\n                    const moduleName = params.get('module_name');\n                    await PAGE_LOADERS[frag](moduleName);\n                } else {\n                    await PAGE_LOADERS[frag]();\n                }\n            }\n\n            setupDropdownMenus();\n        }, 200);\n    } catch (err) {\n        if (typeof DAPS?.showToast === 'function') DAPS.showToast('Failed to load page', 'error');\n        console.error(err);\n    }\n}\n\nasync function populateSettingsDropdown() {\n    const res = await fetch('/api/config');\n    const config = await res.json();\n    const dropdown = document.getElementById('settings-dropdown');\n    if (!dropdown) return;\n    dropdown.innerHTML = '';\n\n    let currentModule = null;\n    const url = window.currentFragmentUrl || '';\n    if (url.includes('/pages/settings')) {\n        const params = new URLSearchParams(url.split('?')[1] || '');\n        currentModule = params.get('module_name');\n    }\n\n    (moduleOrder || Object.keys(config))\n        .filter(\n            (key) =>\n                config.hasOwnProperty(key) &&\n                !Object.keys(PAGE_LOADERS).includes(key) &&\n                key !== 'discord'\n        )\n        .forEach((module) => {\n            const li = document.createElement('li');\n            const a = document.createElement('a');\n            a.href = `/pages/settings?module_name=${module}`;\n            a.textContent = humanize(module);\n            if (currentModule && module === currentModule) {\n                a.classList.add('active');\n            }\n            li.appendChild(a);\n            dropdown.appendChild(li);\n        });\n}\n\ndocument.addEventListener('change', function (e) {\n    const viewFrame = document.getElementById('viewFrame');\n    const currentUrl = viewFrame?.dataset?.currentUrl || window.currentFragmentUrl || '';\n    const target = e.target;\n    if (\n        isEditablePage(currentUrl) &&\n        target &&\n        target.matches('input, select, textarea') &&\n        target.id !== 'schedule-search' &&\n        target.id !== 'notifications-search'\n    ) {\n        DAPS.markDirty();\n    }\n});\nwindow.addEventListener('beforeunload', function (e) {\n    if (DAPS.isDirty) {\n        e.preventDefault();\n        e.returnValue = '';\n    }\n});\ndocument.addEventListener('click', async function (e) {\n    let skip = false;\n    if (DAPS.skipDirtyCheck) {\n        skip = true;\n        DAPS.skipDirtyCheck = false;\n    }\n    let el = e.target;\n    while (el && el.nodeType !== 1) el = el.parentNode;\n    if (!el) return;\n    const anchor = el.closest('a');\n    if (!anchor || !anchor.href) return;\n    const hrefUrl = new URL(anchor.href, window.location.origin);\n    if (hrefUrl.origin !== window.location.origin) return;\n    if (\n        anchor.target === '_blank' ||\n        anchor.href.startsWith('mailto:') ||\n        anchor.href.startsWith('javascript:')\n    )\n        return;\n\n    if (!hrefUrl.pathname.startsWith('/pages/')) return;\n\n    e.preventDefault();\n    let dirty = DAPS.isDirty;\n    const iframe = document.getElementById('viewFrame');\n    if (\n        iframe &&\n        iframe.contentWindow &&\n        iframe.contentWindow.DAPS &&\n        iframe.contentWindow.DAPS.isDirty\n    ) {\n        dirty = true;\n    }\n    let choice = null;\n    if (!skip && dirty) {\n        choice = await showUnsavedModal();\n    }\n    if (!dirty || choice === 'save' || skip) {\n        await navigateTo(anchor);\n    } else if (choice === 'discard') {\n        DAPS.isDirty = false;\n        if (iframe && iframe.contentWindow && iframe.contentWindow.DAPS) {\n            iframe.contentWindow.DAPS.isDirty = false;\n        }\n        await navigateTo(anchor);\n    }\n});\n\nfunction setupDropdownMenus() {\n    document.querySelectorAll('.dropdown').forEach((dropdown) => {\n        const oldToggle = dropdown.querySelector('.dropdown-toggle');\n        const oldMenu = dropdown.querySelector('.dropdown-menu');\n        if (!oldToggle || !oldMenu) return;\n\n        const toggle = oldToggle.cloneNode(true);\n        const menu = oldMenu.cloneNode(true);\n\n        oldToggle.replaceWith(toggle);\n        oldMenu.replaceWith(menu);\n\n        let closeTimeout = null;\n\n        toggle.addEventListener('mouseenter', () => {\n            clearTimeout(closeTimeout);\n            dropdown.classList.add('open');\n        });\n        toggle.addEventListener('click', (e) => {\n            e.preventDefault();\n            clearTimeout(closeTimeout);\n            dropdown.classList.toggle('open');\n        });\n\n        menu.addEventListener('mouseenter', () => {\n            clearTimeout(closeTimeout);\n        });\n\n        dropdown.addEventListener('mouseleave', () => {\n            closeTimeout = setTimeout(() => {\n                dropdown.classList.remove('open');\n            }, 500); // Adjust delay as needed\n        });\n\n        menu.querySelectorAll('a').forEach((link) => {\n            link.addEventListener('click', () => {\n                dropdown.classList.remove('open');\n            });\n        });\n    });\n}\n\ndocument.addEventListener('DOMContentLoaded', async () => {\n    await populateSettingsDropdown();\n    setupDropdownMenus();\n\n    let path = window.location.pathname;\n    let frag = '';\n    if (/\\/pages\\/([a-zA-Z0-9_\\-]+)/.test(path)) {\n        frag = path.match(/\\/pages\\/([a-zA-Z0-9_\\-]+)/)[1];\n    } else if (/\\/([a-zA-Z0-9_\\-]+)$/.test(path)) {\n        frag = path.match(/\\/([a-zA-Z0-9_\\-]+)$/)[1];\n    }\n    frag = frag.replace(/-/g, '_').replace(/\\.html$/, '');\n    highlightNav(frag, path);\n\n    document.addEventListener('click', (e) => {\n        if (!e.target.closest('.dropdown')) {\n            document.querySelectorAll('.dropdown').forEach((d) => d.classList.remove('open'));\n        }\n    });\n\n    document.querySelectorAll('nav .menu a, .dropdown-toggle').forEach((link) => {\n        link.addEventListener('keydown', (e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n                e.preventDefault();\n                link.click();\n            }\n        });\n    });\n});\n\nexport { populateSettingsDropdown };\n"
  },
  {
    "path": "web/static/js/notifications.js",
    "content": "import {\n    fetchConfig,\n    NOTIFICATION_LIST,\n    NOTIFICATION_DEFINITIONS,\n    NOTIFICATION_TYPES_PER_MODULE,\n} from './helper.js';\nimport { buildNotificationPayload } from './payload.js';\nimport { DAPS } from './common.js';\nconst { bindSaveButton, showToast } = DAPS;\n\nexport async function loadNotifications() {\n    const form = document.getElementById('notificationsForm');\n    if (!form) return;\n    const config = await fetchConfig();\n    const notifications = config.notifications || {};\n    const modules = Array.isArray(NOTIFICATION_LIST)\n        ? NOTIFICATION_LIST\n        : Object.keys(notifications);\n    const DEFINITIONS = NOTIFICATION_DEFINITIONS || {};\n    const notifyTypes = Object.keys(DEFINITIONS);\n    const allowedTypesMap = NOTIFICATION_TYPES_PER_MODULE || {};\n\n    form.innerHTML = '';\n    let cardIndex = 0;\n    for (const module of modules) {\n        const moduleSettings = notifications[module] || {};\n        const enabledTypes = Object.keys(moduleSettings);\n\n        const card = document.createElement('div');\n        card.className = 'card';\n\n        const header = document.createElement('div');\n        header.className = 'card-header';\n        header.textContent = module.replace(/_/g, ' ').replace(/\\b\\w/g, (c) => c.toUpperCase());\n        card.appendChild(header);\n\n        const moduleAllowedTypes = allowedTypesMap[module] || notifyTypes;\n        for (const type of moduleAllowedTypes) {\n            const def = DEFINITIONS[type];\n            if (!def || !def.fields) continue;\n\n            const isEnabled = enabledTypes.includes(type);\n            const notifyObj =\n                moduleSettings[type] && typeof moduleSettings[type] === 'object'\n                    ? moduleSettings[type]\n                    : {};\n\n            const fieldRow = document.createElement('div');\n            fieldRow.className = 'field toggle-row';\n\n            const toggleWrapper = document.createElement('label');\n            toggleWrapper.className = 'toggle-switch';\n            const input = document.createElement('input');\n            input.type = 'checkbox';\n            input.name = `${module}_${type}`;\n            input.checked = isEnabled;\n            const slider = document.createElement('span');\n            slider.className = 'slider';\n            toggleWrapper.appendChild(input);\n            toggleWrapper.appendChild(slider);\n\n            const typeLabel = document.createElement('span');\n            typeLabel.textContent = def.label;\n            typeLabel.className = 'toggle-label';\n\n            const flexSpacer = document.createElement('div');\n            flexSpacer.className = 'flex-spacer';\n\n            const testBtn = document.createElement('button');\n            testBtn.type = 'button';\n            testBtn.textContent = 'Test';\n            testBtn.className = 'btn btn--test';\n            if (isEnabled) testBtn.classList.add('enabled');\n\n            fieldRow.appendChild(toggleWrapper);\n            fieldRow.appendChild(typeLabel);\n            fieldRow.appendChild(flexSpacer);\n            fieldRow.appendChild(testBtn);\n\n            const fieldset = document.createElement('div');\n            fieldset.className = 'field notification-fieldset';\n            if (isEnabled) {\n                fieldset.classList.add('expanded');\n                testBtn.classList.add('enabled');\n                fieldRow.classList.add('toggle-row--expanded');\n            }\n            fieldset.dataset.notifyType = type;\n\n            const legend = document.createElement('div');\n            legend.className = 'fieldset-legend';\n            legend.textContent = `${def.label} Settings`;\n            fieldset.appendChild(legend);\n\n            for (const fieldDef of def.fields) {\n                const fieldContainer = document.createElement('div');\n                fieldContainer.className = 'notification-field-container';\n                const fieldLabel = document.createElement('label');\n                fieldLabel.textContent = fieldDef.label;\n                fieldLabel.setAttribute('for', `${type}_${fieldDef.key}_${module}`);\n                fieldContainer.appendChild(fieldLabel);\n\n                let inputElement;\n                const isPassword = fieldDef.key.toLowerCase().includes('password');\n                if (fieldDef.type === 'checkbox') {\n                    const toggleWrap = document.createElement('label');\n                    toggleWrap.className = 'toggle-switch';\n                    inputElement = document.createElement('input');\n                    inputElement.type = 'checkbox';\n                    inputElement.className = 'toggle-input';\n                    inputElement.name = `${type}_${fieldDef.key}_${module}`;\n                    inputElement.required = fieldDef.required || false;\n                    inputElement.id = `${type}_${fieldDef.key}_${module}`;\n                    inputElement.checked = notifyObj[fieldDef.key] || false;\n                    const toggleSlider = document.createElement('span');\n                    toggleSlider.className = 'slider';\n                    toggleWrap.appendChild(inputElement);\n                    toggleWrap.appendChild(toggleSlider);\n                    fieldContainer.appendChild(toggleWrap);\n                } else if (fieldDef.type === 'textarea') {\n                    inputElement = document.createElement('textarea');\n                    inputElement.name = `${type}_${fieldDef.key}_${module}`;\n                    inputElement.className = 'input textarea-input';\n                    inputElement.required = fieldDef.required || false;\n                    inputElement.id = `${type}_${fieldDef.key}_${module}`;\n                    inputElement.rows = 1;\n                    if (fieldDef.placeholder) inputElement.placeholder = fieldDef.placeholder;\n                    if (notifyObj[fieldDef.key] !== undefined && notifyObj[fieldDef.key] !== null) {\n                        inputElement.value = Array.isArray(notifyObj[fieldDef.key])\n                            ? notifyObj[fieldDef.key].join(', ')\n                            : notifyObj[fieldDef.key];\n                    }\n\n                    function autoExpandTextarea(el) {\n                        el.style.height = 'auto';\n                        el.style.height = el.scrollHeight + 'px';\n                    }\n\n                    inputElement.addEventListener('input', () => autoExpandTextarea(inputElement));\n\n                    setTimeout(() => autoExpandTextarea(inputElement), 0);\n                    fieldContainer.appendChild(inputElement);\n                } else {\n                    inputElement = document.createElement('input');\n                    inputElement.type =\n                        fieldDef.type === 'password'\n                            ? 'password'\n                            : fieldDef.type === 'number'\n                            ? 'number'\n                            : 'text';\n                    inputElement.name = `${type}_${fieldDef.key}_${module}`;\n                    inputElement.className = 'input';\n                    inputElement.required = fieldDef.required || false;\n                    inputElement.id = `${type}_${fieldDef.key}_${module}`;\n                    if (fieldDef.placeholder) inputElement.placeholder = fieldDef.placeholder;\n                    if (notifyObj[fieldDef.key] !== undefined && notifyObj[fieldDef.key] !== null) {\n                        inputElement.value = notifyObj[fieldDef.key];\n                    }\n                }\n                if (isPassword && fieldDef.type !== 'checkbox') {\n                    const wrap = document.createElement('div');\n                    wrap.className = 'password-wrapper';\n                    wrap.style.position = 'relative';\n\n                    inputElement.type = 'password';\n\n                    const toggle = document.createElement('span');\n                    toggle.className = 'toggle-password';\n                    toggle.innerHTML = '👁️';\n                    toggle.style.cursor = 'pointer';\n                    toggle.addEventListener('click', () => {\n                        if (inputElement.type === 'password') {\n                            inputElement.type = 'text';\n                            toggle.textContent = '🙈';\n                        } else {\n                            inputElement.type = 'password';\n                            toggle.textContent = '👁️';\n                        }\n                    });\n                    wrap.appendChild(inputElement);\n                    wrap.appendChild(toggle);\n                    fieldContainer.appendChild(wrap);\n                } else if (fieldDef.type !== 'checkbox') {\n                    fieldContainer.appendChild(inputElement);\n                }\n                fieldset.appendChild(fieldContainer);\n            }\n\n            testBtn.addEventListener('click', async () => {\n                testBtn.classList.remove('btn--success', 'btn--cancel', 'running');\n                testBtn.textContent = 'Testing...';\n                testBtn.classList.add('running');\n                testBtn.disabled = true;\n\n                const missingFields = [];\n                for (const fieldDef of def.fields) {\n                    if (fieldDef.required) {\n                        const name = `${type}_${fieldDef.key}_${module}`;\n                        const inputEl = fieldset.querySelector(`[name=\"${name}\"]`);\n                        let value =\n                            inputEl?.type === 'checkbox' ? inputEl.checked : inputEl?.value?.trim();\n                        if (inputEl?.tagName === 'TEXTAREA')\n                            value = inputEl.value\n                                .split(/[\\n,]+/)\n                                .map((s) => s.trim())\n                                .filter(Boolean);\n                        if (!value || (Array.isArray(value) && value.length === 0)) {\n                            missingFields.push(fieldDef.label);\n                        }\n                    }\n                }\n                if (missingFields.length > 0) {\n                    showToast(\n                        '❌ Required fields missing:\\n' +\n                            missingFields.map((f) => `• ${f}`).join('\\n'),\n                        'error',\n                        6000\n                    );\n                    resetTestButton();\n                    return;\n                }\n\n                const notifyObj = {};\n                def.fields.forEach((fieldDef) => {\n                    const name = `${type}_${fieldDef.key}_${module}`;\n                    const input = fieldset.querySelector(`[name=\"${name}\"]`);\n                    if (!input) return;\n                    let val;\n                    if (input.type === 'checkbox') val = input.checked;\n                    else if (input.tagName === 'TEXTAREA')\n                        val = input.value\n                            .split(/[\\n,]+/)\n                            .map((s) => s.trim())\n                            .filter(Boolean);\n                    else if (input.type === 'number') val = Number(input.value);\n                    else val = input.value;\n                    notifyObj[fieldDef.key] = val;\n                });\n\n                const payload = {\n                    module,\n                    notifications: { [type]: notifyObj },\n                };\n\n                const res = await fetch('/api/test-notification', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                });\n                let result;\n                try {\n                    result = await res.json();\n                } catch {\n                    result = null;\n                }\n\n                if (res.ok && result && typeof result === 'object' && 'result' in result) {\n                    if (result.result) {\n                        showToast(\n                            `✅ ${module} (${type}) test notification: ${\n                                result.message || 'Success'\n                            }`,\n                            'success'\n                        );\n                        testBtn.textContent = 'Success';\n                        testBtn.classList.remove('running');\n                        testBtn.classList.add('btn--success');\n                    } else {\n                        showToast(\n                            `❌ ${module} (${type}) test notification: ${\n                                result.message || 'Failed'\n                            }`,\n                            'error',\n                            6000\n                        );\n                        testBtn.textContent = 'Fail';\n                        testBtn.classList.remove('running');\n                        testBtn.classList.add('btn--cancel');\n                    }\n                } else {\n                    showToast(\n                        `❌ ${module} (${type}) test notification: Unexpected response`,\n                        'error',\n                        6000\n                    );\n                    testBtn.textContent = 'Fail';\n                    testBtn.classList.remove('running');\n                    testBtn.classList.add('btn--cancel');\n                }\n                setTimeout(() => {\n                    testBtn.textContent = 'Test';\n                    testBtn.classList.remove('btn--success', 'btn--cancel', 'running');\n                    testBtn.disabled = false;\n                }, 1200);\n            });\n\n            function resetTestButton() {\n                testBtn.textContent = 'Test';\n                testBtn.classList.remove('btn--success', 'btn--cancel', 'running');\n                testBtn.disabled = false;\n            }\n\n            input.addEventListener('change', () => {\n                if (input.checked) {\n                    fieldset.classList.add('expanded');\n                    testBtn.classList.add('enabled');\n                } else {\n                    fieldset.classList.remove('expanded');\n                    testBtn.classList.remove('enabled');\n                }\n            });\n            input.addEventListener('change', () => {\n                if (input.checked) {\n                    fieldRow.classList.add('toggle-row--expanded');\n                    fieldset.classList.add('expanded');\n                    testBtn.classList.add('enabled');\n                } else {\n                    fieldRow.classList.remove('toggle-row--expanded');\n                    fieldset.classList.remove('expanded');\n                    testBtn.classList.remove('enabled');\n                }\n            });\n\n            card.appendChild(fieldRow);\n            card.appendChild(fieldset);\n        }\n\n        form.appendChild(card);\n        setTimeout(() => card.classList.add('show-card'), 40 * cardIndex);\n        cardIndex++;\n    }\n\n    const searchInput = document.getElementById('notifications-search');\n    if (searchInput) {\n        searchInput.addEventListener('input', (e) => {\n            window.skipDirtyCheck = true;\n            searchInput.defaultValue = searchInput.value;\n            const query = e.target.value.toLowerCase();\n            document.querySelectorAll('.card').forEach((card) => {\n                let text = '';\n                const header = card.querySelector('.card-header');\n                if (header) text += header.textContent + ' ';\n                card.querySelectorAll('.fieldset-legend').forEach((leg) => {\n                    text += leg.textContent + ' ';\n                });\n                card.querySelectorAll('input, textarea').forEach((input) => {\n                    if (\n                        input.tagName === 'TEXTAREA' ||\n                        input.type === 'text' ||\n                        input.type === 'number'\n                    ) {\n                        text += input.value + ' ';\n                    } else if (input.type === 'checkbox') {\n                        text += (input.checked ? 'true' : 'false') + ' ';\n                    }\n                });\n                text = text.toLowerCase().trim();\n                card.style.display = query === '' || text.includes(query) ? 'flex' : 'none';\n            });\n        });\n    }\n\n    const saveBtn = document.getElementById('saveBtn');\n    bindSaveButton(saveBtn, buildNotificationPayload, 'notifications');\n}\n"
  },
  {
    "path": "web/static/js/payload.js",
    "content": "import { BOOL_FIELDS, INT_FIELDS, TEXTAREA_FIELDS, JSON_FIELDS } from './settings/constants.js';\nimport { NOTIFICATION_DEFINITIONS } from './helper.js';\nimport { getBorderReplacerrData } from './settings/modules/border_replacerr.js';\nimport { getLabelarrData } from './settings/modules/labelarr.js';\nimport { getGdriveSyncData } from './settings/modules/sync_gdrive.js';\nimport { getUpgradinatorrData } from './settings/modules/upgradinatorr.js';\n\nexport async function buildNotificationPayload() {\n    const form = document.getElementById('notificationsForm');\n    if (!form) return null;\n    const DEFINITIONS = NOTIFICATION_DEFINITIONS || {};\n    const result = {};\n    const missing = [];\n\n    form.querySelectorAll('.card').forEach((card) => {\n        const module = card\n            .querySelector('.card-header')\n            ?.textContent?.toLowerCase()\n            .replace(/\\s+/g, '_');\n        if (!module) return;\n        const moduleObj = {};\n        const toggles = Array.from(card.querySelectorAll('.toggle-switch input'));\n        toggles.forEach((toggle) => {\n            const m = toggle.name.match(new RegExp(`^${module}_(.+)$`));\n            if (!m) return;\n            const type = m[1],\n                def = DEFINITIONS[type],\n                fields = {};\n            if (def?.fields && toggle.checked) {\n                def.fields.forEach((fd) => {\n                    const input = form.querySelector(`[name=\"${type}_${fd.key}_${module}\"]`);\n                    if (!input) return;\n                    let val =\n                        input.type === 'checkbox'\n                            ? input.checked\n                            : input.tagName === 'TEXTAREA'\n                            ? input.value\n                                  .split(/[\\n,]+/)\n                                  .map((s) => s.trim())\n                                  .filter(Boolean)\n                            : input.type === 'number'\n                            ? Number(input.value)\n                            : input.value.trim();\n                    if (fd.required && (val === '' || (Array.isArray(val) && !val.length))) {\n                        missing.push(`${module}: ${type} – ${fd.label}`);\n                    }\n                    if (fd.key === 'channel_id' && (isNaN(val) || !Number.isInteger(Number(val)))) {\n                        missing.push(`${module}: ${type} – ${fd.label} must be integer`);\n                    }\n                    fields[fd.key] = val;\n                });\n                moduleObj[type] = fields;\n            } else if (toggle.checked) {\n                moduleObj[type] = {};\n            }\n        });\n        result[module] = moduleObj;\n    });\n    if (missing.length) return null;\n    return {\n        notifications: result,\n    };\n}\n\nexport async function buildSchedulePayload() {\n    const form = document.getElementById('scheduleForm');\n    if (!form) return null;\n    const data = new FormData(form),\n        out = {};\n    for (const [k, v] of data.entries()) {\n        out[k] = v.trim() || null;\n    }\n    return {\n        schedule: out,\n    };\n}\n\nexport async function buildInstancesPayload() {\n    const form = document.getElementById('instancesForm');\n    if (!form) return null;\n    const out = {};\n\n    form.querySelectorAll('.category').forEach((sec) => {\n        const svc = sec.querySelector('h2')?.textContent.toLowerCase().replace(/ /g, '_');\n        out[svc] = {};\n\n        sec.querySelectorAll('.card').forEach((card) => {\n            const field = card.querySelector('.field');\n            if (!field) return;\n            const name = field.querySelector('input[name$=\"__name\"]')?.value.trim();\n            const url = field.querySelector('input[name$=\"__url\"]')?.value.trim();\n            const api = field.querySelector('input[name$=\"__api\"]')?.value.trim();\n            if (name)\n                out[svc][name] = {\n                    url,\n                    api,\n                };\n        });\n    });\n\n    if (!Object.values(out).some((o) => Object.keys(o).length)) return null;\n    return {\n        instances: out,\n    };\n}\n\nexport async function buildSettingsPayload(moduleName) {\n    function fillPayloadFromFormData(data, payload, excludeKeys = []) {\n        for (const [key, val] of data.entries()) {\n            if (excludeKeys.includes(key)) continue;\n            if (BOOL_FIELDS.includes(key)) {\n                payload[key] = val === 'true';\n            } else if (INT_FIELDS.includes(key)) {\n                payload[key] = parseInt(val, 10) || 0;\n            } else if (TEXTAREA_FIELDS.includes(key)) {\n                payload[key] = val\n                    .split('\\n')\n                    .map((s) => s.trim())\n                    .filter(Boolean);\n            } else if (JSON_FIELDS.includes(key)) {\n                try {\n                    payload[key] = JSON.parse(val);\n                } catch {\n                    payload[key] = val;\n                }\n            } else {\n                payload[key] = val;\n            }\n        }\n    }\n\n    function normalizeJsonStringKeysAndValues(jsonStr) {\n        try {\n            const parsed = JSON.parse(jsonStr);\n            return JSON.stringify(parsed);\n        } catch {\n            let normalized = jsonStr.replace(/:\\s*'([^']*)'/g, ': \"$1\"');\n\n            normalized = normalized.replace(/([{,]\\s*)([a-zA-Z0-9_]+)(\\s*:)/g, '$1\"$2\"$3');\n\n            normalized = normalized.replace(/:\\s*([^\"{\\[\\]\\s,]+)(?=\\s*[,}])/g, (match, val) => {\n                const trimmed = val.trim();\n                if (\n                    /^\".*\"$/.test(trimmed) || // already double-quoted\n                    /^[\\d.eE+-]+$/.test(trimmed) || // number\n                    /^(true|false|null)$/.test(trimmed) // bool/null\n                ) {\n                    return match;\n                }\n                return `: \"${trimmed}\"`;\n            });\n            return normalized;\n        }\n    }\n    const form = document.getElementById('settingsForm');\n    if (!form) return null;\n    const data = new FormData(form);\n    const payload = {};\n    const excludeKeys = [];\n    if (moduleName === 'nohl') {\n        excludeKeys.push('mode', 'source_dirs');\n    }\n    if (moduleName === 'sync_gdrive') {\n        try {\n            const raw = data.get('token') || '{}';\n            const fixed = normalizeJsonStringKeysAndValues(raw);\n            payload.token = JSON.parse(fixed);\n        } catch {\n            alert('Invalid token JSON');\n            return null;\n        }\n        payload.gdrive_list = (getGdriveSyncData() || []).filter(\n            (e) => e && Object.keys(e).length > 0\n        );\n        excludeKeys.push('token', 'gdrive_list');\n    }\n    if (moduleName === 'labelarr') {\n        payload.mappings = getLabelarrData() || [];\n    }\n    if (moduleName === 'upgradinatorr') {\n        payload.instances_list = getUpgradinatorrData();\n    }\n    if (moduleName === 'border_replacerr') {\n        const holidayArray = getBorderReplacerrData() || [];\n        const holidaysObj = {};\n        holidayArray.forEach((entry) => {\n            holidaysObj[entry.holiday] = {\n                schedule: entry.schedule,\n                color: entry.color,\n            };\n        });\n        const globalColorContainer = document.querySelector('#border-colors-container');\n        const globalColorInputs = Array.from(globalColorContainer.children || [])\n            .filter(\n                (el) => el.classList.contains('subfield') && el.querySelector('input[type=\"color\"]')\n            )\n            .flatMap((el) => Array.from(el.querySelectorAll('input[type=\"color\"]')));\n        payload.border_colors = globalColorInputs\n            .map((i) => i.value)\n            .filter((val, idx, arr) => arr.indexOf(val) === idx); // remove duplicates\n        payload.holidays = holidaysObj;\n    }\n    if (moduleName === 'nohl') {\n        // Always output source_dirs as array of {path, mode} for nohl, matching fields order\n        const sourceFields = form.querySelectorAll('.subfield-list .subfield');\n        if (sourceFields.length > 0) {\n            payload.source_dirs = Array.from(sourceFields)\n                .map((sub) => {\n                    const pathInput = sub.querySelector('input[name=\"source_dirs\"]');\n                    const select = sub.querySelector('select[name=\"mode\"]');\n                    const path = pathInput ? pathInput.value.trim() : '';\n                    // Always default mode to 'scan' if not set\n                    const mode = select && select.value ? select.value : 'scan';\n                    if (!path) return null;\n                    return { path, mode };\n                })\n                .filter(Boolean);\n        } else {\n            payload.source_dirs = [];\n        }\n    } else if (moduleName === 'jduparr') {\n        const sourceFields = form.querySelectorAll('.subfield-list .subfield');\n        if (sourceFields.length > 0) {\n            payload.source_dirs = Array.from(sourceFields)\n                .map((sub) => sub.querySelector('input[name=\"source_dirs\"]')?.value.trim())\n                .filter(Boolean);\n        }\n    }\n    const scalarInstances = data.getAll('instances');\n    const nestedInstances = {};\n    for (const [key, val] of data.entries()) {\n        const match = key.match(/^instances\\.(.+?)\\.library_names$/);\n        if (match) {\n            const inst = match[1];\n            nestedInstances[inst] = nestedInstances[inst] || {\n                library_names: [],\n            };\n            nestedInstances[inst].library_names.push(val);\n        }\n    }\n    const combinedInstances = [\n        ...scalarInstances,\n        ...Object.entries(nestedInstances).map(([k, v]) => ({\n            [k]: v,\n        })),\n    ];\n    excludeKeys.push(\n        'instances',\n        ...Array.from(data.keys ? data.keys() : []).filter((k) => k.startsWith('instances.'))\n    );\n    fillPayloadFromFormData(data, payload, excludeKeys);\n\n    // For nohl, do not apply legacy fallback for source_dirs (handled above).\n    if (moduleName !== 'nohl' && data.has('source_dirs')) {\n        payload.source_dirs = data\n            .getAll('source_dirs')\n            .map((v) => v.trim())\n            .filter(Boolean);\n    }\n    if (combinedInstances.length > 0) {\n        payload.instances = combinedInstances;\n    }\n    return {\n        [moduleName]: payload,\n    };\n}\n"
  },
  {
    "path": "web/static/js/poster_search.js",
    "content": "import { fetchConfig } from './helper.js';\nimport { showToast } from './common.js';\n\nconst IDS = {\n    searchInput: 'poster-search-input',\n    searchResults: 'poster-search-results',\n    statsSpinner: 'poster-stats-spinner',\n    scopeToggle: 'search-scope-toggle',\n    scopeLabel: 'search-scope-label',\n};\n\nlet config = {};\nlet gdriveLocations = [];\nlet customLocations = [];\nlet gdriveFiles = [];\nlet customFiles = [];\nlet assetsDir = '';\nlet assetsFiles = [];\nlet gdriveStatsData = [];\nlet assetsStatsData = [];\nlet gdriveTotals = { files: 0, size: 0 };\nlet assetsTotals = { files: 0, size: 0 };\nlet gdriveSortMode = 'priority-desc';\nlet priorityMap = {};\n\nlet loaderStartTime = 0;\nfunction showLoaderModal(show = true) {\n    const container = document.querySelector('.container-iframe');\n    let loader = container.querySelector('.poster-search-loader-modal');\n    if (show) {\n        loaderStartTime = Date.now();\n        if (!loader) {\n            loader = document.createElement('div');\n            loader.className = 'poster-search-loader-modal';\n            loader.innerHTML = `\n              <div class=\"terminal-loader\">\n                <div class=\"terminal-header\">\n                  <div class=\"terminal-title\">Status</div>\n                  <div class=\"terminal-controls\">\n                    <div class=\"control close\"></div>\n                    <div class=\"control minimize\"></div>\n                    <div class=\"control maximize\"></div>\n                  </div>\n                </div>\n                <div class=\"text\">Loading Posters...</div>\n              </div>\n            `;\n            container.insertBefore(loader, container.firstChild);\n        }\n        loader.style.display = 'flex';\n    } else if (loader) {\n        const elapsed = Date.now() - loaderStartTime;\n        const delay = Math.max(0, 4000 - elapsed); // 4s min for 1 cycle\n        setTimeout(() => {\n            loader.style.display = 'none';\n        }, delay);\n    }\n}\n\nfunction formatBytes(bytes) {\n    if (bytes < 1024) return bytes + ' B';\n    let kb = bytes / 1024;\n    if (kb < 1024) return kb.toFixed(1) + ' KB';\n    let mb = kb / 1024;\n    if (mb < 1024) return mb.toFixed(1) + ' MB';\n    return (mb / 1024).toFixed(2) + ' GB';\n}\n\nfunction renderStatsTable(statsArr, totals, title) {\n    if (!statsArr.length) return '';\n    const columns = [\n        { key: 'name', label: 'Folder', isNumeric: false },\n        { key: 'file_count', label: 'Files', isNumeric: true },\n        { key: 'size_bytes', label: 'Size', isNumeric: true },\n        { key: 'percent', label: '% of Total', isNumeric: true },\n    ];\n\n    let arr = statsArr.map((s) => ({\n        ...s,\n        percent: totals.files ? (s.file_count / totals.files) * 100 : 0,\n    }));\n    let header = columns.map((col) => `<th>${col.label}</th>`).join('');\n    let rows = arr\n        .map((s) => {\n            let badge = s.isCustom ? ' <span class=\"gdrive-custom-badge\">(Custom)</span>' : '';\n            let folderCol = '';\n            if (s.notInSource) {\n                folderCol = `\n                <span class=\"gdrive-tooltip-wrapper\">\n                    <span class=\"gdrive-name gdrive-tooltip-red\" tabindex=\"0\">${s.name}</span>\n                    <span class=\"gdrive-tooltip-content\">\n                        This GDrive is <b>not present</b> in Poster Renamerr's Source Directories</span>\n                    </span>\n                </span>\n            `;\n            } else {\n                folderCol = `<span class=\"gdrive-name\">${s.name}</span>`;\n            }\n\n            const rowClass = s.error ? 'gdrive-row-error' : '';\n            return `<tr class=\"${rowClass}\">\n            <td>${folderCol}</td>\n            <td>${s.file_count || 0}</td>\n            <td>${formatBytes(s.size_bytes || 0)}</td>\n            <td>\n                <div class=\"stat-bar-bg\">\n                    <div class=\"stat-bar-inner\" style=\"width:${s.percent}%;\"></div>\n                </div>\n                <span class=\"stat-bar-percent\">${s.percent.toFixed(1)}%</span>\n            </td>\n        </tr>`;\n        })\n        .join('\\n');\n    return `\n        <div class=\"stats-title\">${title}</div>\n        <table class=\"stats-table\">\n            <thead>\n                <tr>${header}</tr>\n            </thead>\n            <tbody>${rows}</tbody>\n        </table>\n        <div class=\"stats-footer\">\n            <b>Total files:</b> ${totals.files} &nbsp; <b>Total size:</b> ${formatBytes(\n        totals.size\n    )}\n        </div>\n    `;\n}\n\nfunction sortGdriveStats(statsArr, mode, priorityMap = {}) {\n    if (!Array.isArray(statsArr)) return;\n    let compare = () => 0;\n    statsArr.forEach((s) => {\n        s.file_count =\n            typeof s.file_count === 'number' && !isNaN(s.file_count)\n                ? s.file_count\n                : Array.isArray(s.files)\n                ? s.files.length\n                : 0;\n    });\n    if (mode.startsWith('priority')) {\n        const asc = mode.endsWith('asc');\n\n        const inSource = statsArr.filter((s) => !s.notInSource);\n        const notInSource = statsArr.filter((s) => s.notInSource);\n        const compare = (a, b) => {\n            const ap = priorityMap[a.location] ?? 9999;\n            const bp = priorityMap[b.location] ?? 9999;\n            return asc ? ap - bp : bp - ap;\n        };\n        inSource.sort(compare);\n\n        notInSource.sort((a, b) => String(a.name).localeCompare(String(b.name)));\n\n        statsArr.splice(0, statsArr.length, ...inSource, ...notInSource);\n        return;\n    } else if (mode.startsWith('files')) {\n        const asc = mode.endsWith('asc');\n        compare = (a, b) => (asc ? a.file_count - b.file_count : b.file_count - a.file_count);\n    } else if (mode.startsWith('size')) {\n        const asc = mode.endsWith('asc');\n        compare = (a, b) => (asc ? a.size_bytes - b.size_bytes : b.size_bytes - a.size_bytes);\n    } else if (mode.startsWith('name')) {\n        const asc = mode.endsWith('asc');\n        compare = (a, b) =>\n            asc\n                ? String(a.name).localeCompare(String(b.name))\n                : String(b.name).localeCompare(String(a.name));\n    }\n    statsArr.sort(compare);\n}\n\nasync function fetchAndRenderStats() {\n    showSpinner(true);\n\n    if (!config || !Object.keys(config).length) config = await fetchConfig();\n\n    let gdriveLocations = (config.sync_gdrive?.gdrive_list || []).map((g) => ({\n        name: g.name,\n        location: g.location,\n    }));\n    let gdriveLocSet = new Set(gdriveLocations.map((g) => g.location));\n    let sourceDirs = config.poster_renamerr?.source_dirs || [];\n    let customDirs = sourceDirs.filter((dir) => !gdriveLocSet.has(dir));\n    let sourceDirSet = new Set(sourceDirs);\n    let assetsDir = config.poster_renamerr?.destination_dir || '';\n\n    let customStatsArr = [];\n    let gdriveStatsArr = [];\n\n    if (customDirs.length) {\n        let statsArr = await Promise.all(\n            customDirs.map(async (dir) => {\n                let res = await fetch('/api/poster-search-stats', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ location: dir }),\n                });\n                let stats = await res.json();\n                if (stats && !stats.error && typeof stats.file_count === 'number') {\n                    return {\n                        name: dir.split('/').pop(),\n                        location: dir,\n                        ...stats,\n                        isCustom: true,\n                    };\n                }\n                if (stats.error) {\n                    return {\n                        name: dir.split('/').pop(),\n                        location: dir,\n                        file_count: 0,\n                        size_bytes: 0,\n                        files: [],\n                        isCustom: true,\n                        error: true,\n                    };\n                }\n                return null;\n            })\n        );\n        customStatsArr = statsArr.filter(Boolean);\n    }\n\n    let gdriveStatRaw = await Promise.all(\n        gdriveLocations.map(async (l) => {\n            let res = await fetch('/api/poster-search-stats', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ location: l.location }),\n            });\n            let stats = await res.json();\n            if (stats && !stats.error && typeof stats.file_count === 'number') {\n                return {\n                    ...stats,\n                    name: l.name,\n                    location: l.location,\n                    isCustom: false,\n                    notInSource: !sourceDirSet.has(l.location),\n                };\n            }\n            if (stats.error) {\n                return {\n                    name: l.name,\n                    location: l.location,\n                    file_count: 0,\n                    size_bytes: 0,\n                    files: [],\n                    isCustom: false,\n                    notInSource: !sourceDirSet.has(l.location),\n                    error: true,\n                };\n            }\n            return null;\n        })\n    );\n    gdriveStatsArr = gdriveStatRaw.filter(Boolean);\n\n    let mergedGdriveStats = [...gdriveStatsArr, ...customStatsArr];\n    mergedGdriveStats.forEach((s) => {\n        s.file_count = Number(s.file_count) || 0;\n        s.size_bytes = Number(s.size_bytes) || 0;\n    });\n\n    let gTotals = {\n        files: mergedGdriveStats.reduce((sum, s) => sum + s.file_count, 0),\n        size: mergedGdriveStats.reduce((sum, s) => sum + s.size_bytes, 0),\n    };\n    let aStats = null,\n        aTotals = { files: 0, size: 0 };\n    if (assetsDir) {\n        let res = await fetch('/api/poster-search-stats', {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({ location: assetsDir }),\n        });\n        let stats = await res.json();\n        if (!stats.error && typeof stats.file_count === 'number') {\n            aStats = [\n                {\n                    name: 'Assets Dir',\n                    ...stats,\n                },\n            ];\n            aTotals = { files: stats.file_count, size: stats.size_bytes };\n        }\n    }\n\n    gdriveStatsData = mergedGdriveStats.map((s) => ({ ...s }));\n    gdriveTotals = gTotals;\n    assetsStatsData = aStats || [];\n    assetsTotals = aTotals;\n\n    priorityMap = {};\n    sourceDirs.forEach((dir, idx) => {\n        priorityMap[dir] = idx;\n    });\n\n    sortGdriveStats(gdriveStatsData, gdriveSortMode, priorityMap);\n    renderStatsSection();\n    showSpinner(false);\n}\n\nfunction renderStatsSection() {\n    const statsCard = document.getElementById('poster-stats-card');\n    if (!statsCard) return;\n    statsCard.className = 'card';\n\n    if (!statsCard.dataset.expanded) {\n        statsCard.style.display = 'none';\n    }\n    statsCard.style.marginBottom = '2em';\n\n    statsCard.innerHTML = `\n        <div id=\"gdrive-sort-row\" class=\"gdrive-sort-row\">\n            <label for=\"gdrive-sort-select\" class=\"gdrive-sort-label\">Sort by:</label>\n            <select id=\"gdrive-sort-select\" class=\"select gdrive-sort-select\">\n                <option value=\"priority-desc\">Source Order (High → Low)</option>\n                <option value=\"priority-asc\">Source Order (Low → High)</option>\n                <option value=\"files-desc\">Files (High → Low)</option>\n                <option value=\"files-asc\">Files (Low → High)</option>\n                <option value=\"size-desc\">Size (High → Low)</option>\n                <option value=\"size-asc\">Size (Low → High)</option>\n                <option value=\"name-asc\">Name (A → Z)</option>\n                <option value=\"name-desc\">Name (Z → A)</option>\n            </select>\n        </div>\n        <div id=\"gdrive-stats-table\">\n            ${renderStatsTable([...gdriveStatsData], gdriveTotals, 'GDrive Locations')}\n        </div>\n        <div id=\"assets-stats-table\" style=\"margin-top:1.3em;\">\n            ${renderStatsTable(assetsStatsData, assetsTotals, 'Assets Directory')}\n        </div>\n    `;\n\n    const select = document.getElementById('gdrive-sort-select');\n    if (select) {\n        select.value = gdriveSortMode;\n        select.onchange = function () {\n            gdriveSortMode = this.value;\n            let arr = gdriveStatsData.map((s) => ({ ...s }));\n            sortGdriveStats(arr, gdriveSortMode, priorityMap);\n            document.getElementById('gdrive-stats-table').innerHTML = renderStatsTable(\n                arr,\n                gdriveTotals,\n                'GDrive Locations'\n            );\n        };\n    }\n}\n\nfunction setupStatsToggle() {\n    const btn = getById('toggle-stats-btn');\n    const card = getById('poster-stats-card');\n    btn.textContent = '📊 Show Statistics';\n    card.style.display = 'none';\n    card.dataset.expanded = ''; // Not expanded\n    btn.onclick = function () {\n        const expanded = card.style.display !== '' && card.style.display !== 'block';\n        if (expanded) {\n            card.style.display = '';\n            card.dataset.expanded = '1';\n            btn.textContent = '📊 Hide Statistics';\n        } else {\n            card.style.display = 'none';\n            card.dataset.expanded = '';\n            btn.textContent = '📊 Show Statistics';\n        }\n    };\n}\n\nfunction getById(id) {\n    return document.getElementById(id);\n}\nfunction highlight(str, term) {\n    if (!term) return str;\n    const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')})`, 'gi');\n    return str.replace(regex, `<span class=\"highlight\">$1</span>`);\n}\nfunction showSpinner(show) {\n    const spinner = getById(IDS.statsSpinner);\n    if (spinner) spinner.style.display = show ? '' : 'none';\n}\nfunction materialIcon(name, style = '') {\n    return `<span class=\"material-icons\" style=\"vertical-align:middle;${style}\">${name}</span>`;\n}\n\nfunction showImageModal(imgSrc, caption) {\n    closeImageModal();\n    const modal = document.createElement('div');\n    modal.id = 'img-preview-modal';\n    modal.className = 'show';\n    modal.innerHTML = `\n        <div class=\"img-modal-bg\"></div>\n        <div class=\"img-modal-content\">\n            <button class=\"img-modal-close\" type=\"button\">&times;</button>\n            <img class=\"img-modal-img\" src=\"${imgSrc}\" alt=\"Preview\" />\n            <div class=\"img-modal-caption\">${caption || ''}</div>\n        </div>\n    `;\n    document.body.appendChild(modal);\n    modal.querySelector('.img-modal-bg').onclick = closeImageModal;\n    modal.querySelector('.img-modal-close').onclick = closeImageModal;\n}\nfunction closeImageModal() {\n    const old = document.getElementById('img-preview-modal');\n    if (old) old.remove();\n}\n\nlet hoverPreviewImg = null;\nfunction setupHoverPreview() {\n    hoverPreviewImg = document.querySelector('.hover-preview');\n    if (!hoverPreviewImg) {\n        hoverPreviewImg = document.createElement('img');\n        hoverPreviewImg.className = 'hover-preview';\n        hoverPreviewImg.style.display = 'none';\n        hoverPreviewImg.style.position = 'absolute';\n        hoverPreviewImg.style.pointerEvents = 'none';\n        hoverPreviewImg.style.maxWidth = '200px';\n        hoverPreviewImg.style.maxHeight = '200px';\n        hoverPreviewImg.style.zIndex = '10002';\n        document.body.appendChild(hoverPreviewImg);\n    }\n}\nsetupHoverPreview();\n\nasync function fetchAllFileLists() {\n    showSpinner(true);\n    config = await fetchConfig();\n\n    gdriveLocations = (config.sync_gdrive?.gdrive_list || []).map((g) => ({\n        name: g.name,\n        location: g.location,\n    }));\n    const gdriveLocSet = new Set(gdriveLocations.map((g) => g.location));\n    const sourceDirs = config.poster_renamerr?.source_dirs || [];\n    customLocations = sourceDirs.filter((dir) => !gdriveLocSet.has(dir));\n    assetsDir = config.poster_renamerr?.destination_dir || '';\n\n    gdriveFiles = [];\n    for (const { name, location } of gdriveLocations) {\n        try {\n            const res = await fetch('/api/poster-search-stats', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ location }),\n            });\n            const stats = await res.json();\n            if (Array.isArray(stats.files)) {\n                stats.files.forEach((f) => gdriveFiles.push({ file: f, name, location }));\n            }\n        } catch {}\n    }\n\n    customFiles = [];\n    for (const dir of customLocations) {\n        try {\n            const res = await fetch('/api/poster-search-stats', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ location: dir }),\n            });\n            const stats = await res.json();\n            if (Array.isArray(stats.files)) {\n                stats.files.forEach((f) =>\n                    customFiles.push({\n                        file: f,\n                        name: dir.split('/').pop() + ' (Custom)',\n                        location: dir,\n                    })\n                );\n            }\n        } catch {}\n    }\n\n    assetsFiles = [];\n    if (assetsDir) {\n        try {\n            const res = await fetch('/api/poster-search-stats', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ location: assetsDir }),\n            });\n            const stats = await res.json();\n            if (Array.isArray(stats.files)) {\n                assetsFiles = stats.files;\n            }\n        } catch {\n            assetsFiles = [];\n        }\n    }\n    showSpinner(false);\n}\n\nfunction renderResults(term) {\n    const resultsDiv = getById(IDS.searchResults);\n    let html = '';\n    let useAssets = getById(IDS.scopeToggle).checked;\n\n    if (!useAssets) {\n        const groups = {};\n        [...gdriveFiles, ...customFiles].forEach(({ file, name, location }) => {\n            if (!term || file.toLowerCase().includes(term)) {\n                const key = name + '||' + location;\n                if (!groups[key]) groups[key] = { name, location, files: [] };\n                groups[key].files.push(file);\n            }\n        });\n        Object.values(groups).forEach((group) => {\n            const locate = encodeURIComponent(group.location);\n            html += `<div class=\"result-group\">\n                <div class=\"result-folder\" tabindex=\"0\" aria-label=\"${group.name}\">${\n                group.name\n            }</div>\n                <ul class=\"poster-list\">${group.files\n                    .map(\n                        (f) =>\n                            `<li class=\"img-preview-link\">\n                    <span class=\"poster-file-label\"\n                          data-location=\"${locate}\"\n                          data-file=\"${encodeURIComponent(f)}\"\n                          tabindex=\"0\"\n                          aria-label=\"Preview ${f}\">${highlight(f, term)}</span>\n                    <button class=\"copy-btn\" title=\"Copy filename\" aria-label=\"Copy filename ${f}\">\n                        <span class=\"copy-btn-default\">${materialIcon(\n                            'content_copy',\n                            'font-size:1.2em;margin-right:3px;'\n                        )}Copy</span>\n                        <span class=\"copy-btn-copied\" style=\"display:none;\">${materialIcon(\n                            'check',\n                            'font-size:1.2em;margin-right:3px;'\n                        )}Copied</span>\n                    </button>\n                </li>`\n                    )\n                    .join('')}</ul>\n            </div>`;\n        });\n    }\n    if (useAssets && assetsFiles.length) {\n        const matches = assetsFiles.filter((file) => {\n            if (file.startsWith('tmp/')) return false;\n            if (file === '.DS_Store') return false;\n            if (!term) return true;\n            const lower = file.toLowerCase();\n            const fname = file.split('/').pop().toLowerCase();\n            return lower.includes(term) || fname.includes(term);\n        });\n        if (matches.length) {\n            const locate = encodeURIComponent(assetsDir);\n            html += `<div class=\"result-group\">\n                <div class=\"result-folder\">Assets Dir</div>\n                <ul class=\"poster-list\">${matches\n                    .map(\n                        (f) =>\n                            `<li class=\"img-preview-link\">\n                    <span class=\"poster-file-label\"\n                          data-location=\"${locate}\"\n                          data-file=\"${encodeURIComponent(f)}\"\n                          tabindex=\"0\"\n                          aria-label=\"Preview ${f}\">${highlight(f, term)}</span>\n                    <button class=\"copy-btn\" title=\"Copy filename\" aria-label=\"Copy filename ${f}\">\n                        <span class=\"copy-btn-default\">${materialIcon(\n                            'content_copy',\n                            'font-size:1.2em;margin-right:3px;'\n                        )}Copy</span>\n                        <span class=\"copy-btn-copied\" style=\"display:none;\">${materialIcon(\n                            'check',\n                            'font-size:1.2em;margin-right:3px;'\n                        )}Copied</span>\n                    </button>\n                </li>`\n                    )\n                    .join('')}</ul>\n            </div>`;\n        }\n    }\n    resultsDiv.innerHTML =\n        html ||\n        `<div style=\"margin-top:2em;\">No results found. Try another search or check your filters.</div>`;\n}\n\nfunction copyToClipboard(btn, text) {\n    navigator.clipboard\n        .writeText(text)\n        .then(() => {\n            const def = btn.querySelector('.copy-btn-default');\n            const copied = btn.querySelector('.copy-btn-copied');\n            if (def && copied) {\n                def.style.display = 'none';\n                copied.style.display = 'inline';\n                setTimeout(() => {\n                    def.style.display = '';\n                    copied.style.display = 'none';\n                }, 1400);\n            }\n        })\n        .catch(() => {\n            showToast && showToast('Could not copy to clipboard.', 'error');\n        });\n}\n\nfunction setupEventListeners() {\n    const toggle = getById(IDS.scopeToggle);\n    const label = getById(IDS.scopeLabel);\n    toggle.checked = false;\n    label.textContent = 'GDrive Locations';\n    toggle.onchange = () => {\n        label.textContent = toggle.checked ? 'Assets Directory' : 'GDrive Locations';\n        getById(IDS.searchInput).value = '';\n        getById(IDS.searchResults).innerHTML = '';\n    };\n\n    document.addEventListener('keydown', (e) => {\n        const input = getById(IDS.searchInput);\n        const modal = document.getElementById('img-preview-modal');\n        if ((e.key === '/' && !e.ctrlKey) || (e.key === 'f' && e.ctrlKey)) {\n            e.preventDefault();\n            input && input.focus();\n        } else if (e.key === 'Escape') {\n            if (modal) closeImageModal();\n            else input && (input.value = '');\n        } else if (e.key === 'Enter' && document.activeElement === input) {\n            e.preventDefault();\n            renderResults(input.value.trim().toLowerCase());\n        }\n    });\n\n    getById(IDS.searchInput).onkeypress = (e) => {\n        if (e.key === 'Enter') {\n            e.preventDefault();\n            renderResults(e.target.value.trim().toLowerCase());\n        }\n    };\n\n    getById(IDS.searchResults).addEventListener('click', (e) => {\n        const copyBtn = e.target.closest('.copy-btn');\n        if (copyBtn) {\n            e.stopPropagation();\n            let file = copyBtn.getAttribute('aria-label') || '';\n            file = file\n                .replace(/^Copy filename\\s*/i, '')\n                .replace(/^Copied\\s*/i, '')\n                .trim();\n            if (!file) {\n                const span = copyBtn.closest('li')?.querySelector('.poster-file-label');\n                if (span) file = span.textContent;\n            }\n            copyToClipboard(copyBtn, file);\n            return false;\n        }\n\n        const label = e.target.closest('.poster-file-label');\n        if (label) {\n            let location = decodeURIComponent(label.getAttribute('data-location') || '');\n            let path = decodeURIComponent(label.getAttribute('data-file') || '');\n            let caption = label.textContent;\n            if (location && path) {\n                const url = `/api/preview-poster?location=${encodeURIComponent(\n                    location\n                )}&path=${encodeURIComponent(path)}`;\n                showImageModal(url, caption);\n            }\n            return false;\n        }\n    });\n\n    getById(IDS.searchResults).addEventListener('mouseover', (e) => {\n        const label = e.target.closest('.poster-file-label');\n        if (label) {\n            let location = decodeURIComponent(label.getAttribute('data-location') || '');\n            let path = decodeURIComponent(label.getAttribute('data-file') || '');\n            if (location && path) {\n                const url = `/api/preview-poster?location=${encodeURIComponent(\n                    location\n                )}&path=${encodeURIComponent(path)}&thumb=1`;\n                hoverPreviewImg.src = url;\n                hoverPreviewImg.style.display = 'block';\n            }\n        }\n    });\n    getById(IDS.searchResults).addEventListener('mousemove', (e) => {\n        if (hoverPreviewImg && hoverPreviewImg.style.display === 'block') {\n            const imgWidth = hoverPreviewImg.naturalWidth\n                ? Math.min(hoverPreviewImg.naturalWidth, 200)\n                : 200;\n            const imgHeight = hoverPreviewImg.naturalHeight\n                ? Math.min(hoverPreviewImg.naturalHeight, 200)\n                : 200;\n            const vpWidth = window.innerWidth;\n            const vpHeight = window.innerHeight;\n            let left = e.pageX + 14;\n            let top = e.pageY + 14;\n            if (left + imgWidth > vpWidth - 10) left = Math.max(10, vpWidth - imgWidth - 10);\n            if (top + imgHeight > vpHeight - 10) top = Math.max(10, vpHeight - imgHeight - 10);\n            hoverPreviewImg.style.left = left + 'px';\n            hoverPreviewImg.style.top = top + 'px';\n        }\n    });\n    getById(IDS.searchResults).addEventListener('mouseout', (e) => {\n        if (e.target.closest('.poster-file-label')) {\n            hoverPreviewImg.style.display = 'none';\n        }\n    });\n}\n\nexport async function initPosterSearch() {\n    showLoaderModal(true);\n    getById(IDS.searchResults).innerHTML = '';\n    getById(IDS.searchInput).value = '';\n    await fetchAllFileLists();\n    setupEventListeners();\n    showLoaderModal(false);\n    setupStatsToggle();\n    await fetchAndRenderStats();\n}\n"
  },
  {
    "path": "web/static/js/schedule.js",
    "content": "import { fetchConfig, renderHelp, moduleOrder } from './helper.js';\nimport { buildSchedulePayload } from './payload.js';\nimport { navigateTo } from './navigation.js';\nimport { DAPS } from './common.js';\nconst { bindSaveButton, showToast, humanize } = DAPS;\n\nexport async function loadSchedule() {\n    const config = await fetchConfig();\n    const schedule = config.schedule || {};\n    const form = document.getElementById('scheduleForm');\n    if (!form) return;\n    form.innerHTML = '';\n    const help = renderHelp('schedule');\n    if (help) form.before(help);\n\n    const orderedModules = (moduleOrder || Object.keys(schedule)).filter((m) =>\n        schedule.hasOwnProperty(m)\n    );\n    for (const [i, module] of orderedModules.entries()) {\n        const time = schedule[module];\n        const label = document.createElement('label');\n        label.textContent = humanize(module);\n        const input = document.createElement('input');\n        input.type = 'text';\n        input.name = module;\n        input.value = time || '';\n        input.className = 'input';\n        input.placeholder = 'e.g. hourly(01), daily(12:00|18:00), weekly(Mon@12:00|Tue@18:00)';\n        const field = document.createElement('div');\n        field.className = 'field';\n        field.appendChild(label);\n        field.appendChild(input);\n\n        input.addEventListener('input', () => {\n            if (!input.value.trim() || isValidSchedule(input.value.trim())) {\n                input.classList.remove('input-invalid');\n            } else {\n                input.classList.add('input-invalid');\n            }\n        });\n\n        const runBtn = document.createElement('button');\n        runBtn.type = 'button';\n        runBtn.textContent = 'Run Now';\n        runBtn.className = 'run-btn btn';\n        runBtn.addEventListener('mouseenter', () => {\n            if (runBtn.classList.contains('running')) {\n                runBtn.textContent = 'Cancel';\n                runBtn.classList.add('cancel-hover');\n            }\n        });\n        runBtn.addEventListener('mouseleave', () => {\n            if (runBtn.classList.contains('running')) {\n                runBtn.textContent = 'Running';\n                runBtn.classList.remove('cancel-hover');\n            }\n        });\n\n        runBtn.addEventListener('click', async () => {\n            if (runBtn.classList.contains('running')) {\n                runBtn.textContent = 'Canceling';\n                await fetch('/api/cancel', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ module }),\n                });\n                runBtn.classList.remove('running');\n                runBtn.textContent = 'Run Now';\n                showToast(`🛑 ${humanize(module)} cancelled successfully.`, 'info');\n                return;\n            }\n            runBtn.textContent = 'Running';\n            runBtn.classList.add('running');\n            if (!btnContainer.querySelector('.run-btn + .run-btn')) {\n                const viewLogsBtn = document.createElement('button');\n                viewLogsBtn.type = 'button';\n                viewLogsBtn.textContent = 'View Logs';\n                viewLogsBtn.className = 'run-btn btn';\n                viewLogsBtn.addEventListener('click', () => {\n                    window._preselectedLogModule = module;\n                    window.skipDirtyCheck = true;\n                    const link = document.createElement('a');\n                    link.href = '/pages/logs';\n                    navigateTo(link);\n                });\n                btnContainer.appendChild(viewLogsBtn);\n            }\n            const res = await fetch('/api/run', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ module }),\n            });\n            if (!res.ok) {\n                const err = await res.json();\n                runBtn.classList.remove('running');\n                runBtn.textContent = 'Run Now';\n                showToast(\n                    `❌ Failed to start ${humanize(module)}: ${err.error || res.statusText}`,\n                    'error'\n                );\n                return;\n            }\n            showToast(`▶️ ${humanize(module)} started successfully!`, 'success');\n            const interval = setInterval(async () => {\n                const resStatus = await fetch(`/api/status?module=${module}`);\n                const { running } = await resStatus.json();\n                if (!running) {\n                    runBtn.classList.remove('running');\n                    runBtn.textContent = 'Run Now';\n                    clearInterval(interval);\n                }\n            }, 2000);\n        });\n\n        const btnContainer = document.createElement('div');\n        btnContainer.className = 'btn-container';\n        btnContainer.appendChild(runBtn);\n        field.appendChild(btnContainer);\n\n        (async () => {\n            const resStatus = await fetch(`/api/status?module=${module}`);\n            const { running } = await resStatus.json();\n            if (running) {\n                runBtn.textContent = 'Running';\n                runBtn.classList.add('running');\n                const viewLogsBtn = document.createElement('button');\n                viewLogsBtn.type = 'button';\n                viewLogsBtn.textContent = 'View Logs';\n                viewLogsBtn.className = 'run-btn btn ';\n                viewLogsBtn.addEventListener('click', () => {\n                    window._preselectedLogModule = module;\n                    window.skipDirtyCheck = true;\n                    const link = document.createElement('a');\n                    link.href = '/fragments/logs';\n                    window.DAPS.navigateTo(link);\n                });\n                btnContainer.appendChild(viewLogsBtn);\n            }\n        })();\n\n        const card = document.createElement('div');\n        card.className = 'card';\n        card.appendChild(field);\n        form.appendChild(card);\n        setTimeout(() => card.classList.add('show-card'), 40 * i);\n    }\n\n    if (window._scheduleRunInterval) {\n        clearInterval(window._scheduleRunInterval);\n        window._scheduleRunInterval = null;\n    }\n    window._scheduleRunInterval = setInterval(() => {\n        document.querySelectorAll('.field').forEach((field) => {\n            const inp = field.querySelector('input');\n            const runBtn = field.querySelector('button.run-btn');\n            if (!inp || !runBtn) return;\n            const module = inp.name;\n            fetch(`/api/status?module=${module}`)\n                .then((res) => res.json())\n                .then(({ running }) => {\n                    if (running && !runBtn.classList.contains('running')) {\n                        runBtn.textContent = 'Running';\n                        runBtn.classList.add('running');\n                        const btnContainer = runBtn.parentElement;\n                        const viewExists = btnContainer.querySelector('.run-btn + .run-btn');\n                        if (!viewExists) {\n                            const viewLogsBtn = document.createElement('button');\n                            viewLogsBtn.type = 'button';\n                            viewLogsBtn.textContent = 'View Logs';\n                            viewLogsBtn.className = 'run-btn';\n                            viewLogsBtn.addEventListener('click', () => {\n                                window._preselectedLogModule = module;\n                                const link = document.createElement('a');\n                                link.href = '/fragments/logs';\n                                window.DAPS.navigateTo(link);\n                            });\n                            btnContainer.appendChild(viewLogsBtn);\n                        }\n                    } else if (!running && runBtn.classList.contains('running')) {\n                        runBtn.classList.remove('running');\n                        runBtn.textContent = 'Run Now';\n                        const btnContainer = runBtn.parentElement;\n                        const viewLogsBtn = btnContainer.querySelector('.run-btn + .run-btn');\n                        if (viewLogsBtn) btnContainer.removeChild(viewLogsBtn);\n                    }\n                });\n        });\n    }, 3000);\n\n    const saveBtn = document.getElementById('saveBtn');\n    bindSaveButton(saveBtn, buildSchedulePayload, 'schedule');\n\n    const searchInput = document.getElementById('schedule-search');\n    if (searchInput) {\n        searchInput.addEventListener('input', (e) => {\n            DAPS.skipDirtyCheck = true;\n            searchInput.defaultValue = searchInput.value;\n            const query = e.target.value.toLowerCase();\n            document.querySelectorAll('.card').forEach((card) => {\n                const text = card.textContent.toLowerCase();\n                card.style.display = text.includes(query) ? 'flex' : 'none';\n            });\n        });\n    }\n}\n\nfunction isValidSchedule(val) {\n    if (!val) return true;\n    if (/^hourly\\(\\d{2}\\)$/i.test(val)) return true;\n    if (/^daily\\(\\d{2}:\\d{2}(?:\\|\\d{2}:\\d{2})*\\)$/i.test(val)) return true;\n    if (/^weekly\\([a-z]+@\\d{2}:\\d{2}(?:\\|[a-z]+@\\d{2}:\\d{2})*\\)$/i.test(val)) return true;\n    if (/^monthly\\(\\d{1,2}@\\d{2}:\\d{2}(?:\\|\\d{1,2}@\\d{2}:\\d{2})*\\)$/i.test(val)) return true;\n    if (/^cron\\([^\\)]+\\)$/i.test(val)) return true;\n    return false;\n}\n"
  },
  {
    "path": "web/static/js/settings/constants.js",
    "content": "export const BOOL_FIELDS = [\n    'dry_run',\n    'skip',\n    'sync_posters',\n    'run_border_replacerr',\n    'print_files',\n    'rename_folders',\n    'unattended',\n    'enable_batching',\n    'asset_folders',\n    'print_only_renames',\n    'incremental_border_replacerr',\n    'silent',\n    'disable_batching',\n    'replace_border',\n    'update_notifications',\n];\n\nexport const TEXT_FIELDS = [\n    'tag_name',\n    'ignore_tag',\n    'custom_format',\n    'title',\n    'alt_title',\n    'poster_path',\n];\n\nexport const TEXTAREA_FIELDS = [\n    'exclude_profiles',\n    'exclude_movies',\n    'exclude_series',\n    'exclusion_list',\n    'exclude',\n    'token',\n    'ignore_collections',\n    'ignore_root_folders',\n    'ignore_media',\n];\n\nexport const INT_FIELDS = [\n    'count',\n    'radarr_count',\n    'sonarr_count',\n    'season_monitored_threshold',\n    'border_width',\n    'searches',\n];\n\nexport const JSON_FIELDS = ['token'];\n\nexport const DROP_DOWN_FIELDS = ['log_level', 'action_type', 'app_type', 'app_instance', 'theme'];\n\n// Add to constants.js\n\nexport const DROP_DOWN_OPTIONS = {\n    mode: ['resolve', 'symlink', 'hardlink'],\n    log_level: ['info', 'debug'],\n    action_type: ['copy', 'move', 'hardlink', 'symlink'],\n    theme: ['light', 'dark', 'auto'],\n    month: [\n        { value: '01', label: 'Jan', days: 31 },\n        { value: '02', label: 'Feb', days: 28 },\n        { value: '03', label: 'Mar', days: 31 },\n        { value: '04', label: 'Apr', days: 30 },\n        { value: '05', label: 'May', days: 31 },\n        { value: '06', label: 'Jun', days: 30 },\n        { value: '07', label: 'Jul', days: 31 },\n        { value: '08', label: 'Aug', days: 31 },\n        { value: '09', label: 'Sep', days: 30 },\n        { value: '10', label: 'Oct', days: 31 },\n        { value: '11', label: 'Nov', days: 30 },\n        { value: '12', label: 'Dec', days: 31 },\n    ],\n};\n\nexport const DIR_PICKER = ['source_dirs', 'destination_dir', 'data_dir'];\n\nexport const ARR_AND_PLEX_INSTANCES = [\n    'poster_renamerr',\n    'labelarr',\n    'border_replacerr',\n    'sync_gdrive',\n    'nohl',\n    'unmatched_assets',\n    'poster_cleanarr',\n    'health_checkarr',\n    'renameinatorr',\n];\nexport const SHOW_PLEX_IN_INSTANCE_FIELD = [\n    'poster_renamerr',\n    'unmatched_assets',\n    'poster_cleanarr'\n];\n\nexport const DRAG_AND_DROP = {\n    poster_renamerr: ['source_dirs'],\n};\n\nexport const LIST_FIELD = {\n    unmatched_assets: ['source_dirs'],\n    poster_cleanarr: ['source_dirs'],\n    nohl: ['source_dirs'],\n};\n\nexport const PLACEHOLDER_TEXT = {\n    sync_gdrive: {\n        name: 'Unique name for your Gdrive',\n        token: '{\\n  \"access_token\": \"ya29.a0AfH6SMBEXAMPLEEXAMPLETOKEN\",\\n  \"refresh_token\": \"1\",\\n  \"scope\": \"https://www.googleapis.com/auth/drive\",\\n  \"token_type\": \"Bearer\",\\n  \"expiry_date\": 1712345678901\\n}',\n        gdrive_sa_location: 'Click to pick your service account file…',\n        location: 'Click to pick the destination directory',\n        id: 'Paste the Gdrive ID to pull posters from',\n        client_id: 'asdasds.apps.googleusercontent.com',\n        client_secret: 'GOCSPX-asda123',\n    },\n    poster_renamerr: {\n        source_dirs: 'Click to pick a source directory...',\n        destination_dir: '/path/to/Kometa/assets_directory',\n    },\n    upgradinatorr: {\n        data_dir: '/path/to/media_folder',\n        instance: 'Select an instance',\n        count: '0',\n        tag_name: 'Enter the tag you wish to use',\n        ignore_tag: 'The tag you wish to use to ignore an entry',\n    },\n    renameinatorr: {\n        tag_name: 'Enter the tag you wish to use',\n    },\n    nohl: {\n        source_dirs: 'Click to pick a source directory...',\n    },\n    border_replacerr: {\n        holiday_name: 'Holiday name',\n    },\n    labelarr: {\n        labels: 'Comma-separated list of labels',\n    },\n};\n"
  },
  {
    "path": "web/static/js/settings/modal_helpers.js",
    "content": "import { DROP_DOWN_OPTIONS } from './constants.js';\nimport { holidayPresets } from './presets.js';\n\nexport function populateScheduleDropdowns() {\n    const months = DROP_DOWN_OPTIONS.month; // Array of { value, label, days }\n    ['from', 'to'].forEach((type) => {\n        const monthSel = document.getElementById(`schedule-${type}-month`);\n        const daySel = document.getElementById(`schedule-${type}-day`);\n        if (!monthSel || !daySel) return;\n\n        monthSel.innerHTML = months\n            .map((m) => `<option value=\"${m.value}\">${m.label}</option>`)\n            .join('');\n\n        function updateDays() {\n            const mIdx = months.findIndex((m) => m.value === monthSel.value);\n            const days = mIdx >= 0 ? months[mIdx].days : 31;\n            let opts = '';\n            for (let d = 1; d <= days; d++) {\n                const dd = String(d).padStart(2, '0');\n                opts += `<option value=\"${dd}\">${dd}</option>`;\n            }\n            daySel.innerHTML = opts;\n        }\n        monthSel.addEventListener('change', updateDays);\n        updateDays();\n    });\n}\n\nexport function loadHolidayPresets() {\n    const presetSelect = document.getElementById('holiday-preset');\n    if (!presetSelect) return;\n    presetSelect.innerHTML =\n        '<option value=\"\">Select preset...</option>' +\n        Object.keys(holidayPresets || {})\n            .map((label) => `<option value=\"${label}\">${label}</option>`)\n            .join('');\n    presetSelect.onchange = function () {\n        const label = presetSelect.value;\n        const modal = presetSelect.closest('.modal-content');\n        if (!label || !holidayPresets[label]) return;\n        const preset = holidayPresets[label];\n        modal.querySelector('#holiday-name').value = label;\n        if (\n            preset.schedule &&\n            preset.schedule.startsWith('range(') &&\n            preset.schedule.endsWith(')')\n        ) {\n            const range = preset.schedule.slice(6, -1);\n            const [from, to] = range.split('-');\n            if (from) {\n                const [fromMonth, fromDay] = from.split('/');\n                modal.querySelector('#schedule-from-month').value = fromMonth || '';\n                modal.querySelector('#schedule-from-day').value = fromDay || '';\n            }\n            if (to) {\n                const [toMonth, toDay] = to.split('/');\n                modal.querySelector('#schedule-to-month').value = toMonth || '';\n                modal.querySelector('#schedule-to-day').value = toDay || '';\n            }\n        }\n        const colorContainer = modal.querySelector('#border-colors-container');\n        colorContainer.innerHTML = '';\n        (preset.colors || []).forEach((color) => {\n            const swatch = document.createElement('div');\n            swatch.className = 'subfield';\n            swatch.innerHTML = `\n        <input type=\"color\" value=\"${color}\" />\n        <button type=\"button\" class=\"btn--cancel remove-btn btn--remove-item btn\">−</button>\n        `;\n            swatch.querySelector('.remove-btn').onclick = () => swatch.remove();\n            colorContainer.appendChild(swatch);\n        });\n    };\n}\n\nexport async function populateGDrivePresetsDropdown(gdriveSyncData, editingIdx = null) {\n    const presetSelect = document.getElementById('gdrive-sync-preset');\n    const presetDetail = document.getElementById('gdrive-preset-detail');\n    const searchBox = document.getElementById('gdrive-preset-search');\n    if (!presetSelect) return;\n\n    const entries = await gdrivePresets();\n\n    const idsInUse = gdriveSyncData\n        .filter((entry, i) => i !== editingIdx)\n        .map((entry) => String(entry.id));\n    presetSelect.innerHTML =\n        '<option value=\"\">— No Preset —</option>' +\n        entries\n            .map(\n                (drive) =>\n                    `<option value=\"${drive.id}\" data-name=\"${drive.name}\"${\n                        idsInUse.includes(String(drive.id)) ? ' disabled style=\"color:#aaa;\"' : ''\n                    }>${drive.name}${\n                        idsInUse.includes(String(drive.id)) ? ' (Already Added)' : ''\n                    }</option>`\n            )\n            .join('');\n\n    setTimeout(function () {\n        if ($('#gdrive-sync-preset').data('select2')) {\n            $('#gdrive-sync-preset').select2('destroy');\n        }\n        $('#gdrive-sync-preset').select2({\n            placeholder: 'Select a GDrive preset',\n            allowClear: true,\n            width: '100%',\n            dropdownParent: $('#gdrive-sync-preset').closest('.modal-content'),\n            language: {\n                searching: () => 'Type to filter drives…',\n                noResults: () => 'No matching presets',\n                inputTooShort: () => 'Type to search…',\n            },\n        });\n        $('#gdrive-sync-preset').on('select2:open', function () {\n            setTimeout(() => {\n                $('.select2-search__field').attr('placeholder', 'Type to search presets…');\n            }, 0);\n        });\n    }, 0);\n\n    function updatePresetDetail() {\n        const id = presetSelect.value;\n        const drive = entries.find((d) => String(d.id) === String(id));\n        if (id && drive) {\n            if (document.getElementById('gdrive-id'))\n                document.getElementById('gdrive-id').value = drive.id ?? '';\n            if (document.getElementById('gdrive-name'))\n                document.getElementById('gdrive-name').value = drive.name ?? '';\n            if (document.getElementById('gdrive-location'))\n                document.getElementById('gdrive-location').value = drive.location ?? '';\n\n            if (presetDetail) {\n                let metaLines = '';\n\n                if ('type' in drive) {\n                    metaLines += `<div class=\"preset-field\"><span class=\"preset-label\">Type:</span> <span class=\"preset-type\">${drive.type}</span></div>`;\n                }\n\n                if ('content' in drive && drive.content) {\n                    metaLines += `<div class=\"preset-field\"><span class=\"preset-label\">Content:</span></div>`;\n                    if (Array.isArray(drive.content)) {\n                        metaLines += `<div class=\"preset-content\">${drive.content\n                            .map((line) => `<div>${line}</div>`)\n                            .join('')}</div>`;\n                    } else {\n                        metaLines += `<div class=\"preset-content\">${drive.content}</div>`;\n                    }\n                }\n\n                for (const key of Object.keys(drive)) {\n                    if (['name', 'id', 'type', 'content'].includes(key)) continue;\n                    metaLines += `<div class=\"preset-field\"><span class=\"preset-label\">${\n                        key.charAt(0).toUpperCase() + key.slice(1)\n                    }:</span> <span>${drive[key]}</span></div>`;\n                }\n                presetDetail.innerHTML = `<div class=\"preset-card\">${\n                    metaLines || '<i>No extra metadata</i>'\n                }</div>`;\n            }\n        } else if (presetDetail) {\n            presetDetail.innerHTML = '';\n        }\n    }\n    presetSelect.onchange = updatePresetDetail;\n    updatePresetDetail();\n\n    if (searchBox) {\n        searchBox.addEventListener('input', () => {\n            const filter = searchBox.value.toLowerCase();\n            Array.from(presetSelect.options).forEach((opt) => {\n                if (!opt.value) return;\n                opt.style.display = opt.text.toLowerCase().includes(filter) ? '' : 'none';\n            });\n            let firstVisible = Array.from(presetSelect.options).find(\n                (opt) => opt.style.display !== 'none' && opt.value\n            );\n            if (firstVisible) {\n                presetSelect.value = firstVisible.value;\n                updatePresetDetail();\n            } else {\n                presetSelect.value = '';\n                updatePresetDetail();\n            }\n        });\n    }\n}\n\nasync function gdrivePresets() {\n    if (window._gdrivePresetsCache) return window._gdrivePresetsCache; // use cache\n    try {\n        const response = await fetch(\n            'https://raw.githubusercontent.com/Drazzilb08/daps-gdrive-presets/main/presets.json'\n        );\n        if (!response.ok) throw new Error('Failed to fetch GDrive presets');\n        const data = await response.json();\n\n        window._gdrivePresetsCache = Array.isArray(data)\n            ? data\n            : Object.entries(data).map(([name, value]) =>\n                  typeof value === 'object'\n                      ? {\n                            name,\n                            ...value,\n                        }\n                      : {\n                            name,\n                            id: value,\n                        }\n              );\n    } catch (err) {\n        console.error('Error loading GDrive presets:', err);\n        window._gdrivePresetsCache = [];\n    }\n    return window._gdrivePresetsCache;\n}\n"
  },
  {
    "path": "web/static/js/settings/modals.js",
    "content": "import { PLACEHOLDER_TEXT } from './constants.js';\nimport {\n    populateScheduleDropdowns,\n    loadHolidayPresets,\n    populateGDrivePresetsDropdown,\n} from './modal_helpers.js';\nimport { DAPS } from '../common.js';\nconst { markDirty, humanize } = DAPS;\n\nconst directoryCache = {};\n\nfunction modalFooterHtml(saveId, cancelId, saveLabel = 'Save') {\n    return `\n        <div class=\"modal-footer\">\n          <button class=\"btn btn--success\" id=\"${saveId}\">${saveLabel}</button>\n          <button class=\"btn btn--cancel\" id=\"${cancelId}\">Cancel</button>\n        </div>\n    `;\n}\n\nfunction attachModalSaveCancel(modal, saveSelector, cancelSelector, onSave) {\n    const saveBtn = modal.querySelector(saveSelector);\n    const cancelBtn = modal.querySelector(cancelSelector);\n    if (saveBtn) saveBtn.onclick = onSave;\n    if (cancelBtn) cancelBtn.onclick = () => modal.classList.remove('show');\n}\n\nexport function gdriveSyncModal(editIdx, gdriveSyncData, updateGdriveList) {\n    const moduleName = 'sync_gdrive';\n    const isEdit = typeof editIdx === 'number';\n    let modal = document.getElementById('gdrive-sync-modal');\n    if (!modal) {\n        modal = document.createElement('div');\n        modal.id = 'gdrive-sync-modal';\n        modal.className = 'modal';\n        modal.innerHTML = `\n        <div class=\"modal-content\">\n        <label>Preset (optional)</label>\n        <select id=\"gdrive-sync-preset\" class=\"select\"> \n            <option value=\"\">— No Preset —</option>\n        </select>\n        <div id=\"gdrive-preset-detail\" style=\"margin-bottom: 0.75rem;\"></div>\n        <label>Name</label><input type=\"text\" id=\"gdrive-name\" class=\"input\" placeholder=\"${\n            PLACEHOLDER_TEXT[moduleName]?.name ?? ''\n        }\" />\n        <label>GDrive ID</label><input type=\"text\" id=\"gdrive-id\" class=\"input\" placeholder=\"${\n            PLACEHOLDER_TEXT[moduleName]?.id ?? ''\n        }\" />\n        <label>Location</label><input type=\"text\" id=\"gdrive-location\" class=\"input\" readonly placeholder=\"${\n            PLACEHOLDER_TEXT[moduleName]?.location ?? ''\n        }\" />\n        ${modalFooterHtml('gdrive-save-btn', 'gdrive-cancel-btn', isEdit ? 'Save' : 'Add')}\n        </div>\n    `;\n        document.body.appendChild(modal);\n        modal\n            .querySelector('#gdrive-location')\n            .addEventListener('click', () =>\n                directoryPickerModal(modal.querySelector('#gdrive-location'))\n            );\n        setTimeout(() => populateGDrivePresetsDropdown(gdriveSyncData, modal.editingIdx), 0);\n    }\n    modal.editingIdx = isEdit ? editIdx : null;\n    const presetSelect = modal.querySelector('#gdrive-sync-preset');\n    const presetDetail = modal.querySelector('#gdrive-preset-detail');\n    if (presetSelect) {\n        if ($(presetSelect).data('select2')) {\n            $(presetSelect).val('').trigger('change');\n        } else {\n            presetSelect.value = '';\n        }\n    }\n    if (presetDetail) presetDetail.innerHTML = '';\n    const nameInput = modal.querySelector('#gdrive-name');\n    const idInput = modal.querySelector('#gdrive-id');\n    const locInput = modal.querySelector('#gdrive-location');\n    if (isEdit) {\n        const entry = gdriveSyncData[editIdx];\n        nameInput.value = entry.name || '';\n        idInput.value = entry.id || '';\n        locInput.value = entry.location || '';\n    } else {\n        nameInput.value = '';\n        idInput.value = '';\n        locInput.value = '';\n    }\n    const heading = modal.querySelector('h2');\n    if (heading) {\n        heading.textContent = (isEdit ? 'Edit' : 'Add') + ' GDrive Sync';\n    }\n\n    function handleGDriveSave() {\n        const name = modal.querySelector('#gdrive-name').value.trim();\n        const id = modal.querySelector('#gdrive-id').value.trim();\n        const loc = modal.querySelector('#gdrive-location').value.trim();\n        if (!name || !id || !loc) {\n            return alert('All fields must be filled.');\n        }\n        const entry = { id, location: loc, name };\n        if (typeof editIdx === 'number') {\n            gdriveSyncData[editIdx] = entry;\n        } else {\n            gdriveSyncData.push(entry);\n        }\n        if (typeof updateGdriveList === 'function') updateGdriveList();\n        markDirty();\n        populateGDrivePresetsDropdown(gdriveSyncData, modal.editingIdx);\n        modal.classList.remove('show');\n    }\n    attachModalSaveCancel(modal, '#gdrive-save-btn', '#gdrive-cancel-btn', handleGDriveSave);\n    modal.classList.add('show');\n}\n\nexport function borderReplacerrModal(editIdx, borderReplacerrData, onUpdate) {\n    const moduleName = 'border_replacerr';\n    const isEdit = typeof editIdx === 'number';\n    let modal = document.getElementById('border-replacerr-modal');\n    if (!modal) {\n        modal = document.createElement('div');\n        modal.id = 'border-replacerr-modal';\n        modal.className = 'modal';\n        modal.innerHTML = `\n          <div class=\"modal-content\">\n          <h2 id=\"border-replacerr-modal-heading\"></h2>\n            <label>Holiday Preset</label>\n            <select id=\"holiday-preset\" class=\"select\">\n              <option value=\"\">Select preset...</option>\n            </select>\n            \n            <label>Holiday Name</label>\n            <input type=\"text\" id=\"holiday-name\" class=\"input\" placeholder=\"${\n                PLACEHOLDER_TEXT[moduleName]?.holiday_name ?? ''\n            }\" />\n            \n            <label>Schedule</label>\n            <div class=\"schedule-range\">\n              <select id=\"schedule-from-month\" class=\"select\"></select>\n              <select id=\"schedule-from-day\" class=\"select\"></select>\n              <span class=\"schedule-to-label\">To</span>\n              <select id=\"schedule-to-month\" class=\"select\"></select>\n              <select id=\"schedule-to-day\" class=\"select\"></select>\n            </div>\n            \n            <label>Colors</label>\n            <div id=\"border-colors-container\" class=\"border-colors-container\"></div>\n            <button type=\"button\" id=\"addBorderColor\" class=\"btn\">➕ Add Color</button>\n            \n            <div class=\"modal-footer\">\n              <button type=\"button\" id=\"holiday-save-btn\" class=\"btn btn--success\"></button>\n              <button type=\"button\" id=\"holiday-cancel-btn\" class=\"btn btn--cancel\">Cancel</button>\n            </div>\n          </div>\n        `;\n        document.body.appendChild(modal);\n    }\n    loadHolidayPresets();\n    populateScheduleDropdowns();\n    const heading = modal.querySelector('h2');\n    if (heading) {\n        heading.textContent = (isEdit ? 'Edit' : 'Add') + ' Holiday';\n    }\n    const saveBtn = modal.querySelector('#holiday-save-btn');\n    if (saveBtn) {\n        saveBtn.textContent = isEdit ? 'Save' : 'Add';\n    }\n    const colorContainer = modal.querySelector('#border-colors-container');\n    const addColorBtn = modal.querySelector('#addBorderColor');\n\n    function addBorderColor(color = '#ffffff') {\n        const swatch = document.createElement('div');\n        swatch.className = 'subfield';\n        swatch.innerHTML = `\n          <input type=\"color\" value=\"${color}\" />\n          <button type=\"button\" class=\"btn--cancel remove-btn btn--remove-item btn\">−</button>\n        `;\n        swatch.querySelector('.remove-btn').onclick = () => swatch.remove();\n        colorContainer.appendChild(swatch);\n    }\n    addColorBtn.onclick = () => addBorderColor();\n    modal.querySelector('#holiday-cancel-btn').onclick = () => {\n        modal.classList.remove('show');\n    };\n    modal.querySelector('#holiday-save-btn').onclick = () => {\n        const name = modal.querySelector('#holiday-name').value.trim();\n        const existing = borderReplacerrData || [];\n        const duplicate = existing.some(\n            (entry, i) => entry.holiday === name && (!isEdit || i !== editIdx)\n        );\n        if (duplicate) {\n            alert('A holiday with this name already exists.');\n            return;\n        }\n        const scheduleFrom = `${modal.querySelector('#schedule-from-month').value}/${\n            modal.querySelector('#schedule-from-day').value\n        }`;\n        const scheduleTo = `${modal.querySelector('#schedule-to-month').value}/${\n            modal.querySelector('#schedule-to-day').value\n        }`;\n        const colors = Array.from(\n            modal.querySelectorAll('#border-colors-container input[type=\"color\"]')\n        ).map((input) => input.value);\n        if (!name || !scheduleFrom || !scheduleTo || !colors.length) {\n            alert('All fields are required.');\n            return;\n        }\n        const schedule = `range(${scheduleFrom}-${scheduleTo})`;\n        const holidayEntry = {\n            holiday: name,\n            schedule,\n            color: colors,\n        };\n        if (isEdit) {\n            borderReplacerrData[editIdx] = holidayEntry;\n        } else {\n            borderReplacerrData.push(holidayEntry);\n        }\n        modal.classList.remove('show');\n        if (typeof onUpdate === 'function') onUpdate();\n        markDirty();\n    };\n    colorContainer.innerHTML = '';\n    if (isEdit) {\n        const entry = borderReplacerrData[editIdx];\n        modal.querySelector('#holiday-name').value = entry.holiday || '';\n        let from = '',\n            to = '';\n        if (entry.schedule && entry.schedule.startsWith('range(') && entry.schedule.endsWith(')')) {\n            const range = entry.schedule.slice(6, -1);\n            const [f, t] = range.split('-');\n            from = f || '';\n            to = t || '';\n        }\n        if (from) {\n            const [fromMonth, fromDay] = from.split('/');\n            modal.querySelector('#schedule-from-month').value = fromMonth || '';\n            modal.querySelector('#schedule-from-day').value = fromDay || '';\n        }\n        if (to) {\n            const [toMonth, toDay] = to.split('/');\n            modal.querySelector('#schedule-to-month').value = toMonth || '';\n            modal.querySelector('#schedule-to-day').value = toDay || '';\n        }\n        (entry.color || []).forEach(addBorderColor);\n    } else {\n        modal.querySelector('#holiday-name').value = '';\n        modal.querySelector('#schedule-from-month').selectedIndex = 0;\n        modal.querySelector('#schedule-from-day').selectedIndex = 0;\n        modal.querySelector('#schedule-to-month').selectedIndex = 0;\n        modal.querySelector('#schedule-to-day').selectedIndex = 0;\n        addBorderColor();\n    }\n    modal.classList.add('show');\n}\n\nexport function labelarrModal(editIdx, labelarrData, rootConfig, updateLabelarrTable) {\n    const moduleName = 'labelarr';\n    const isEdit = typeof editIdx === 'number';\n    let modal = document.getElementById('labelarr-modal');\n    if (!modal) {\n        modal = document.createElement('div');\n        modal.id = 'labelarr-modal';\n        modal.className = 'modal';\n        modal.innerHTML = `\n          <div class=\"modal-content\">\n            <h2 id=\"labelarr-modal-heading\"></h2>\n            <label>App Type</label>\n            <select id=\"labelarr-app-type\" class=\"select\">\n              <option value=\"radarr\">Radarr</option>\n              <option value=\"sonarr\">Sonarr</option>\n            </select>\n            <label>App Instance</label>\n            <select id=\"labelarr-app-instance\" class=\"select\"></select>\n            <label>Labels</label>\n            <input type=\"text\" id=\"labelarr-labels\" class=\"input\" placeholder=\"${\n                PLACEHOLDER_TEXT[moduleName]?.labels ?? ''\n            }\" />\n            <div id=\"labelarr-plex-list\" class=\"instances-list\"></div>\n            <div class=\"modal-footer\">\n              <button id=\"labelarr-save-btn\" class=\"btn btn--success\"></button>\n              <button id=\"labelarr-cancel-btn\" class=\"btn btn--cancel\">Cancel</button>\n            </div>\n          </div>\n        `;\n        document.body.appendChild(modal);\n\n        const plexListDiv = modal.querySelector('#labelarr-plex-list');\n        plexListDiv.innerHTML = '';\n        const plexInstances = Object.keys(rootConfig.instances?.plex || {});\n        if (plexInstances.length) {\n            plexInstances.forEach((pi) => {\n                const wrapper = document.createElement('div');\n                wrapper.className = 'card plex-instance-card';\n                wrapper.innerHTML = `\n                    <div class=\"plex-instance-header\">\n                        <h3>${humanize(pi)}</h3>\n                    </div>\n                    <div class=\"library-actions\">\n                        <div style=\"display: flex; gap: 0.5rem;\">\n                            <button type=\"button\" class=\"btn select-all-libs\" data-inst=\"${pi}\">Select All</button>\n                            <button type=\"button\" class=\"btn deselect-all-libs\" data-inst=\"${pi}\">Deselect All</button>\n                        </div>\n                        <button type=\"button\" class=\"btn load-libs-btn\" data-inst=\"${pi}\">Load Libraries</button>\n                    </div>\n                    <div id=\"labelarr-plex-libs-${pi}\" class=\"plex-libraries\" style=\"max-height: 0px;\"></div>\n                `;\n                plexListDiv.appendChild(wrapper);\n\n                const loadBtn = wrapper.querySelector('.load-libs-btn');\n                const libsDiv = wrapper.querySelector(`#labelarr-plex-libs-${pi}`);\n                loadBtn.addEventListener('click', async () => {\n                    loadBtn.disabled = true;\n                    try {\n                        const res = await fetch(\n                            `/api/plex/libraries?instance=${encodeURIComponent(pi)}`\n                        );\n                        if (!res.ok) throw new Error(await res.text());\n                        const fetchedLibs = await res.json();\n                        const checkedLibs = Array.from(\n                            libsDiv.querySelectorAll('input[type=\"checkbox\"]:checked')\n                        ).map((cb) => cb.value);\n                        libsDiv.innerHTML = fetchedLibs\n                            .map(\n                                (l) => `\n                            <label class=\"library-pill\">\n                                <input type=\"checkbox\" value=\"${l}\" ${\n                                    checkedLibs.includes(l) ? 'checked' : ''\n                                }/>\n                                ${l}\n                            </label>\n                        `\n                            )\n                            .join('');\n\n                        requestAnimationFrame(() => {\n                            libsDiv.classList.add('open');\n                            libsDiv.style.maxHeight = libsDiv.scrollHeight + 'px';\n                        });\n                    } catch (err) {\n                    } finally {\n                        loadBtn.disabled = false;\n                    }\n                });\n\n                wrapper.querySelector('.select-all-libs').addEventListener('click', () => {\n                    libsDiv\n                        .querySelectorAll('input[type=\"checkbox\"]')\n                        .forEach((cb) => (cb.checked = true));\n                });\n                wrapper.querySelector('.deselect-all-libs').addEventListener('click', () => {\n                    libsDiv\n                        .querySelectorAll('input[type=\"checkbox\"]')\n                        .forEach((cb) => (cb.checked = false));\n                });\n            });\n        } else {\n            plexListDiv.innerHTML = `<div class=\"card plex-instance-card\">\n                <div class=\"plex-instance-header\"><h3>Plex</h3></div>\n                <div class=\"plex-libraries\"><p class=\"no-entries\" style=\"margin: 0.5em 0 0 1em;\">🚫 No Plex instances configured.</p></div>\n            </div>`;\n        }\n\n        modal.querySelector('#labelarr-cancel-btn').onclick = () => {\n            modal.classList.remove('show');\n        };\n\n        modal.querySelector('#labelarr-app-type').onchange = () => {\n            const type = modal.querySelector('#labelarr-app-type').value;\n            const instSel = modal.querySelector('#labelarr-app-instance');\n            instSel.innerHTML = '';\n            Object.keys(rootConfig.instances?.[type] || {}).forEach((inst) => {\n                const o = document.createElement('option');\n                o.value = inst;\n                o.textContent = humanize(inst);\n                instSel.appendChild(o);\n            });\n        };\n    }\n\n    modal = document.getElementById('labelarr-modal');\n    delete modal.dataset.editing;\n    const heading = modal.querySelector('#labelarr-modal-heading');\n    if (heading) heading.textContent = (isEdit ? 'Edit' : 'Add') + ' Mapping';\n    const saveBtn = modal.querySelector('#labelarr-save-btn');\n    if (saveBtn) saveBtn.textContent = isEdit ? 'Save' : 'Add';\n\n    if (saveBtn) {\n        saveBtn.onclick = null;\n        saveBtn.onclick = () => {\n            console.log('Save button clicked!');\n            const appType = modal.querySelector('#labelarr-app-type').value;\n            const appInstance = modal.querySelector('#labelarr-app-instance').value;\n            const labels = modal\n                .querySelector('#labelarr-labels')\n                .value.split(',')\n                .map((s) => s.trim())\n                .filter(Boolean);\n            const plex_instances = [];\n            const plexListDiv = modal.querySelector('#labelarr-plex-list');\n            plexListDiv.querySelectorAll('.card.plex-instance-card').forEach((card) => {\n                const inst = card.querySelector('.load-libs-btn').dataset.inst;\n                const libs = Array.from(\n                    card.querySelectorAll('.plex-libraries input[type=\"checkbox\"]:checked')\n                ).map((cb) => cb.value);\n                if (libs.length) {\n                    plex_instances.push({ instance: inst, library_names: libs });\n                }\n            });\n            if (!labels.length || (!appInstance && plex_instances.length === 0)) {\n                alert('You must fill out labels and at least an App or Plex instance.');\n                return;\n            }\n            const mapping = {\n                app_type: appType,\n                app_instance: appInstance,\n                labels,\n                plex_instances,\n            };\n            if (typeof modal.dataset.editing !== 'undefined') {\n                labelarrData[modal.dataset.editing] = mapping;\n            } else {\n                labelarrData.push(mapping);\n            }\n            if (\n                window.config &&\n                Array.isArray(window.config.mappings) &&\n                window.config.mappings !== labelarrData\n            ) {\n                window.config.mappings.length = 0;\n                Array.prototype.push.apply(window.config.mappings, labelarrData);\n            }\n            console.log('labelarrData now:', JSON.stringify(labelarrData, null, 2));\n            if (typeof updateLabelarrTable === 'function') updateLabelarrTable();\n            markDirty();\n            modal.classList.remove('show');\n        };\n        console.log('Save handler attached to', saveBtn);\n    }\n\n    if (isEdit) {\n        const entry = labelarrData[editIdx];\n        modal.dataset.editing = editIdx;\n        modal.querySelector('#labelarr-app-type').value = entry.app_type;\n        modal.querySelector('#labelarr-app-type').dispatchEvent(new Event('change'));\n        modal.querySelector('#labelarr-app-instance').value = entry.app_instance;\n        modal.querySelector('#labelarr-labels').value = (entry.labels || []).join(', ');\n\n        const plexInstObj = {};\n        (entry.plex_instances || []).forEach((inst) => {\n            if (typeof inst === 'object' && inst.instance) {\n                plexInstObj[inst.instance] = { library_names: inst.library_names || [] };\n            }\n        });\n        modal.querySelectorAll('.card.plex-instance-card').forEach((card) => {\n            const inst = card.querySelector('.load-libs-btn').dataset.inst;\n            const libsDiv = card.querySelector(`.plex-libraries`);\n            const loadBtn = card.querySelector('.load-libs-btn');\n            if (plexInstObj[inst]) {\n                loadBtn.disabled = true;\n                fetch(`/api/plex/libraries?instance=${encodeURIComponent(inst)}`)\n                    .then((res) => res.json())\n                    .then((allLibs) => {\n                        libsDiv.innerHTML = allLibs\n                            .map(\n                                (l) => `\n                        <label class=\"library-pill\">\n                            <input type=\"checkbox\" value=\"${l}\" ${\n                                    plexInstObj[inst].library_names.includes(l) ? 'checked' : ''\n                                }/>\n                            ${l}\n                        </label>\n                    `\n                            )\n                            .join('');\n                        requestAnimationFrame(() => {\n                            libsDiv.classList.add('open');\n                            libsDiv.style.maxHeight = libsDiv.scrollHeight + 'px';\n                        });\n                    })\n                    .finally(() => {\n                        loadBtn.disabled = false;\n                    });\n            } else {\n                libsDiv.classList.remove('open');\n                libsDiv.innerHTML = '';\n                libsDiv.style.maxHeight = null;\n            }\n        });\n    } else {\n        modal.querySelector('#labelarr-app-type').value = 'radarr';\n        modal.querySelector('#labelarr-app-type').dispatchEvent(new Event('change'));\n        modal.querySelector('#labelarr-labels').value = '';\n        modal.querySelectorAll('.plex-libraries').forEach((div) => {\n            div.innerHTML = '';\n            div.classList.remove('open');\n            div.style.maxHeight = null;\n        });\n    }\n    modal.classList.add('show');\n}\n\nexport function upgradinatorrModal(editIdx, upgradinatorrData, rootConfig, updateTable) {\n    const moduleName = 'upgradinatorr';\n    const isEdit = typeof editIdx === 'number';\n    let modal = document.getElementById('upgradinatorr-modal');\n    if (!modal) {\n        modal = document.createElement('div');\n        modal.id = 'upgradinatorr-modal';\n        modal.className = 'modal';\n        modal.innerHTML = `\n                  <div class=\"modal-content\">\n                    <h2>${isEdit ? 'Edit' : 'Add'} Instance</h2>\n                    <label>Instance</label>\n                    <select id=\"upgradinatorr-instance\" class=\"select\">\n                    <option value=\"\" disabled selected>\n                        ${PLACEHOLDER_TEXT[moduleName]?.instance ?? 'Select an instance'}\n                    </option>\n                    </select>\n                    <label>Count</label>\n                    <input type=\"number\" id=\"upgradinatorr-count\" class=\"input\" placeholder=\"${\n                        PLACEHOLDER_TEXT[moduleName]?.count ?? ''\n                    }\"/>\n                    <label>Tag Name</label>\n                    <input type=\"text\" id=\"upgradinatorr-tag-name\" class=\"input\" placeholder=\"${\n                        PLACEHOLDER_TEXT[moduleName]?.tag_name ?? ''\n                    }\" />\n                    <label>Ignore Tag</label>\n                    <input type=\"text\" id=\"upgradinatorr-ignore-tag\" class=\"input\" placeholder=\"${\n                        PLACEHOLDER_TEXT[moduleName]?.ignore_tag ?? ''\n                    }\" />\n                    <label>Unattended</label>\n                    <select id=\"upgradinatorr-unattended\" class=\"select\">\n                      <option value=\"true\">True</option>\n                      <option value=\"false\">False</option>\n                    </select>\n                    <div id=\"season-threshold-container\" style=\"display:none;\">\n                      <label>Season Monitored Threshold</label>\n                      <input type=\"number\" id=\"upgradinatorr-season-threshold\" class=\"input\" min=\"0\" step=\"1\" />\n                    </div>\n                    <div class=\"modal-footer\">\n                      <button id=\"upgradinatorr-save-btn\" class=\"btn btn--success\">${\n                          isEdit ? 'Save' : 'Add'\n                      }</button>\n                      <button id=\"upgradinatorr-cancel-btn\" class=\"btn btn--cancel\">Cancel</button>\n                    </div>\n                  </div>\n                `;\n        document.body.appendChild(modal);\n        const instSelect = modal.querySelector('#upgradinatorr-instance');\n        const instList = [\n            ...Object.keys(rootConfig.instances.radarr || {}),\n            ...Object.keys(rootConfig.instances.sonarr || {}),\n        ];\n        instList.forEach((inst) => {\n            const opt = document.createElement('option');\n            opt.value = inst;\n            opt.textContent = humanize(inst);\n            instSelect.appendChild(opt);\n        });\n        modal.querySelector('#upgradinatorr-cancel-btn').onclick = () => {\n            modal.classList.remove('show');\n        };\n\n        const thresholdField = modal.querySelector('#season-threshold-container');\n        instSelect.addEventListener('change', () => {\n            const selected = instSelect.value;\n            const isSonarr = Object.keys(rootConfig.instances.sonarr || {}).includes(selected);\n            thresholdField.style.display = isSonarr ? '' : 'none';\n        });\n\n        instSelect.dispatchEvent(new Event('change'));\n        modal.querySelector('#upgradinatorr-save-btn').onclick = () => {\n            const inst = modal.querySelector('#upgradinatorr-instance').value;\n            const count = parseInt(modal.querySelector('#upgradinatorr-count').value, 10) || 0;\n            const tag_name = modal.querySelector('#upgradinatorr-tag-name').value.trim();\n            const ignore_tag = modal.querySelector('#upgradinatorr-ignore-tag').value.trim();\n            const unattended = modal.querySelector('#upgradinatorr-unattended').value === 'true';\n            const isSonarr = Object.keys(rootConfig.instances.sonarr || {}).includes(inst);\n            const season_threshold = isSonarr\n                ? parseInt(modal.querySelector('#upgradinatorr-season-threshold').value, 10) || 0\n                : undefined;\n            const entry = {\n                instance: inst,\n                count,\n                tag_name,\n                ignore_tag,\n                unattended,\n            };\n            if (isSonarr) entry.season_monitored_threshold = season_threshold;\n\n            const existingIdx = upgradinatorrData.findIndex((e) => e.instance === inst);\n            if (existingIdx !== -1) {\n                upgradinatorrData[existingIdx] = entry;\n            } else {\n                upgradinatorrData.push(entry);\n            }\n            if (typeof updateTable === 'function') updateTable();\n            markDirty();\n            modal.classList.remove('show');\n        };\n    }\n    modal.querySelector('#upgradinatorr-instance').value = isEdit\n        ? upgradinatorrData[editIdx].instance\n        : '';\n    modal.querySelector('#upgradinatorr-count').value = isEdit\n        ? upgradinatorrData[editIdx].count\n        : '';\n    modal.querySelector('#upgradinatorr-tag-name').value = isEdit\n        ? upgradinatorrData[editIdx].tag_name\n        : '';\n    modal.querySelector('#upgradinatorr-ignore-tag').value = isEdit\n        ? upgradinatorrData[editIdx].ignore_tag\n        : '';\n    modal.querySelector('#upgradinatorr-unattended').value = isEdit\n        ? String(upgradinatorrData[editIdx].unattended)\n        : 'false';\n\n    const seasonThresholdInput = modal.querySelector('#upgradinatorr-season-threshold');\n    if (seasonThresholdInput) {\n        seasonThresholdInput.value = isEdit\n            ? typeof upgradinatorrData[editIdx].season_monitored_threshold !== 'undefined'\n                ? upgradinatorrData[editIdx].season_monitored_threshold\n                : ''\n            : '99';\n    }\n\n    const instSelect = modal.querySelector('#upgradinatorr-instance');\n    const thresholdField = modal.querySelector('#season-threshold-container');\n    if (instSelect && thresholdField) {\n        instSelect.dispatchEvent(new Event('change'));\n    }\n    const heading = modal.querySelector('h2');\n    if (heading) {\n        heading.textContent = (isEdit ? 'Edit' : 'Add') + ' Upgradinatorr Instance List';\n    }\n    const saveBtn = modal.querySelector('#upgradinatorr-save-btn');\n    if (saveBtn) {\n        saveBtn.textContent = isEdit ? 'Save' : 'Add';\n    }\n    modal.classList.add('show');\n}\n\nexport function directoryPickerModal(inputElement) {\n    let suggestionTimeout;\n    let modal = document.getElementById('dir-modal');\n    if (!modal) {\n        modal = document.createElement('div');\n        modal.id = 'dir-modal';\n        modal.className = 'modal';\n        modal.classList.remove('show');\n        modal.innerHTML = `\n<div class=\"modal-content\">\n<h2>Select Directory</h2>\n<input type=\"text\" id=\"dir-path-input\" class=\"input\" placeholder=\"Type or paste a path…\" />\n<ul id=\"dir-list\" class=\"dir-list\"></ul>\n<div class=\"modal-footer\">\n    <button type=\"button\" id=\"dir-create\" class=\"btn\">New Folder</button>\n    <button type=\"button\" id=\"dir-accept\" class=\"btn btn--success\">Accept</button>\n    <button type=\"button\" id=\"dir-cancel\" class=\"btn btn--cancel\">Cancel</button>\n</div>\n</div>`;\n        document.body.appendChild(modal);\n        const dirList = modal.querySelector('#dir-list');\n        const pathInput = modal.querySelector('#dir-path-input');\n        async function updateDirList() {\n            const current = modal.currentPath;\n            const list = directoryCache[current] || [];\n            dirList.innerHTML = '';\n\n            const up = document.createElement('li');\n            up.textContent = '..';\n            up.onclick = () => {\n                if (current !== '/') {\n                    modal.currentPath = current.split('/').slice(0, -1).join('/') || '/';\n                    showPath(modal.currentPath);\n                }\n            };\n            dirList.appendChild(up);\n\n            (directoryCache[current] || []).sort().forEach((name) => {\n                const li = document.createElement('li');\n                li.textContent = name;\n                li.onclick = () => {\n                    modal.currentPath = current.endsWith('/')\n                        ? current + name\n                        : current + '/' + name;\n                    showPath(modal.currentPath);\n                };\n                li.ondblclick = () => {\n                    inputElement.value = modal.currentPath;\n                    closeModal();\n                };\n                dirList.appendChild(li);\n            });\n        }\n\n        function showPath(val) {\n            modal.currentPath = val;\n            pathInput.value = val;\n            if (!directoryCache[val]) {\n                fetch(`/api/list?path=${encodeURIComponent(val)}`)\n                    .then((res) => res.json())\n                    .then((d) => {\n                        directoryCache[val] = d.directories;\n                        updateDirList();\n                    })\n                    .catch((e) => {\n                        console.error('List error:', e);\n                    });\n            } else {\n                updateDirList();\n            }\n        }\n\n        function closeModal() {\n            modal.classList.remove('show');\n            window.currentInput = null;\n        }\n        pathInput.addEventListener('input', () => {\n            const val = pathInput.value.trim() || '/';\n            modal.currentPath = val;\n            clearTimeout(suggestionTimeout);\n            suggestionTimeout = setTimeout(() => {\n                const parent = val === '/' ? '/' : val.replace(/\\/?[^/]+$/, '') || '/';\n                const partial = val.slice(parent.length).replace(/^\\/+/, '').toLowerCase();\n                const entries = directoryCache[parent] || [];\n                if (entries.length) {\n                    dirList.innerHTML = '';\n\n                    const up = document.createElement('li');\n                    up.textContent = '..';\n                    up.onclick = () => {\n                        if (parent !== '/') {\n                            modal.currentPath = parent.split('/').slice(0, -1).join('/') || '/';\n                            showPath(modal.currentPath);\n                        }\n                    };\n                    dirList.appendChild(up);\n\n                    entries\n                        .filter((name) => name.toLowerCase().startsWith(partial))\n                        .sort()\n                        .forEach((name) => {\n                            const li = document.createElement('li');\n                            li.textContent = name;\n                            li.onclick = () => {\n                                modal.currentPath = parent.endsWith('/')\n                                    ? parent + name\n                                    : parent + '/' + name;\n                                showPath(modal.currentPath);\n                            };\n                            li.ondblclick = () => {\n                                inputElement.value = modal.currentPath;\n                                closeModal();\n                            };\n                            dirList.appendChild(li);\n                        });\n                }\n\n                const entry = val.slice(parent.length).replace(/^\\/+/, '');\n                if (directoryCache[parent]?.includes(entry)) {\n                    showPath(val);\n                }\n            }, 200);\n        });\n        pathInput.addEventListener('keydown', (e) => {\n            if (e.key === 'Enter') {\n                e.preventDefault();\n                showPath(pathInput.value.trim() || '/');\n            }\n        });\n        modal.querySelector('#dir-create').onclick = async () => {\n            const name = prompt('New folder name:');\n            if (!name) return;\n            const newPath = modal.currentPath.endsWith('/')\n                ? modal.currentPath + name\n                : modal.currentPath + '/' + name;\n            try {\n                await fetch(`/api/create-folder?path=${encodeURIComponent(newPath)}`, {\n                    method: 'POST',\n                });\n                if (!directoryCache[modal.currentPath]) directoryCache[modal.currentPath] = [];\n                directoryCache[modal.currentPath].push(name);\n                showPath(newPath);\n            } catch (e) {\n                alert('Create failed: ' + e.message);\n            }\n        };\n        modal.querySelector('#dir-cancel').onclick = closeModal;\n\n        modal.updateDirList = updateDirList;\n        modal.showPath = showPath;\n        modal.closeModal = closeModal;\n    }\n\n    modal.currentInput = inputElement;\n\n    const acceptBtn = modal.querySelector('#dir-accept');\n    acceptBtn.onclick = () => {\n        modal.currentInput.value = modal.currentPath;\n        modal.closeModal();\n    };\n    modal.currentPath = inputElement.value.trim() || '/';\n    const pathInput = modal.querySelector('#dir-path-input');\n    pathInput.value = modal.currentPath;\n\n    if (inputElement.placeholder) {\n        pathInput.placeholder = inputElement.placeholder;\n    }\n\n    if (!directoryCache[modal.currentPath]) {\n        fetch(`/api/list?path=${encodeURIComponent(modal.currentPath)}`)\n            .then((res) => res.json())\n            .then((d) => {\n                directoryCache[modal.currentPath] = d.directories;\n                modal.updateDirList();\n            });\n    } else {\n        modal.updateDirList();\n    }\n    modal.classList.add('show');\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/border_replacerr.js",
    "content": "import { renderHelp } from '../../helper.js';\nimport { renderTextareaArrayField } from '../settings_helpers.js';\nimport { borderReplacerrModal } from '../modals.js';\nimport { renderField, renderRemoveBordersBooleanField } from '../settings_helpers.js';\n\nlet borderReplacerrData = [];\n\nexport function renderReplacerrSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    wrapper.className = 'settings-wrapper';\n\n    const help = renderHelp('border_replacerr');\n    if (help) wrapper.appendChild(help);\n\n    Object.entries(config).forEach(([key, value]) => {\n        if (\n            !['holidays', 'border_colors', 'remove_borders', 'exclusion_list', 'exclude'].includes(\n                key\n            )\n        ) {\n            renderField(wrapper, key, value);\n        }\n    });\n\n    ['exclusion_list', 'exclude'].forEach((fieldKey) => {\n        if (config[fieldKey]) {\n            wrapper.appendChild(renderTextareaArrayField(fieldKey, config[fieldKey]));\n        }\n    });\n\n    let removeBordersField = renderRemoveBordersBooleanField(config);\n    wrapper.appendChild(removeBordersField);\n\n    // Border Colors\n    const borderColorField = document.createElement('div');\n    borderColorField.className = 'field';\n    borderColorField.innerHTML = `\n        <label>Border Colors</label>\n        <button type=\"button\" id=\"addBorderColor\" class=\"btn add-control-btn\">➕ Add Color</button>\n        <div id=\"border-colors-container\"></div>\n    `;\n    wrapper.appendChild(borderColorField);\n\n    const borderColorsContainer = borderColorField.querySelector('#border-colors-container');\n    function updateBorderColorsFromDOM() {\n        config.border_colors = Array.from(\n            borderColorsContainer.querySelectorAll('input[type=\"color\"]')\n        ).map((input) => input.value);\n\n        if (removeBordersField && removeBordersField.parentNode)\n            removeBordersField.parentNode.removeChild(removeBordersField);\n        removeBordersField = renderRemoveBordersBooleanField(config);\n\n        let insertAfter = null;\n        for (let i = wrapper.children.length - 1; i >= 0; i--) {\n            const node = wrapper.children[i];\n            const label = node.querySelector && node.querySelector('label');\n            if (label && /exclusion list|exclude/i.test(label.textContent.trim())) {\n                insertAfter = node;\n                break;\n            }\n        }\n        if (insertAfter && insertAfter.nextSibling)\n            wrapper.insertBefore(removeBordersField, insertAfter.nextSibling);\n        else if (insertAfter) wrapper.appendChild(removeBordersField);\n        else wrapper.insertBefore(removeBordersField, borderColorField);\n    }\n    function addColorPicker(container, color = '#ffffff') {\n        const subfield = document.createElement('div');\n        subfield.className = 'subfield';\n        subfield.innerHTML = `\n            <input type=\"color\" value=\"${color}\"/>\n            <button type=\"button\" class=\"remove-color btn--cancel btn--remove-item btn\">−</button>\n        `;\n        const colorInput = subfield.querySelector('input[type=\"color\"]');\n        colorInput.addEventListener('input', updateBorderColorsFromDOM);\n        subfield.querySelector('.remove-color').onclick = () => {\n            subfield.remove();\n            updateBorderColorsFromDOM();\n        };\n        container.appendChild(subfield);\n        updateBorderColorsFromDOM();\n    }\n    (config.border_colors || []).forEach((color) => addColorPicker(borderColorsContainer, color));\n    borderColorField.querySelector('#addBorderColor').onclick = () =>\n        addColorPicker(borderColorsContainer, '#ffffff');\n\n    if (rootConfig?.poster_renamerr?.run_border_replacerr === true) {\n        ['source_dirs', 'destination_dir'].forEach((fieldKey) => {\n            const fields = wrapper.querySelectorAll(`[name=\"${fieldKey}\"]`);\n            fields.forEach((field) => {\n                field.disabled = true;\n                field.value = '';\n                field.placeholder = \"🔒 Managed by Poster Renamerr with 'Run Border Replacerr'\";\n                field.title = \"Managed by Poster Renamerr with 'Run Border Replacerr'\";\n                if (fieldKey === 'source_dirs') {\n                    const fieldContainer = field.closest('.field');\n                    if (fieldContainer) {\n                        const addBtn = fieldContainer.querySelector('.add-control-btn');\n                        if (addBtn) addBtn.style.display = 'none';\n                        fieldContainer\n                            .querySelectorAll('.remove-item')\n                            .forEach((btn) => (btn.style.display = 'none'));\n                    }\n                }\n            });\n        });\n    }\n\n    const holidaysField = document.createElement('div');\n    holidaysField.className = 'field';\n    holidaysField.innerHTML = `\n        <label>Holidays</label>\n        <button type=\"button\" id=\"add-holiday-btn\" class=\"btn add-control-btn\">➕ Add Holiday</button>\n        <div id=\"holidays-container\"></div>\n    `;\n    wrapper.appendChild(holidaysField);\n\n    borderReplacerrData = Object.entries(config.holidays || {}).map(([holiday, details]) => ({\n        holiday,\n        schedule: details.schedule,\n        color: details.color,\n    }));\n\n    const holidaysContainer = holidaysField.querySelector('#holidays-container');\n    function updateBorderReplacerrUI() {\n        holidaysContainer.innerHTML = '';\n        if (borderReplacerrData.length === 0) {\n            holidaysContainer.innerHTML = `<p class=\"no-entries\">🎄 No holidays configured yet.</p>`;\n        } else {\n            borderReplacerrData.forEach((entry, i) => {\n                const card = document.createElement('div');\n                card.className = 'holiday-card card show-card';\n                card.innerHTML = `\n                    <div class=\"holiday-header\">\n                        <span><strong>${entry.holiday}</strong></span>\n                        <span>${entry.schedule}</span>\n                        <div class=\"holiday-actions\">\n                            <button type=\"button\" class=\"btn edit-btn\" data-idx=\"${i}\">Edit</button>\n                            <button type=\"button\" class=\"remove-btn btn--cancel btn--remove-item btn\" data-idx=\"${i}\">-</button>\n                        </div>\n                    </div>\n                    <div>${entry.color\n                        .map((c) => `<span class=\"holiday-swatch\" style=\"background:${c}\"></span>`)\n                        .join('')}</div>\n                `;\n                holidaysContainer.appendChild(card);\n            });\n            holidaysContainer.querySelectorAll('.edit-btn').forEach((btn) => {\n                btn.onclick = () => {\n                    const idx = parseInt(btn.dataset.idx, 10);\n                    if (!isNaN(idx))\n                        borderReplacerrModal(idx, borderReplacerrData, updateBorderReplacerrUI);\n                };\n            });\n            holidaysContainer.querySelectorAll('.remove-btn').forEach((btn) => {\n                btn.onclick = () => {\n                    const confirmed = confirm('Are you sure you want to remove this holiday?');\n                    if (confirmed) {\n                        const idx = parseInt(btn.dataset.idx, 10);\n                        borderReplacerrData.splice(idx, 1);\n                        updateBorderReplacerrUI();\n                    }\n                };\n            });\n        }\n    }\n    holidaysField.querySelector('#add-holiday-btn').onclick = () =>\n        borderReplacerrModal(null, borderReplacerrData, updateBorderReplacerrUI);\n\n    updateBorderReplacerrUI();\n\n    formFields.appendChild(wrapper);\n}\n\nexport function getBorderReplacerrData() {\n    return borderReplacerrData;\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/health_checkarr.js",
    "content": "import { renderHelp } from '../../helper.js';\nimport { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js';\n\nexport function renderHealthCheckarrSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('health_checkarr');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        if (key === 'instances') {\n            renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'health_checkarr');\n        } else {\n            renderField(wrapper, key, value);\n        }\n    });\n    formFields.appendChild(wrapper);\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/jduparr.js",
    "content": "import { renderField } from '../settings_helpers.js';\nimport { renderHelp } from '../../helper.js';\n\nexport function renderJduparrSettings(formFields, config) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('jduparr');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        renderField(wrapper, key, value);\n    });\n    formFields.appendChild(wrapper);\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/labelarr.js",
    "content": "import { renderHelp } from '../../helper.js';\nimport { renderField } from '../settings_helpers.js';\nimport { labelarrModal } from '../modals.js';\nimport { humanize } from '../../common.js';\n\nlet labelarrData = [];\n\nexport function renderLabelarrSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    wrapper.className = 'settings-wrapper';\n\n    const help = renderHelp('labelarr');\n    if (help) wrapper.appendChild(help);\n\n    Object.entries(config).forEach(([key, value]) => {\n        if (key !== 'mappings') {\n            renderField(wrapper, key, value);\n        }\n    });\n\n    const mappingsField = document.createElement('div');\n    mappingsField.className = 'field setting-field';\n    mappingsField.innerHTML = `\n        <label>Mappings</label>\n        <button type=\"button\" id=\"add-mapping-btn\" class=\"btn add-control-btn\">➕ Add Mapping</button>\n        <div id=\"labelarr-mappings-container\" class=\"mappings-container\"></div>\n    `;\n    wrapper.appendChild(mappingsField);\n\n    const mappingsContainer = mappingsField.querySelector('#labelarr-mappings-container');\n\n    labelarrData = Array.isArray(config.mappings)\n        ? JSON.parse(JSON.stringify(config.mappings))\n        : [];\n\n    function updateMappings() {\n        mappingsContainer.innerHTML = '';\n        if (labelarrData.length === 0) {\n            mappingsContainer.innerHTML = `\n                <div class=\"no-entries labelarr-no-mappings\">\n                    🚫 No mappings yet. <br>\n                    Click <b>\"Add Mapping\"</b> to create your first sync mapping.\n                </div>\n            `;\n            return;\n        }\n        labelarrData.forEach((entry, i) => {\n            try {\n                const card = document.createElement('div');\n                card.className = 'labelarr-mapping-card card show-card';\n\n                const left = document.createElement('div');\n                left.className = 'labelarr-mapping-left';\n                left.innerHTML = `\n                    <div class=\"mapping-app\">${humanize(entry.app_type)}</div>\n                    <div class=\"mapping-instance\">Instance <span>${humanize(\n                        entry.app_instance\n                    )}:</span></div>\n                    <div class=\"mapping-labels\">\n                        ${\n                            entry.labels && entry.labels.length\n                                ? entry.labels\n                                      .map(\n                                          (l) =>\n                                              `<span class=\"labelarr-label\">${humanize(l)}</span>`\n                                      )\n                                      .join('')\n                                : '<span class=\"labelarr-label labelarr-label-empty\">No label</span>'\n                        }\n                    </div>\n                `;\n\n                const center = document.createElement('div');\n                center.className = 'labelarr-mapping-center';\n                center.innerHTML = `<span class=\"labelarr-arrow\">→</span>`;\n\n                const right = document.createElement('div');\n                right.className = 'labelarr-mapping-right';\n\n                let plexHtml = '';\n                (entry.plex_instances || []).forEach((inst) => {\n                    const instance =\n                        inst.instance || Object.keys(inst).find((k) => k !== 'library_names');\n                    const libraries = Array.isArray(inst.library_names) ? inst.library_names : [];\n                    plexHtml += `\n                        <div class=\"labelarr-plex-target\">\n                            <span class=\"labelarr-plex-instance\">${humanize(instance)}:</span>\n                            ${libraries\n                                .map(\n                                    (lib) =>\n                                        `<span class=\"labelarr-library\">${humanize(lib)}</span>`\n                                )\n                                .join('')}\n                        </div>\n                    `;\n                });\n                right.innerHTML =\n                    plexHtml || `<span class=\"labelarr-plex-none\">No Plex Target</span>`;\n\n                const actions = document.createElement('div');\n                actions.className = 'labelarr-mapping-actions';\n                actions.innerHTML = `\n                    <button type=\"button\" class=\"edit-btn btn\" data-idx=\"${i}\">Edit</button>\n                    <button type=\"button\" class=\"remove-btn btn--cancel btn--remove-item btn\" data-idx=\"${i}\">-</button>\n                `;\n\n                card.appendChild(left);\n                card.appendChild(center);\n                card.appendChild(right);\n                card.appendChild(actions);\n\n                mappingsContainer.appendChild(card);\n            } catch (err) {\n                console.error('Error rendering mapping entry:', entry, err);\n            }\n        });\n\n        mappingsContainer.querySelectorAll('.remove-btn').forEach((btn) => {\n            btn.onclick = () => {\n                const idx = parseInt(btn.dataset.idx, 10);\n                if (!isNaN(idx)) {\n                    const confirmed = confirm('Are you sure you want to remove this mapping?');\n                    if (!confirmed) return;\n                    labelarrData.splice(idx, 1);\n                    updateMappings();\n                }\n            };\n        });\n\n        mappingsContainer.querySelectorAll('.edit-btn').forEach((btn) => {\n            btn.onclick = () => {\n                const idx = parseInt(btn.dataset.idx, 10);\n                if (!isNaN(idx)) {\n                    labelarrModal(idx, labelarrData, rootConfig, updateMappings);\n                }\n            };\n        });\n    }\n\n    mappingsField.querySelector('#add-mapping-btn').onclick = () =>\n        labelarrModal(undefined, labelarrData, rootConfig, updateMappings);\n\n    updateMappings();\n\n    formFields.appendChild(wrapper);\n\n    wrapper.flushLabelarrToConfig = () => {\n        config.mappings = JSON.parse(JSON.stringify(labelarrData));\n    };\n}\n\nexport function getLabelarrData() {\n    return labelarrData;\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/main.js",
    "content": "import { renderField } from '../settings_helpers.js';\nimport { renderHelp } from '../../helper.js';\n\nexport function renderMain(formFields, config) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('main');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        renderField(wrapper, key, value);\n    });\n    formFields.appendChild(wrapper);\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/nohl.js",
    "content": "import { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js';\nimport { renderHelp } from '../../helper.js';\n\nexport function renderNohlSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('nohl');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        if (key === 'source_dirs') {\n            renderField(wrapper, key, value);\n        } else if (key === 'instances') {\n            renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'nohl');\n        } else {\n            renderField(wrapper, key, value);\n        }\n    });\n    formFields.appendChild(wrapper);\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/poster_cleanarr.js",
    "content": "import { renderHelp } from '../../helper.js';\nimport { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js';\n\nexport function renderPosterCleanarrSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('poster_cleanarr');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        if (key === 'instances') {\n            renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'poster_cleanarr');\n        } else {\n            renderField(wrapper, key, value);\n        }\n    });\n    formFields.appendChild(wrapper);\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/poster_renamerr.js",
    "content": "import { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js';\nimport { renderHelp } from '../../helper.js';\n\nexport function renderPosterRenamerrSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('poster_renamerr');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        if (key === 'instances') {\n            renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'poster_renamerr');\n        } else {\n            renderField(wrapper, key, value);\n        }\n    });\n    formFields.appendChild(wrapper);\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/renameinatorr.js",
    "content": "import { renderHelp } from '../../helper.js';\nimport { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js';\n\nexport function renderRenameinatorrSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('renameinatorr');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        if (key === 'instances') {\n            renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'renameinatorr');\n        } else {\n            renderField(wrapper, key, value);\n        }\n    });\n    formFields.appendChild(wrapper);\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/sync_gdrive.js",
    "content": "import { gdriveSyncModal } from '../modals.js';\nimport { renderHelp } from '../../helper.js';\nimport { renderField, renderTextareaArrayField } from '../settings_helpers.js';\n\nlet gdriveSyncData = [];\n\nexport function renderGdriveSettings(formFields, config) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('gdrive_sync');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        if (key === 'token') {\n            wrapper.appendChild(renderTextareaArrayField(key, value));\n        } else if (key === 'gdrive_list') {\n            const field = document.createElement('div');\n            field.className = 'field setting-field';\n            field.innerHTML = `\n                <label>GDrive Sync</label>\n                <button type=\"button\" id=\"add-gdrive-sync\" class=\"btn add-control-btn\">➕ Add gDrive</button>\n                <div id=\"gdrive-sync-list\" class=\"sync-list-container\"></div>\n            `;\n            wrapper.appendChild(field);\n            const syncList = field.querySelector('#gdrive-sync-list');\n            gdriveSyncData = Array.isArray(value) ? [...value] : [];\n\n            function updateList() {\n                if (!Array.isArray(gdriveSyncData) || gdriveSyncData.length === 0) {\n                    syncList.innerHTML = `\n                        <div class=\"no-entries\">\n                          <p>🚫 No drives to list.</p>\n                          <p>Click <strong>\"Add gDrive\"</strong> to configure one.</p>\n                        </div>\n                    `;\n                } else {\n                    const validEntries = gdriveSyncData.filter((e) => e && e.id && e.location);\n                    if (validEntries.length === 0) {\n                        syncList.innerHTML = `\n                            <div class=\"no-entries\">\n                              <p>🚫 No valid drives to list.</p>\n                              <p>Click <strong>\"Add gDrive\"</strong> to configure one.</p>\n                            </div>\n                        `;\n                        return;\n                    }\n                    syncList.innerHTML = validEntries\n                        .map(\n                            (entry, i) => `\n                      <div class=\"card setting-entry show-card\">\n                        <div class=\"setting-entry-content\">\n                          <strong>${entry.name || entry.id}</strong> → <em class=\"path-text\">${\n                                entry.location\n                            }</em>\n                        </div>\n                        <div class=\"setting-entry-actions\">\n                          <button type=\"button\" data-idx=\"${i}\" class=\"edit-btn btn\">Edit</button>\n                          <button type=\"button\" data-idx=\"${i}\" class=\"remove-btn btn--cancel btn--remove-item btn\">-</button>\n                        </div>\n                      </div>\n                    `\n                        )\n                        .join('');\n                }\n                syncList.querySelectorAll('.remove-btn').forEach((btn) => {\n                    btn.onclick = () => {\n                        const confirmed = confirm('Are you sure you want to remove this sync?');\n                        if (confirmed) {\n                            gdriveSyncData.splice(parseInt(btn.dataset.idx), 1);\n                            updateList();\n                        }\n                    };\n                });\n                syncList.querySelectorAll('.edit-btn').forEach((btn) => {\n                    btn.onclick = () => {\n                        const idx = parseInt(btn.dataset.idx, 10);\n                        gdriveSyncModal(idx, gdriveSyncData, updateList);\n                    };\n                });\n            }\n            field.querySelector('#add-gdrive-sync').onclick = () =>\n                gdriveSyncModal(undefined, gdriveSyncData, updateList);\n            updateList();\n        } else {\n            renderField(wrapper, key, value);\n        }\n    });\n    formFields.appendChild(wrapper);\n}\n\nexport function getGdriveSyncData() {\n    return gdriveSyncData;\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/unmatched_assets.js",
    "content": "import { renderHelp } from '../../helper.js';\nimport { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js';\n\nexport function renderUnmatchedAssetsSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    const help = renderHelp('unmatched_assets');\n    if (help) wrapper.appendChild(help);\n    wrapper.className = 'settings-wrapper';\n    Object.entries(config).forEach(([key, value]) => {\n        if (key === 'instances') {\n            renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'unmatched_assets');\n        } else {\n            renderField(wrapper, key, value);\n        }\n    });\n    formFields.appendChild(wrapper);\n}\n"
  },
  {
    "path": "web/static/js/settings/modules/upgradinatorr.js",
    "content": "import { renderField } from '../settings_helpers.js';\nimport { renderHelp } from '../../helper.js';\nimport { upgradinatorrModal } from '../modals.js';\nimport { humanize } from '../../common.js';\n\nlet upgradinatorrData = [];\n\nexport function renderUpgradinatorrSettings(formFields, config, rootConfig) {\n    const wrapper = document.createElement('div');\n    wrapper.className = 'settings-wrapper';\n\n    const help = renderHelp('upgradinatorr');\n    if (help) wrapper.appendChild(help);\n\n    Object.entries(config).forEach(([key, value]) => {\n        if (key !== 'instances_list') {\n            if (typeof value === 'object' && !Array.isArray(value) && value !== null) {\n                return;\n            }\n            renderField(wrapper, key, value);\n        }\n    });\n\n    const instanceField = document.createElement('div');\n    instanceField.className = 'field setting-field';\n    instanceField.innerHTML = `\n        <label>Instances</label>\n        <button type=\"button\" id=\"add-instance-btn\" class=\"btn add-control-btn\">➕ Add Instance</button>\n        <div class=\"card-body\">\n            <table id=\"upgradinatorr-table\" class=\"upgradinatorr-table\">\n                <thead>\n                    <tr>\n                        <th>Instance</th>\n                        <th>Count</th>\n                        <th>Tag Name</th>\n                        <th>Ignore Tag</th>\n                        <th>Unattended</th>\n                        <th>Threshold</th>\n                        <th>Actions</th>\n                    </tr>\n                </thead>\n                <tbody></tbody>\n            </table>\n        </div>\n    `;\n    wrapper.appendChild(instanceField);\n\n    const tbody = instanceField.querySelector('tbody');\n    upgradinatorrData = Object.entries(config.instances_list || {}).map(([inst, opts]) => {\n        const entry = {\n            instance: opts.instance,\n            count: opts.count,\n            tag_name: opts.tag_name,\n            ignore_tag: opts.ignore_tag,\n            unattended: opts.unattended,\n        };\n        if (typeof opts.season_monitored_threshold !== 'undefined') {\n            entry.season_monitored_threshold = opts.season_monitored_threshold;\n        }\n        return entry;\n    });\n\n    function updateTable() {\n        tbody.innerHTML = upgradinatorrData\n            .map(\n                (entry, i) => `\n            <tr>\n                <td>${humanize(entry.instance)}</td>\n                <td>${entry.count}</td>\n                <td>${entry.tag_name}</td>\n                <td>${entry.ignore_tag}</td>\n                <td>${entry.unattended}</td>\n                <td>${entry.season_monitored_threshold ?? ''}</td>\n                <td>\n                    <button type=\"button\" class=\"edit-upgrade btn\" data-idx=\"${i}\">Edit</button>\n                    <button type=\"button\" class=\"remove-btn btn--cancel btn--remove-item btn\" data-idx=\"${i}\">-</button>\n                </td>\n            </tr>\n        `\n            )\n            .join('');\n        tbody.querySelectorAll('.remove-btn').forEach((btn) => {\n            btn.onclick = () => {\n                const confirmed = confirm('Are you sure you want to remove this instance?');\n                if (confirmed) {\n                    const idx = parseInt(btn.dataset.idx, 10);\n                    upgradinatorrData.splice(idx, 1);\n                    updateTable();\n                }\n            };\n        });\n        tbody.querySelectorAll('.edit-upgrade').forEach((btn) => {\n            btn.onclick = () => {\n                const idx = parseInt(btn.dataset.idx, 10);\n                upgradinatorrModal(idx, upgradinatorrData, rootConfig, updateTable);\n            };\n        });\n    }\n\n    instanceField\n        .querySelector('#add-instance-btn')\n        .addEventListener('click', () =>\n            upgradinatorrModal(undefined, upgradinatorrData, rootConfig, updateTable)\n        );\n    updateTable();\n\n    formFields.appendChild(wrapper);\n}\n\nexport function getUpgradinatorrData() {\n    return upgradinatorrData;\n}\n"
  },
  {
    "path": "web/static/js/settings/presets.js",
    "content": "export const holidayPresets = {\n    \"🎆 New Year's Day\": {\n        schedule: 'range(12/30-01/02)',\n        colors: ['#00BFFF', '#FFD700'],\n    },\n    \"💘 Valentine's Day\": {\n        schedule: 'range(02/05-02/15)',\n        colors: ['#D41F3A', '#FFC0CB'],\n    },\n    '🐣 Easter': {\n        schedule: 'range(03/31-04/02)',\n        colors: ['#FFB6C1', '#87CEFA', '#98FB98'],\n    },\n    \"🌸 Mother's Day\": {\n        schedule: 'range(05/10-05/15)',\n        colors: ['#FF69B4', '#FFDAB9'],\n    },\n    \"👨‍👧‍👦 Father's Day\": {\n        schedule: 'range(06/15-06/20)',\n        colors: ['#1E90FF', '#4682B4'],\n    },\n    '🗽 Independence Day': {\n        schedule: 'range(07/01-07/05)',\n        colors: ['#FF0000', '#FFFFFF', '#0000FF'],\n    },\n    '🧹 Labor Day': {\n        schedule: 'range(09/01-09/07)',\n        colors: ['#FFD700', '#4682B4'],\n    },\n    '🎃 Halloween': {\n        schedule: 'range(10/01-10/31)',\n        colors: ['#FFA500', '#000000'],\n    },\n    '🦃 Thanksgiving': {\n        schedule: 'range(11/01-11/30)',\n        colors: ['#FFA500', '#8B4513'],\n    },\n    '🎄 Christmas': {\n        schedule: 'range(12/01-12/31)',\n        colors: ['#FF0000', '#00FF00'],\n    },\n};\n"
  },
  {
    "path": "web/static/js/settings/settings_helpers.js",
    "content": "import { directoryPickerModal } from './modals.js';\nimport {\n    BOOL_FIELDS,\n    TEXT_FIELDS,\n    TEXTAREA_FIELDS,\n    INT_FIELDS,\n    DROP_DOWN_OPTIONS,\n    DROP_DOWN_FIELDS,\n    DIR_PICKER,\n    ARR_AND_PLEX_INSTANCES,\n    PLACEHOLDER_TEXT,\n    DRAG_AND_DROP,\n    LIST_FIELD,\n    SHOW_PLEX_IN_INSTANCE_FIELD,\n} from './constants.js';\nimport { humanize, showToast } from '../common.js';\n\nfunction createListField(name, list) {\n    const label = humanize(name);\n    const moduleName = window.currentModuleName;\n    const placeholder = PLACEHOLDER_TEXT[moduleName]?.[name] ?? '';\n\n    const field = document.createElement('div');\n    field.className = 'field setting-field';\n    field.innerHTML = `\n            <label>${label}</label>\n            <button type=\"button\" class=\"btn add-control-btn\">➕ Add ${label}</button>\n            <div class=\"subfield-list\"></div>\n        `;\n    const container = field.querySelector('.subfield-list');\n    let data = Array.isArray(list) ? [...list] : [];\n    const supportsMode = moduleName === 'nohl';\n    data = data.map((entry) => {\n        if (typeof entry === 'string') {\n            return supportsMode\n                ? {\n                      path: entry,\n                      mode: 'scan',\n                  }\n                : {\n                      path: entry,\n                  };\n        }\n        return entry;\n    });\n    if (data.length === 0) {\n        data = [\n            supportsMode\n                ? {\n                      path: '',\n                      mode: 'scan',\n                  }\n                : {\n                      path: '',\n                  },\n        ];\n    }\n\n    function renderSubfield(entry) {\n        const sub = document.createElement('div');\n        sub.className = 'subfield';\n        sub.innerHTML = `\n                <input type=\"text\" class=\"input\" name=\"${name}\" value=\"${\n            entry.path\n        }\" readonly placeholder=\"${placeholder}\" />\n                ${\n                    supportsMode\n                        ? `\n                    <select class=\"select source-mode\" name=\"mode\">\n                        <option value=\"resolve\"${\n                            entry.mode === 'resolve' ? ' selected' : ''\n                        }>Resolve</option>\n                        <option value=\"scan\"${\n                            entry.mode === 'scan' ? ' selected' : ''\n                        }>Scan</option>\n                    </select>`\n                        : ''\n                }\n                <button type=\"button\" class=\"btn--cancel remove-directory btn\">−</button>\n            `;\n        const txt = sub.querySelector('input');\n        txt.addEventListener('click', () => directoryPickerModal(txt));\n        sub.querySelector('.remove-directory').onclick = () => {\n            sub.remove();\n            updateRemoveButtons();\n        };\n        return sub;\n    }\n    data.forEach((entry) => container.appendChild(renderSubfield(entry)));\n\n    function updateRemoveButtons() {\n        const subs = container.querySelectorAll('.subfield');\n        subs.forEach((sub) => {\n            const btn = sub.querySelector('.remove-directory');\n            btn.disabled = subs.length <= 1;\n            btn.style.opacity = btn.disabled ? '0.5' : '';\n            btn.style.cursor = btn.disabled ? 'not-allowed' : '';\n        });\n    }\n    updateRemoveButtons();\n    field.querySelector('.add-control-btn').onclick = () => {\n        container.appendChild(\n            renderSubfield(\n                supportsMode\n                    ? {\n                          path: '',\n                          mode: 'resolve',\n                      }\n                    : {\n                          path: '',\n                      }\n            )\n        );\n        updateRemoveButtons();\n    };\n    return field;\n}\n\nfunction createField(label, html) {\n    const div = document.createElement('div');\n    div.className = 'field';\n    div.innerHTML = `\n      <label>${label}</label>\n      <div class=\"field-control\">${html}</div>\n    `;\n    return div;\n}\n\nfunction boolDropdown(name, selected) {\n    return `<select class=\"select\" name=\"${name}\">\n        <option value=\"true\"${selected ? ' selected' : ''}>True</option>\n        <option value=\"false\"${!selected ? ' selected' : ''}>False</option>\n    </select>`;\n}\n\nexport function renderTextField(name, value) {\n    /**\n     * Render a list-of-directories field (no drag handles, no drag logic).\n     * @param {string} name\n     * @param {string[]} list\n     */\n\n    const label = humanize(name);\n    const isDir = DIR_PICKER.includes(name);\n    const readonly = isDir ? 'readonly' : '';\n\n    const moduleName = window.currentModuleName;\n    const placeholder =\n        PLACEHOLDER_TEXT[moduleName]?.[name] ?? (isDir ? 'Click to pick a directory' : '');\n    const field = createField(\n        label,\n        `<input type=\"text\" class=\"input\" name=\"${name}\" value=\"${\n            value || ''\n        }\" ${readonly} placeholder=\"${placeholder}\" />`\n    );\n    if (isDir) {\n        const input = field.querySelector(`input[name=\"${name}\"]`);\n        input.addEventListener('click', () => directoryPickerModal(input));\n    }\n    return field;\n}\n\nexport function renderBooleanField(name, value) {\n    const label = humanize(name);\n    return createField(label, boolDropdown(name, value === true || value === 'true'));\n}\n\nexport function renderDropdownField(name, value, options) {\n    const moduleName = window.currentModuleName;\n    const placeholder = PLACEHOLDER_TEXT[moduleName]?.[name];\n\n    let html = `<select class=\"select\" name=\"${name}\">`;\n    if (placeholder) {\n        html += `<option value=\"\" disabled${\n            value == null || value === '' ? ' selected' : ''\n        }>${placeholder}</option>`;\n    }\n    html += options\n        .map(\n            (opt) =>\n                `<option value=\"${opt}\"${value === opt ? ' selected' : ''}>${humanize(\n                    opt\n                )}</option>`\n        )\n        .join('');\n    html += `</select>`;\n    return createField(humanize(name), html);\n}\n\n/**\n * Render a textarea for array or JSON input, auto-resizing to content.\n * @param {string} name - The field name (key).\n * @param {Array|Object|string} values - Array of lines or JSON object/string.\n * @returns {HTMLDivElement} The created field element.\n */\nexport function renderTextareaArrayField(name, values) {\n    let content = '';\n    let placeholder = '';\n    if (name === 'token') {\n        placeholder = PLACEHOLDER_TEXT?.[window.currentModuleName]?.token ?? '';\n        content =\n            values === null || values === 'null'\n                ? ''\n                : typeof values === 'object'\n                ? JSON.stringify(values, null, 2)\n                : values;\n    } else {\n        content = Array.isArray(values) ? values.join('\\n') : '';\n        placeholder = 'Enter items, one per line';\n    }\n    const moduleName = window.currentModuleName;\n    placeholder = PLACEHOLDER_TEXT?.[moduleName]?.[name] ?? placeholder;\n\n    const textarea = document.createElement('textarea');\n    textarea.name = name;\n    textarea.rows = 6;\n    textarea.className = 'textarea';\n    textarea.value = content;\n    textarea.placeholder = placeholder;\n    const field = createField(humanize(name), '');\n    field.querySelector('.field-control').appendChild(textarea);\n    setTimeout(() => {\n        if (textarea) {\n            textarea.style.height = 'auto';\n            textarea.style.height = textarea.scrollHeight + 'px';\n            textarea.addEventListener('input', () => {\n                textarea.style.height = 'auto';\n                textarea.style.height = textarea.scrollHeight + 'px';\n            });\n        }\n    }, 0);\n    return field;\n}\n\n/**\n * Render a number input field.\n * @param {string} name - The field name.\n * @param {number} value - The current value.\n * @returns {HTMLDivElement} The created field element.\n */\nexport function renderNumberField(name, value) {\n    const label = humanize(name);\n    const html = `<input type=\"number\" class=\"input number-field\" name=\"${name}\" value=\"${value}\" min=\"0\" step=\"1\" />`;\n    const div = createField(label, html);\n    div.classList.add('show-field');\n    return div;\n}\n\nexport function renderRemoveBordersBooleanField(config) {\n    const name = 'remove_borders';\n    const label = humanize(name);\n    const borderColors = Array.isArray(config.border_colors)\n        ? config.border_colors.filter(Boolean)\n        : [];\n    let forcedValue,\n        disabled,\n        warning = '';\n    if (borderColors.length === 0) {\n        forcedValue = true;\n        disabled = true;\n        warning =\n            'Borders will be removed because no border colors are set. Add a border color to disable this option.';\n    } else {\n        forcedValue = false;\n        disabled = true;\n        warning =\n            'Cannot remove borders while custom border colors are set. Remove all border colors to enable this option.';\n    }\n    let html = `<select class=\"select\" name=\"${name}\"${disabled ? ' disabled' : ''}>\n        <option value=\"true\"${forcedValue ? ' selected' : ''}>True</option>\n        <option value=\"false\"${!forcedValue ? ' selected' : ''}>False</option>\n    </select>`;\n    html += `<div class=\"field-hint\">\n        <strong>Note:</strong> This setting is <b>automatically controlled</b>:\n        <ul style=\"margin:0.25em 0 0.25em 1.5em;padding:0;\">\n            <li>If any border colors are set, borders will not be removed.</li>\n            <li>If no border colors are set, borders will always be removed.</li>\n        </ul>\n        ${warning ? `<span class=\"field-hint-warning-text\">${warning}</span>` : ''}\n    </div>`;\n    const div = createField(label, html);\n    div.classList.add('show-field');\n    return div;\n}\n\nexport function renderPlexSonarrRadarrInstancesField(\n    formFields,\n    instanceList,\n    rootConfig,\n    moduleName\n) {\n    const allInstancesEmpty =\n        !rootConfig.instances ||\n        !Object.values(rootConfig.instances).some(\n            (group) => group && typeof group === 'object' && Object.keys(group).length > 0\n        );\n    if (allInstancesEmpty) {\n        const field = document.createElement('div');\n        field.className = `field instances-field ${moduleName}`;\n        field.innerHTML = `<label>Instances</label><div class=\"instances-list\"></div>`;\n        formFields.appendChild(field);\n\n        const listDiv = field.querySelector('.instances-list');\n        const noCard = document.createElement('div');\n        noCard.className = 'card plex-instance-card';\n        noCard.innerHTML = `\n          <div class=\"plex-libraries\">\n            <p class=\"no-entries\" style=\"margin: 0.5em 0 0 1em;\">\n              🚫 No instances configured for ${humanize(moduleName)}.\n            </p>\n          </div>\n        `;\n        listDiv.appendChild(noCard);\n        return;\n    }\n    const field = document.createElement('div');\n    field.className = 'field instances-field poster-cleanarr';\n    field.innerHTML = `<label>Instances</label><div class=\"instances-list\"></div>`;\n    formFields.appendChild(field);\n    const listDiv = field.querySelector('.instances-list');\n    const scalarInst = [];\n    const plexData = {};\n    (instanceList || []).forEach((item) => {\n        if (typeof item === 'string') scalarInst.push(item);\n        else if (typeof item === 'object') {\n            const inst = Object.keys(item)[0];\n            plexData[inst] = item[inst];\n        }\n    });\n\n    function renderARRGroupCard(instType, instances) {\n        const card = document.createElement('div');\n        card.className = `card plex-instance-card`;\n        card.innerHTML = `\n          <div class=\"plex-instance-header\">\n            <h3>${humanize(instType)}</h3>\n          </div>\n          <div class=\"plex-libraries open\"></div>\n        `;\n        const groupDiv = card.querySelector('.plex-libraries.open');\n        instances.forEach((instanceName) => {\n            const label = document.createElement('label');\n            label.className = 'library-pill';\n            label.innerHTML = `\n                <input type=\"checkbox\" name=\"instances\" value=\"${instanceName}\" ${\n                scalarInst.includes(instanceName) ? 'checked' : ''\n            }/>\n                ${humanize(instanceName)}\n            `;\n            groupDiv.appendChild(label);\n        });\n        return card;\n    }\n\n    const radarrInstances = Object.keys(rootConfig.instances.radarr || {});\n    if (radarrInstances.length) {\n        listDiv.appendChild(renderARRGroupCard('radarr', radarrInstances));\n    } else {\n        const noRadarrCard = document.createElement('div');\n        noRadarrCard.className = 'card plex-instance-card';\n        noRadarrCard.innerHTML = `\n          <div class=\"plex-instance-header\">\n            <h3>${humanize('radarr')}</h3>\n          </div>\n          <div class=\"plex-libraries\">\n            <p class=\"no-entries\" style=\"margin: 0.5em 0 0 1em;\">🚫 No instances configured for ${humanize(\n                'radarr'\n            )}.</p>\n          </div>\n        `;\n        listDiv.appendChild(noRadarrCard);\n    }\n    const sonarrInstances = Object.keys(rootConfig.instances.sonarr || {});\n    if (sonarrInstances.length) {\n        listDiv.appendChild(renderARRGroupCard('sonarr', sonarrInstances));\n    } else {\n        const noSonarrCard = document.createElement('div');\n        noSonarrCard.className = 'card plex-instance-card';\n        noSonarrCard.innerHTML = `\n          <div class=\"plex-instance-header\">\n            <h3>${humanize('sonarr')}</h3>\n          </div>\n          <div class=\"plex-libraries\">\n            <p class=\"no-entries\" style=\"margin: 0.5em 0 0 1em;\">🚫 No instances configured for ${humanize(\n                'sonarr'\n            )}.</p>\n          </div>\n        `;\n        listDiv.appendChild(noSonarrCard);\n    }\n\n    // Only render Plex if SHOW_PLEX_IN_INSTANCE_FIELD includes this module\n    if (SHOW_PLEX_IN_INSTANCE_FIELD.includes(moduleName)) {\n        const plexInstances = Object.keys(rootConfig.instances.plex || {});\n        if (plexInstances.length) {\n            const plexWrapper = document.createElement('div');\n            plexWrapper.className = 'card';\n            plexWrapper.innerHTML = '<h3>Plex</h3>';\n            listDiv.appendChild(plexWrapper);\n            plexInstances.forEach((pi) => {\n                const libs = plexData[pi]?.library_names || [];\n                const wrapper = document.createElement('div');\n                wrapper.innerHTML = `\n            <div class=\"plex-instance-header\">\n                <h3>${humanize(pi)}</h3>\n            </div>\n            <div class=\"library-actions\">\n                <div style=\"display: flex; gap: 0.5rem;\">\n                    <button type=\"button\" class=\"btn select-all-libs\" data-inst=\"${pi}\">Select All</button>\n                    <button type=\"button\" class=\"btn deselect-all-libs\" data-inst=\"${pi}\">Deselect All</button>\n                </div>\n                <button type=\"button\" class=\"btn load-libs-btn plex-instance-header\" data-inst=\"${pi}\">Load Libraries</button>\n            </div>\n            <div id=\"plex-libs-${pi}\" class=\"plex-libraries\" style=\"max-height: 0px;\"></div>\n            `;\n                plexWrapper.appendChild(wrapper);\n                const loadBtn = wrapper.querySelector('.load-libs-btn');\n                const libsDiv = wrapper.querySelector(`#plex-libs-${pi}`);\n                loadBtn.addEventListener('click', async () => {\n                    try {\n                        const res = await fetch(\n                            `/api/plex/libraries?instance=${encodeURIComponent(pi)}`\n                        );\n                        if (!res.ok) throw new Error(await res.text());\n                        const fetchedLibs = await res.json();\n                        const existing = plexData[pi]?.library_names || [];\n                        libsDiv.innerHTML = fetchedLibs\n                            .map(\n                                (l) => `\n            <label class=\"library-pill\">\n                <input type=\"checkbox\" name=\"instances.${pi}.library_names\" value=\"${l}\" ${\n                                    existing.includes(l) ? 'checked' : ''\n                                }/>\n                ${l}\n            </label>\n        `\n                            )\n                            .join('');\n\n                        requestAnimationFrame(() => {\n                            libsDiv.classList.add('open');\n                            libsDiv.style.maxHeight = libsDiv.scrollHeight + 'px';\n                        });\n                        showToast?.(`✅ Loaded libraries for ${humanize(pi)}`, 'success');\n                    } catch (err) {\n                        showToast?.(\n                            `❌ Failed to load libraries for ${humanize(pi)}: ${err.message}`,\n                            'error'\n                        );\n                    }\n                });\n                wrapper.querySelector('.select-all-libs')?.addEventListener('click', () => {\n                    libsDiv\n                        .querySelectorAll('input[type=\"checkbox\"]')\n                        .forEach((cb) => (cb.checked = true));\n                });\n                wrapper.querySelector('.deselect-all-libs')?.addEventListener('click', () => {\n                    libsDiv\n                        .querySelectorAll('input[type=\"checkbox\"]')\n                        .forEach((cb) => (cb.checked = false));\n                });\n\n                if (libs.length) {\n                    libsDiv.innerHTML = libs\n                        .map(\n                            (l) => `\n                        <label class=\"library-pill\">\n                            <input type=\"checkbox\" name=\"instances.${pi}.library_names\" value=\"${l}\" checked/>\n                            ${l}\n                        </label>\n                    `\n                        )\n                        .join('');\n\n                    requestAnimationFrame(() => {\n                        libsDiv.classList.add('open');\n                        libsDiv.style.maxHeight = libsDiv.scrollHeight + 'px';\n                    });\n                }\n            });\n        } else {\n            const noPlexCard = document.createElement('div');\n            noPlexCard.className = 'card plex-instance-card';\n            noPlexCard.innerHTML = `\n              <div class=\"plex-instance-header\">\n                <h3>${humanize('plex')}</h3>\n              </div>\n              <div class=\"plex-libraries\">\n                <p class=\"no-entries\" style=\"margin: 0.5em 0 0 1em;\">🚫 No instances configured for ${humanize(\n                    'plex'\n                )}.</p>\n              </div>\n            `;\n            listDiv.appendChild(noPlexCard);\n        }\n    }\n}\n\nexport function createDragDropField(name, list) {\n    const field = document.createElement('div');\n\n    const moduleName = window.currentModuleName;\n    const placeholder = PLACEHOLDER_TEXT[moduleName]?.[name] || '';\n    field.className = 'field setting-field';\n    field.innerHTML = `\n      <label>${humanize(name)}</label>\n      <button type=\"button\" class=\"btn add-control-btn\">➕ Add Directory</button>\n      <div class=\"subfield-list\"></div>\n    `;\n    const container = field.querySelector('.subfield-list');\n    (Array.isArray(list) ? list : [list]).forEach((dir, idx) => {\n        const sub = document.createElement('div');\n        sub.className = 'subfield';\n        sub.innerHTML = `\n         <span class=\"drag-handle\" style=\"cursor: grab;\">⋮⋮</span>\n         <input type=\"text\" class=\"input\" name=\"${name}\" value=\"${dir}\" readonly placeholder=\"${placeholder}\"/>\n         <button type=\"button\" class=\"btn--cancel remove-directory btn\">−</button>\n       `;\n        const txt = sub.querySelector('input[type=\"text\"]');\n        txt.addEventListener('click', () => directoryPickerModal(txt));\n        sub.querySelector('.remove-directory').onclick = () => {\n            sub.remove();\n            updateRemoveButtons();\n        };\n        container.appendChild(sub);\n    });\n    const updateRemoveButtons = () => {\n        const subs = container.querySelectorAll('.subfield');\n        subs.forEach((sub) => {\n            const btn = sub.querySelector('.remove-directory');\n            if (subs.length <= 1) {\n                btn.disabled = true;\n                btn.style.opacity = '0.5';\n                btn.style.cursor = 'not-allowed';\n            } else {\n                btn.disabled = false;\n                btn.style.opacity = '';\n                btn.style.cursor = '';\n            }\n        });\n    };\n    updateRemoveButtons();\n    const addBtn = field.querySelector('.add-control-btn');\n    addBtn.onclick = () => {\n        const sub = document.createElement('div');\n        sub.className = 'subfield';\n        sub.innerHTML = `\n         <span class=\"drag-handle\" style=\"cursor: grab;\">⋮⋮</span>\n         <input type=\"text\" class=\"input\" name=\"${name}\" readonly placeholder=\"${placeholder}\"/>\n         <button type=\"button\" class=\"remove-directory\">−</button>\n       `;\n        const txt = sub.querySelector('input[type=\"text\"]');\n        txt.addEventListener('click', () => directoryPickerModal(txt));\n        sub.querySelector('.remove-directory').onclick = () => {\n            sub.remove();\n            updateRemoveButtons();\n        };\n        container.appendChild(sub);\n        updateRemoveButtons();\n        makeDraggable(container);\n    };\n\n    function makeDraggable(list) {\n        let dragged;\n        list.querySelectorAll('.subfield').forEach((item) => {\n            item.setAttribute('draggable', true);\n            item.classList.add('draggable');\n            item.style.transition = 'transform 0.2s ease, opacity 0.2s ease';\n            item.addEventListener('dragstart', (e) => {\n                dragged = item;\n                item.classList.add('dragging');\n                item.style.opacity = '0.5';\n                item.style.transform = 'scale(1.05)';\n                e.dataTransfer.effectAllowed = 'move';\n            });\n            item.addEventListener('dragover', (e) => {\n                e.preventDefault();\n                const bounding = item.getBoundingClientRect();\n                const offset = e.clientY - bounding.top + bounding.height / 2;\n                if (offset > bounding.height) {\n                    list.insertBefore(dragged, item.nextSibling);\n                } else {\n                    list.insertBefore(dragged, item);\n                }\n            });\n            item.addEventListener('dragleave', () => {\n                item.classList.remove('drag-over');\n            });\n            item.addEventListener('drop', (e) => {\n                e.preventDefault();\n                item.classList.remove('drag-over');\n            });\n            item.addEventListener('dragend', () => {\n                dragged.classList.remove('dragging');\n                dragged.style.opacity = '';\n                dragged.style.transform = '';\n                list.querySelectorAll('.subfield').forEach((sub) =>\n                    sub.classList.remove('drag-over')\n                );\n            });\n        });\n    }\n    makeDraggable(container);\n    return field;\n}\n\nexport function renderField(formFields, key, value) {\n    const moduleName = window.currentModuleName;\n    if (LIST_FIELD[moduleName] && LIST_FIELD[moduleName].includes(key)) {\n        formFields.appendChild(createListField(key, value));\n        return;\n    } else if (DRAG_AND_DROP[moduleName] && DRAG_AND_DROP[moduleName].includes(key)) {\n        formFields.appendChild(createDragDropField(key, value));\n        return;\n    } else if (DROP_DOWN_FIELDS.includes(key)) {\n        const opts = DROP_DOWN_OPTIONS[key] || [];\n        formFields.appendChild(renderDropdownField(key, value, opts));\n    } else if (BOOL_FIELDS.includes(key)) {\n        formFields.appendChild(renderBooleanField(key, value));\n    } else if (INT_FIELDS.includes(key)) {\n        formFields.appendChild(renderNumberField(key, value));\n    } else if (TEXTAREA_FIELDS.includes(key)) {\n        formFields.appendChild(renderTextareaArrayField(key, value));\n    } else if (TEXT_FIELDS.includes(key)) {\n        formFields.appendChild(renderTextField(key, value));\n    } else if (DIR_PICKER.includes(key)) {\n        formFields.appendChild(renderTextField(key, value));\n    } else {\n        formFields.appendChild(renderTextField(key, value));\n    }\n}\n"
  },
  {
    "path": "web/static/js/settings.js",
    "content": "import { fetchConfig } from './helper.js';\nimport { renderPosterRenamerrSettings } from './settings/modules/poster_renamerr.js';\nimport { renderLabelarrSettings } from './settings/modules/labelarr.js';\nimport { renderReplacerrSettings } from './settings/modules/border_replacerr.js';\nimport { renderUpgradinatorrSettings } from './settings/modules/upgradinatorr.js';\nimport { renderGdriveSettings } from './settings/modules/sync_gdrive.js';\nimport { renderNohlSettings } from './settings/modules/nohl.js';\nimport { renderJduparrSettings } from './settings/modules/jduparr.js';\nimport { renderHealthCheckarrSettings } from './settings/modules/health_checkarr.js';\nimport { renderPosterCleanarrSettings } from './settings/modules/poster_cleanarr.js';\nimport { renderRenameinatorrSettings } from './settings/modules/renameinatorr.js';\nimport { renderUnmatchedAssetsSettings } from './settings/modules/unmatched_assets.js';\nimport { buildSettingsPayload } from './payload.js';\nimport { renderMain } from './settings/modules/main.js';\nimport { DAPS } from './common.js';\nimport { setTheme } from './index.js';\nconst { bindSaveButton, markDirty } = DAPS;\n\nconst MODULE_RENDERERS = {\n    poster_renamerr: renderPosterRenamerrSettings,\n    labelarr: renderLabelarrSettings,\n    border_replacerr: renderReplacerrSettings,\n    upgradinatorr: renderUpgradinatorrSettings,\n    sync_gdrive: renderGdriveSettings,\n    nohl: renderNohlSettings,\n    jduparr: renderJduparrSettings,\n    health_checkarr: renderHealthCheckarrSettings,\n    poster_cleanarr: renderPosterCleanarrSettings,\n    renameinatorr: renderRenameinatorrSettings,\n    unmatched_assets: renderUnmatchedAssetsSettings,\n    main: renderMain,\n};\n\nexport async function loadSettings(moduleName) {\n    window.currentModuleName = moduleName;\n    const formFields = document.getElementById('form-fields');\n    formFields.innerHTML = '';\n    const rootConfig = await fetchConfig();\n    const moduleConfig = rootConfig[moduleName] || {};\n    const renderer = MODULE_RENDERERS[moduleName];\n    if (renderer) {\n        renderer(formFields, moduleConfig, rootConfig);\n    }\n    DAPS.isDirty = false;\n    const settingsForm = document.getElementById('settingsForm');\n    if (settingsForm) {\n        settingsForm.addEventListener('change', () => {\n            markDirty();\n        });\n\n        settingsForm.addEventListener('click', (e) => {\n            if (\n                e.target.classList.contains('remove-btn') ||\n                e.target.classList.contains('add-btn') ||\n                e.target.classList.contains('edit-btn')\n            ) {\n                markDirty();\n            }\n        });\n    }\n    const saveBtn = document.getElementById('saveBtn');\n    bindSaveButton(\n        saveBtn,\n        () => Promise.resolve(buildSettingsPayload(window.currentModuleName)),\n        window.currentModuleName,\n\n        () => {\n            if (window.currentModuleName === 'main') setTheme();\n        }\n    );\n    if (settingsForm) {\n        const allCards = settingsForm.querySelectorAll('.card');\n        allCards.forEach((card, i) => {\n            card.classList.remove('show-card');\n            setTimeout(() => {\n                card.classList.add('show-card');\n\n                const fields = card.querySelectorAll('.field');\n                fields.forEach((field, j) => {\n                    field.classList.remove('show-field');\n                    setTimeout(() => field.classList.add('show-field'), 40 * j);\n                });\n            }, 40 * i);\n        });\n\n        const wrapperFields = settingsForm.querySelectorAll('.settings-wrapper > .field');\n        wrapperFields.forEach((field, i) => {\n            field.classList.remove('show-field');\n            setTimeout(() => field.classList.add('show-field'), 40 * i);\n        });\n    }\n}\n"
  },
  {
    "path": "web/templates/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n<script>\n(function() {\n    var t;\n    try { t = localStorage.getItem('theme'); } catch(e){}\n    if (t) {\n        document.documentElement.setAttribute('data-theme', t);\n    }\n})();\n</script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>DAPS</title>\n    <link rel=\"icon\" href=\"/web/static/img/favicon.ico\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/web/static/img/favicon-32x32.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/web/static/img/favicon-16x16.png\">\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/web/static/img/favicon-colored.svg\">\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/web/static/img/apple-touch-icon.png\">\n    <link rel=\"mask-icon\" href=\"/web/static/img/mask-icon.svg\" color=\"#5bbad5\">\n    <link href=\"https://fonts.googleapis.com/icon?family=Material+Icons\" rel=\"stylesheet\">\n    <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/xterm/css/xterm.css\" />\n    <script src=\"https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js\"></script>\n    \n\n    <!-- Select2 CSS and jQuery dependencies -->\n    <link href=\"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css\" rel=\"stylesheet\" />\n    <script src=\"https://code.jquery.com/jquery-3.6.0.min.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js\"></script>\n    <script type=\"module\" src=\"/web/static/js/main.js\"></script>\n    \n    <link rel=\"stylesheet\" href=\"/web/static/css/base.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/common.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/layout.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/navigation.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/settings.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/instances.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/index.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/schedule.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/modals.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/logs.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/poster_search.css\" />\n    <link rel=\"stylesheet\" href=\"/web/static/css/notifications.css\" />\n    <style>\n\n    </style>\n</head>\n\n<body>\n\n    <div id=\"pageOverlay\"></div>\n    <div class=\"container\">\n        \n<a href=\"/\" class=\"daps-header-gradient daps-header-link\">\n    <span class=\"daps\">DAPS</span>\n    <span class=\"dashboard-label\">Dashboard</span>\n</a>\n\n<nav>\n    <ul class=\"menu\">\n<li><a id=\"link-schedule\" href=\"/pages/schedule\">🗓️ Schedule</a></li>\n<li><a id=\"link-instances\" href=\"/pages/instances\">🖥️ Instances</a></li>\n<li><a id=\"link-notifications\" href=\"/pages/notifications\">💬 Notifications</a></li>\n<li><a id=\"link-poster-search\" href=\"/pages/poster_search\">🎥 Poster Search</a></li>\n<li class=\"dropdown\">\n    <div class=\"dropdown-toggle\">⚙️ Settings</div>\n    <ul class=\"dropdown-menu settings-panel\" id=\"settings-dropdown\"></ul>\n</li>\n<li><a id=\"link-logs\" href=\"/pages/logs\">📜 Logs</a></li>\n    </ul>\n</nav>\n<div class=\"overlay\" id=\"contentOverlay\"></div>\n<div class=\"content\">\n    <div id=\"viewFrame\" class=\"view-frame\"></div>\n</div>\n<div id=\"toast-container\"></div>\n\n<footer class=\"daps-footer\">\n    <span id=\"version\" class=\"footer-version\">📦 Version: ...</span>\n    <span id=\"update-badge\" class=\"has-update-tooltip footer-update-badge\">\n        🚀 Update Available\n        <span class=\"update-tooltip\" id=\"update-tooltip\">\n            <span class=\"update-tooltip-title\">Update Available</span><br>\n            <span class=\"update-tooltip-versions\">\n                Current: <span id=\"tooltip-current-version\"></span><br>\n                Latest: <span id=\"tooltip-latest-version\"></span>\n            </span>\n        </span>\n    </span>\n    <a href=\"https://github.com/Drazzilb08/daps\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"footer-link\">\n        <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path d=\"M12 .296c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.207 11.387.6.113.793-.258.793-.577v-2.234c-3.338.726-4.033-1.415-4.033-1.415-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.838 1.235 1.838 1.235 1.07 1.834 2.809 1.304 3.495.997.108-.776.419-1.304.762-1.604-2.665-.305-5.467-1.332-5.467-5.93 0-1.31.469-2.381 1.236-3.221-.124-.303-.536-1.527.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.655 1.649.243 2.873.119 3.176.77.84 1.234 1.911 1.234 3.221 0 4.61-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .321.192.694.801.576C20.565 22.092 24 17.592 24 12.296c0-6.627-5.373-12-12-12\" />\n        </svg>\n        <span>GitHub</span>\n    </a>\n    <a href=\"https://trash-guides.info/discord\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"footer-link\">\n        <svg width=\"18\" height=\"18\" viewBox=\"0 0 71 55\" fill=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path d=\"M60.104 4.552A58.121 58.121 0 0 0 46.852.8a41.05 41.05 0 0 0-1.965 4.054 55.048 55.048 0 0 0-16.773 0A41.157 41.157 0 0 0 26.15.8 58.08 58.08 0 0 0 12.9 4.552a61.048 61.048 0 0 0-9.723 44.335A58.674 58.674 0 0 0 21.105 55c1.137-1.553 2.135-3.192 2.982-4.906a36.896 36.896 0 0 1-5.737-2.775c.482-.354.951-.724 1.406-1.108a40.44 40.44 0 0 0 36.487 0c.464.397.94.78 1.428 1.148a36.79 36.79 0 0 1-5.76 2.775c.856 1.714 1.854 3.353 2.97 4.906a58.627 58.627 0 0 0 17.91-6.113 61.046 61.046 0 0 0-9.723-44.335ZM23.725 37.27c-3.357 0-6.103-3.095-6.103-6.906s2.717-6.93 6.103-6.93c3.404 0 6.15 3.12 6.127 6.93 0 3.81-2.717 6.906-6.127 6.906Zm23.55 0c-3.357 0-6.103-3.095-6.103-6.906s2.717-6.93 6.103-6.93c3.404 0 6.15 3.12 6.127 6.93 0 3.81-2.717 6.906-6.127 6.906Z\" />\n        </svg>\n        <span>Discord</span>\n    </a>\n</footer>\n</body>\n\n</html>"
  },
  {
    "path": "web/templates/pages/instances.html",
    "content": "<div class=\"container-iframe\">\n    <div class=\"content\">\n        <form id=\"instancesForm\"></form>\n        <button id=\"saveBtn\" class=\"btn\" type=\"button\">💾 Save</button>\n        <div id=\"status\"></div>\n    </div>\n</div>\n"
  },
  {
    "path": "web/templates/pages/logs.html",
    "content": "<div class=\"container-iframe\">\n    <div id=\"scroll-output-container\" style=\"position: relative\">\n        <div id=\"log-output\" class=\"log-output\"></div>\n    </div>\n</div>\n"
  },
  {
    "path": "web/templates/pages/notifications.html",
    "content": "<div class=\"container-iframe\">\n    <div class=\"content\">\n        <input\n            type=\"text\"\n            id=\"notifications-search\"\n            class=\"input\"\n            placeholder=\"🔍 Filter notifications...\"\n        />\n        <form id=\"notificationsForm\"></form>\n        <button id=\"saveBtn\" class=\"btn\" type=\"button\">💾 Save</button>\n        <div id=\"status\"></div>\n    </div>\n</div>\n"
  },
  {
    "path": "web/templates/pages/poster_search.html",
    "content": "<div class=\"container-iframe\">\n    <div class=\"poster-search-loader-modal\" style=\"display: none\">\n        <div class=\"terminal-loader\">\n            <div class=\"terminal-header\">\n                <div class=\"terminal-title\">Status</div>\n                <div class=\"terminal-controls\">\n                    <div class=\"control close\"></div>\n                    <div class=\"control minimize\"></div>\n                    <div class=\"control maximize\"></div>\n                </div>\n            </div>\n            <div class=\"text\">Loading Posters...</div>\n        </div>\n    </div>\n    <div class=\"content\">\n        <div class=\"poster-search-btn-row\">\n            <button id=\"toggle-stats-btn\" class=\"poster-search-toggle-btn btn\">\n                📊 Show Statistics\n            </button>\n        </div>\n        <div id=\"poster-stats-card\">\n            <div id=\"gdrive-stats-section\"></div>\n            <div id=\"assets-stats-section\"></div>\n        </div>\n        <div class=\"poster-search-toggle-row\">\n            <span class=\"poster-search-label\">Search in:</span>\n            <label class=\"toggle-switch\">\n                <input type=\"checkbox\" id=\"search-scope-toggle\" />\n                <span class=\"slider\"></span>\n            </label>\n            <span id=\"search-scope-label\" class=\"poster-search-scope-label\">GDrive Locations</span>\n        </div>\n        <input\n            type=\"text\"\n            id=\"poster-search-input\"\n            class=\"input\"\n            placeholder=\"🔍 Search posters...\"\n            autocomplete=\"off\"\n            spellcheck=\"false\"\n        />\n        <div id=\"poster-search-results\" class=\"poster-search-results\"></div>\n    </div>\n</div>\n"
  },
  {
    "path": "web/templates/pages/schedule.html",
    "content": "<div class=\"container-iframe\">\n    <div class=\"content\">\n        <input\n            type=\"text\"\n            id=\"schedule-search\"\n            class=\"input\"\n            placeholder=\"🔍 Filter notifications...\"\n        />\n        <form id=\"scheduleForm\"></form>\n        <button id=\"saveBtn\" class=\"btn\" type=\"button\">💾 Save</button>\n        <div id=\"status\"></div>\n    </div>\n</div>\n"
  },
  {
    "path": "web/templates/pages/settings.html",
    "content": "<div class=\"container-iframe\">\n    <div class=\"content\">\n        <form id=\"settingsForm\">\n            <div id=\"form-fields\"></div>\n            <div class=\"actions\">\n                <button id=\"saveBtn\" class=\"btn\" type=\"button\">💾 Save</button>\n                <div id=\"status\"></div>\n            </div>\n        </form>\n    </div>\n</div>\n"
  }
]