Repository: Drazzilb08/daps Branch: master Commit: 9ea4bdffa389 Files: 96 Total size: 796.9 KB Directory structure: gitextract_jvbd20pz/ ├── .dockerignore ├── .github/ │ └── workflows/ │ ├── on-branch-create.yml │ ├── on-branch-delete.yml │ ├── on-commit.yml │ └── version.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── compose/ │ └── docker-compose.yml ├── jokes.txt ├── main.py ├── modules/ │ ├── __init__.py │ ├── border_replacerr.py │ ├── health_checkarr.py │ ├── jduparr.py │ ├── labelarr.py │ ├── nohl.py │ ├── poster_cleanarr.py │ ├── poster_renamerr.py │ ├── renameinatorr.py │ ├── sync_gdrive.py │ ├── unmatched_assets.py │ └── upgradinatorr.py ├── requirements.txt ├── start.sh ├── util/ │ ├── __init__.py │ ├── arrpy.py │ ├── assets.py │ ├── config.py │ ├── constants.py │ ├── construct.py │ ├── extract.py │ ├── index.py │ ├── logger.py │ ├── match.py │ ├── normalization.py │ ├── notification.py │ ├── notification_formatting.py │ ├── scanner.py │ ├── scheduler.py │ ├── template/ │ │ └── config_template.json │ ├── utility.py │ └── version.py └── web/ ├── server.py ├── static/ │ ├── css/ │ │ ├── base.css │ │ ├── common.css │ │ ├── index.css │ │ ├── instances.css │ │ ├── layout.css │ │ ├── logs.css │ │ ├── modals.css │ │ ├── navigation.css │ │ ├── notifications.css │ │ ├── poster_search.css │ │ ├── schedule.css │ │ └── settings.css │ └── js/ │ ├── common.js │ ├── help_content.js │ ├── helper.js │ ├── index.js │ ├── instances.js │ ├── logs.js │ ├── main.js │ ├── navigation.js │ ├── notifications.js │ ├── payload.js │ ├── poster_search.js │ ├── schedule.js │ ├── settings/ │ │ ├── constants.js │ │ ├── modal_helpers.js │ │ ├── modals.js │ │ ├── modules/ │ │ │ ├── border_replacerr.js │ │ │ ├── health_checkarr.js │ │ │ ├── jduparr.js │ │ │ ├── labelarr.js │ │ │ ├── main.js │ │ │ ├── nohl.js │ │ │ ├── poster_cleanarr.js │ │ │ ├── poster_renamerr.js │ │ │ ├── renameinatorr.js │ │ │ ├── sync_gdrive.js │ │ │ ├── unmatched_assets.js │ │ │ └── upgradinatorr.js │ │ ├── presets.js │ │ └── settings_helpers.js │ └── settings.js └── templates/ ├── index.html └── pages/ ├── instances.html ├── logs.html ├── notifications.html ├── poster_search.html ├── schedule.html └── settings.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Ignore everything * # Allow files and directories !/main.py !/requirements.txt !/jokes.txt !/modules !/util !/scripts !/config !/VERSION !/spud !/start.sh !/web # Ignore unnecessary files inside allowed directories # This should go after the allowed directories **/*~ **/*.log **/.DS_Store **/Thumbs.db **/config.yml **/Dockerfile ================================================ FILE: .github/workflows/on-branch-create.yml ================================================ name: Tag Docker Image for New Branch on: create: branches: - '*' # Triggers on branch creation jobs: docker-tag: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Get the new branch name id: get_branch run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT - name: Set build number run: echo "BUILD_NUMBER=$(git rev-list --count HEAD)" >> $GITHUB_ENV - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ secrets.GH_USERNAME }} password: ${{ secrets.GH_TOKEN }} - name: Build and push Docker image to GHCR uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 build-args: | "BRANCH=${{ steps.get_branch.outputs.BRANCH_NAME }}" "BUILD_NUMBER=${{ env.BUILD_NUMBER }}" push: true tags: | ghcr.io/drazzilb08/daps:${{ steps.get_branch.outputs.BRANCH_NAME }} ================================================ FILE: .github/workflows/on-branch-delete.yml ================================================ name: Delete GHCR Docker Tag for Deleted Branch on: delete: branches: - '*' # Triggers on branch deletion jobs: ghcr-delete-tag: runs-on: ubuntu-latest steps: - name: Get the deleted branch name id: get_branch run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT - name: Check if branch is not dev or master run: | if [[ "${{ steps.get_branch.outputs.BRANCH_NAME }}" == "dev" || "${{ steps.get_branch.outputs.BRANCH_NAME }}" == "master" ]]; then echo "Skipping deletion for branch: ${{ steps.get_branch.outputs.BRANCH_NAME }}" exit 0 fi - name: Delete tag from GHCR env: GH_TOKEN: ${{ secrets.GH_TOKEN }} TAG_NAME: ${{ steps.get_branch.outputs.BRANCH_NAME }} run: | REPO="drazzilb08/daps" # Get the tag digest DIGEST=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \ "https://ghcr.io/v2/${REPO}/manifests/${TAG_NAME}" \ -I | grep -i 'docker-content-digest:' | awk '{print $2}' | tr -d '\r') if [[ -z "$DIGEST" ]]; then echo "Tag not found on GHCR" exit 0 fi # Delete the tag curl -s -X DELETE -H "Authorization: Bearer $GH_TOKEN" \ "https://ghcr.io/v2/${REPO}/manifests/${DIGEST}" \ && echo "Deleted GHCR tag: ${TAG_NAME}" || echo "Failed to delete GHCR tag: ${TAG_NAME}" ================================================ FILE: .github/workflows/on-commit.yml ================================================ name: Update Docker Image on Branch Commit on: push: branches: - '*' # Triggers on push to any branch paths: - 'Dockerfile' - 'main.py' - 'requirements.txt' - 'jokes.txt' - 'modules/**' - 'util/**' - 'scripts/**' - 'config/**' - 'spud/**' - 'web/**' - 'VERSION' - 'start.sh' jobs: docker-tag: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Get the current branch name id: get_branch run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT - name: Set build number run: echo "BUILD_NUMBER=$(git rev-list --count HEAD)" >> $GITHUB_ENV - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ secrets.GH_USERNAME }} password: ${{ secrets.GH_TOKEN }} - name: Build and push Docker image (branch-specific) uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 build-args: | "BRANCH=${{ steps.get_branch.outputs.BRANCH_NAME }}" "BUILD_NUMBER=${{ env.BUILD_NUMBER }}" push: true tags: | ${{ secrets.DOCKER_USERNAME }}/daps:${{ steps.get_branch.outputs.BRANCH_NAME }} ghcr.io/drazzilb08/daps:${{ steps.get_branch.outputs.BRANCH_NAME }} - name: Build and push Docker image (latest tag for master) if: ${{ steps.get_branch.outputs.BRANCH_NAME == 'master' }} uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 build-args: | "BRANCH=master" "BUILD_NUMBER=${{ env.BUILD_NUMBER }}" push: true tags: | ${{ secrets.DOCKER_USERNAME }}/daps:latest ghcr.io/drazzilb08/daps:latest ================================================ FILE: .github/workflows/version.yml ================================================ name: Docker Version Release on: push: tags: - v* jobs: docker-version: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Get the version id: get_version run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT - name: Extract branch name shell: bash run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT id: extract_branch - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ secrets.GH_USERNAME }} password: ${{ secrets.GH_TOKEN }} - name: Build and push id: docker_build uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 build-args: | "BRANCH=${{ steps.extract_branch.outputs.branch }}" push: true tags: | ${{ secrets.DOCKER_USERNAME }}/daps:${{ steps.get_version.outputs.VERSION }} ghcr.io/drazzilb08/daps:${{ steps.get_version.outputs.VERSION }} ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging dist/ *.egg-info/ *.egg # PyInstaller *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Virtual environments venv/ env/ ENV/ .venv/ # Bash *.sh~ # IDEs / editors .vscode/ .idea/ *.swp *.swo *.swn *.bak # Ignore Directories .archives/ .extra_scripts/ screenshots/ logs/ tests/ # Ignore Files **/config.yml **/.DS_Store **/TODO.* **test.** **side-projects** test2.py .env /tests pyproject.toml pyproject.toml /config ================================================ FILE: Dockerfile ================================================ # Single-stage build for installing Python dependencies and required packages FROM python:3.11-slim # Copy requirements.txt and install Python dependencies COPY requirements.txt . # Install required packages and Python dependencies RUN set -eux; \ apt-get update && \ apt-get install -y --no-install-recommends \ gcc wget curl unzip p7zip-full tzdata jq git build-essential && \ pip3 install --no-cache-dir -r requirements.txt && \ curl https://rclone.org/install.sh | bash && \ git clone https://codeberg.org/jbruchon/libjodycode.git /tmp/libjodycode && \ make -C /tmp/libjodycode && make -C /tmp/libjodycode install && \ ldconfig && \ git clone https://codeberg.org/jbruchon/jdupes.git /tmp/jdupes && \ make -C /tmp/jdupes && make -C /tmp/jdupes install && \ ln -s /usr/local/bin/jdupes /usr/bin/jdupes && \ rm -rf /tmp/libjodycode /tmp/jdupes # Clean up RUN set -eux; \ apt-get remove -y --purge gcc build-essential && \ apt-get autoremove -y && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # Metadata and labels LABEL maintainer="Drazzilb" \ description="daps" \ org.opencontainers.image.source="https://github.com/Drazzilb08/daps" \ org.opencontainers.image.authors="Drazzilb" \ org.opencontainers.image.title="daps" # Branch and build number arguments ARG BRANCH="master" ARG BUILD_NUMBER="" # Pass the build-time BRANCH arg into a runtime environment variable ENV BRANCH=${BRANCH} ENV BUILD_NUMBER=${BUILD_NUMBER} ARG CONFIG_DIR=/config # Set script environment variables ENV CONFIG_DIR=/config ENV APPDATA_PATH=/appdata ENV LOG_DIR=/config/logs ENV TZ=America/Los_Angeles ENV PORT=8000 ENV HOST=0.0.0.0 ENV DOCKER_ENV=true # Expose the application port EXPOSE ${PORT} VOLUME /config WORKDIR /app COPY . . # Create a new user called dockeruser with the specified PUID and PGID RUN groupadd -g 99 dockeruser; \ useradd -u 100 -g 99 dockeruser; \ chown -R dockeruser:dockeruser /app; # Entrypoint script CMD ["bash", "start.sh"] ================================================ FILE: LICENSE ================================================ piMIT License Copyright (c) 2023 Drazzilb Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Create venv if it doesn't exist .PHONY: venv venv: test -d venv || python3 -m venv venv # Install requirements .PHONY: install install: venv . venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt # Freeze current venv into requirements.txt .PHONY: lock lock: . venv/bin/activate && pip freeze > requirements.txt # Lint using flake8 (must be installed in requirements.txt) .PHONY: lint lint: . venv/bin/activate && flake8 ================================================ FILE: README.md ================================================
# DAPS Automate, optimize, and take control of your media libraries. [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![GitHub Issues](https://img.shields.io/github/issues/Drazzilb08/daps.svg)](https://github.com/Drazzilb08/daps/issues) [![GitHub PRs](https://img.shields.io/github/issues-pr/Drazzilb08/daps.svg)](https://github.com/Drazzilb08/daps/pulls) [![GitHub Stars](https://img.shields.io/github/stars/Drazzilb08/daps.svg)](https://github.com/Drazzilb08/daps/stargazers) [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/) [![Bash](https://img.shields.io/badge/bash-5.0%2B-green.svg)](https://www.gnu.org/software/bash/)
--- ## 🚀 Quickstart See [Wiki](https://github.com/Drazzilb08/daps/wiki) for full install docs. **Docker (recommended):** ```bash docker run -d -v /path/to/config:/config -v /path/to/posters:/posters -p 8000:8000 drazzilb08/daps ``` **Local:** ```bash git clone https://github.com/Drazzilb08/daps.git cd daps python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt python3 main.py poster_renamerr ``` and use the built-in Web UI: [http://localhost:8000](http://localhost:8000) --- ## 🙋‍♂️ Contributing & Support Pull requests are welcome for fixes, docs, or new module ideas. If you spot a bug or want a feature, open an [Issue](https://github.com/Drazzilb08/daps/issues) or jump into a PR. ---
Made with ❤️ by Drazzilb If this saved you time, star the repo, tell a friend, or buy yourself a cookie.
================================================ FILE: VERSION ================================================ 2.0.3 ================================================ FILE: compose/docker-compose.yml ================================================ version: "3.9" services: daps: container_name: daps image: ghcr.io/drazzilb08/daps:latest ports: - "8000:8000" volumes: - /path/to/config:/config - /path/to/kometa/assets/:/kometa - /path/to/posters:/posters - /path/to/media:/media environment: - PUID=${PUID} - PGID=${PGID} - TZ=${TIMEZONE} restart: unless-stopped ================================================ FILE: jokes.txt ================================================ I'm reading a book about anti-gravity. It's impossible to put down. I burned 2000 calories today I left my food in the oven for too long. I 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!” I broke my arm in two places. My doctor told me to stop going to those places. I quit my job at the coffee shop the other day. It was just the same old grind over and over. I never buy anything that has Velcro with it... it’s a total rip-off. I used to work at a soft drink can crushing company... it was soda pressing. I wondered why the frisbee kept on getting bigger. Then it hit me. I was going to tell you a fighting joke... but I forgot the punch line. What is the most groundbreaking invention of all time? The shovel. I’m starting my new job at a restaurant next week. I can’t wait. I visited a weight loss website... they told me I have to have cookies disabled. Did you hear about the famous Italian chef that recently died? He pasta way. Broken guitar for sale no strings attached. I could never be a plumber it’s too hard watching your life’s work go down the drain. I cut my finger slicing cheese the other day... but I think I may have grater problems than that. What time did you go to the dentist yesterday? Tooth-hurty. What kind of music do astronauts listen to? Neptunes. Rest in peace, boiled water. You will be mist. What is the only concert in the world that costs 45 cents? 50 Cent, featuring Nickelback. It’s not a dad bod it’s a father figure. My 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. What do you call Santa’s little helpers? Subordinate clauses. Want to hear a construction joke? Sorry, I’m still working on it. What’s the difference between a hippo and a zippo? One is extremely big and heavy, and the other is a little lighter. I burnt my Hawaiian pizza today in the oven, I should have cooked it on aloha temperature. Anyone can be buried when they die but if you want to be cremated then you have to urn it. Where did Captain Hook get his hook? From the second-hand store. I am such a good singer that people always ask me to sing solo solo that they can’t hear me. I am such a good singer that people ask me to sing tenor tenor twelve miles away. Occasionally to relax I just like to tuck my knees into my chest and lean forward. That’s just how I roll. What did the glass of wine say to the glass of beer? Nothing. They barley knew each other. I’ve never trusted stairs. They are always up to something. Why did Shakespeare’s wife leave him? She got sick of all the drama. I just bought a dictionary but all of the pages are blank. I have no words to describe how mad I am. If you want to get a job at the moisturizer factory... you’re going to have to apply daily. I don’t know what’s going to happen next year. It’s probably because I don’t have 2020 vision. Want to hear a joke about going to the bathroom? Urine for a treat. I couldn’t figure out how to use the seat belt. Then it just clicked. I got an email the other day teaching me how to read maps backwards turns out it was just spam. I'm reading a book about anti-gravity. It's impossible to put down! You'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. Did you know the first French fries weren't actually cooked in France? They were cooked in Greece. Want to hear a joke about a piece of paper? Never mind... it's tearable. I just watched a documentary about beavers. It was the best dam show I ever saw! If you see a robbery at an Apple Store what re you? An iWitness? Spring is here! I got so excited I wet my plants! Why did the Clydesdale give the pony a glass of water? Because he was a little horse! CASHIER: "Would you like the milk in a bag, sir?" DAD: "No, just leave it in the carton!’” Did you hear about the guy who invented Lifesavers? They say he made a mint. I bought some shoes from a drug dealer. I don't know what he laced them with, but I was tripping all day! Why do chicken coops only have two doors? Because if they had four, they would be chicken sedans! How do you make a Kleenex dance? Put a little boogie in it! Why did the invisible man turn down the job offer? He couldn't see himself doing it. I used to have a job at a calendar factory but I got the sack because I took a couple of days off. A 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!” I had a dream that I was a muffler last night. I woke up exhausted! Did you hear about the circus fire? It was in tents! Don't trust atoms. They make up everything! How many tickles does it take to make an octopus laugh? Ten-tickles. I’m only familiar with 25 letters in the English language. I don’t know why. Why did the cow in the pasture get promoted at work? Because he is OUT-STANDING in his field! What do prisoners use to call each other? Cell phones. Why couldn't the bike standup by itself? It was two tired. Who was the fattest knight at King Arthur’s round table? Sir Cumference. Did you see they made round bails of hay illegal in Wisconsin? It’s because the cows weren’t getting a square meal. You know what the loudest pet you can get is? A trumpet. What do you get when you cross a snowman with a vampire? Frostbite. What do you call a deer with no eyes? No idea! Can February March? No, but April May! What do you call a lonely cheese? Provolone. Why can't you hear a pterodactyl go to the bathroom? Because the pee is silent. What did the buffalo say to his son when he dropped him off at school? Bison. What do you call someone with no body and no nose? Nobody knows. You heard of that new band 1023MB? They're good but they haven't got a gig yet. Why did the crab never share? Because he's shellfish. How do you get a squirrel to like you? Act like a nut. Why don't eggs tell jokes? They'd crack each other up. Why can't a nose be 12 inches long? Because then it would be a foot. Did you hear the rumor about butter? Well, I'm not going to spread it! I made a pencil with two erasers. It was pointless. I used to hate facial hair... but then it grew on me. I decided to sell my vacuum cleaner— it was just gathering dust! I had a neck brace fitted years ago and I've never looked back since. You know, people say they pick their nose, but I feel like I was just born with mine. What do you call an elephant that doesn't matter? An irrelephant. What do you get from a pampered cow? Spoiled milk. It's inappropriate to make a 'dad joke' if you're not a dad. It's a faux pa. How do lawyers say goodbye? Sue ya later! Wanna hear a joke about paper? Never mind—it's tearable. What's the best way to watch a fly fishing tournament? Live stream. I could tell a joke about pizza, but it's a little cheesy. When does a joke become a dad joke? When it becomes apparent. What’s an astronaut’s favorite part of a computer? The space bar. What did the shy pebble wish for? That she was a little boulder. Why didn’t the skeleton cross the road? Because he had no guts. What did one nut say as he chased another nut? I'm a cashew! Chances are if you' ve seen one shopping center... you've seen a mall. I knew I shouldn't steal a mixer from work... but it was a whisk I was willing to take. How come the stadium got hot after the game? Because all of the fans left. Why was it called the dark ages? Because of all the knights. Why did the tomato blush? Because it saw the salad dressing. Did you hear the joke about the wandering nun? She was a roman catholic. What creature is smarter than a talking parrot? A spelling bee. I'll tell you what often gets over looked... garden fences. Why did the kid cross the playground? To get to the other slide. Why do birds fly south for the winter? Because it's too far to walk. What is a centipedes's favorite Beatle song? I want to hold your hand, hand, hand, hand... My first time using an elevator was an uplifting experience. The second time let me down. To be Frank... I'd have to change my name. Slept like a log last night … woke up in the fireplace. How many South Americans does it take to change a lightbulb? A Brazilian What is the difference between ignorance and apathy? I don't know and I don't care. I went to a Foo Fighters Concert once... It was Everlong... Some people eat light bulbs. They say it's a nice light snack. What do you get hanging from Apple trees? Sore arms. What did Romans use to cut pizza before the rolling cutter was invented? Lil Caesars My pet mouse 'Elvis' died last night. He was caught in a trap.. Never take advice from electrons. They are always negative. Why are oranges the smartest fruit? Because they are made to concentrate. What did the beaver say to the tree? It's been nice gnawing you. How do you fix a damaged jack-o-lantern? You use a pumpkin patch. What did the late tomato say to the early tomato? I’ll ketch up I have kleptomania... when it gets bad, I take something for it. I used to be addicted to soap... but I'm clean now. When is a door not a door? When it's ajar. I made a belt out of watches once... It was a waist of time. This furniture store keeps emailing me, all I wanted was one night stand! How do you find Will Smith in the snow? Look for fresh prints. If at first you don't succeed sky diving is not for you! What kind of music do mummy's like? Rap A book just fell on my head. I only have my shelf to blame. What did the dog say to the two trees? Bark bark. If a child refuses to sleep during nap time... are they guilty of resisting a rest? Why should you never trust a pig with a secret? Because it's bound to squeal. Why are mummys scared of vacation? They're afraid to unwind. Whiteboards ... are remarkable. What kind of dinosaur loves to sleep? A stega-snore-us. Why don't scientists trust atoms? Because they make up everything. What do you call a dinosaur that is sleeping? A dino-snore. What do you call a dinosaur that never gives up? Try and try and try and try-ceratops. What kind of tree fits in your hand? A palm tree! I used to be addicted to the hokey pokey but I turned myself around. What do you call a fake noodle? An impasta. What do you call a cow with two legs? Lean beef. How many tickles does it take to tickle an octopus? Ten-tickles! What musical instrument is found in the bathroom? A tuba toothpaste. My boss told me to attach two pieces of wood together... I totally nailed it! What was the pumpkin’s favorite sport? Squash. What do you call corn that joins the army? Kernel. I've been trying to come up with a dad joke about momentum but I just can't seem to get it going. Why don't sharks eat clowns? Because they taste funny. Why didn’t the melons get married? Because they cantaloupe. What’s a computer’s favorite snack? Microchips! Why was the robot so tired after his road trip? He had a hard drive. Why did the computer have no money left? Someone cleaned out its cache! I'm not anti-social. I'm just not user friendly. Why did the computer get cold? Because it forgot to close windows. What is an astronaut's favorite key on a keyboard? The space bar! What's the difference between a computer salesman and a used-car salesman? The used-car salesman KNOWS when he's lying. If at first you don't succeed... call it version 1.0 Why did Microsoft PowerPoint cross the road? To get to the other slide! What did the computer do at lunchtime? Had a byte! Why did the computer keep sneezing? It had a virus! What did one toilet say to the other? You look a bit flushed. Why did the picture go to jail? Because it was framed. What did one wall say to the other wall? I'll meet you at the corner. What do you call a boy named Lee that no one talks to? Lonely Why do bicycles fall over? Because they are two-tired! Why was the broom late? It over swept! What part of the car is the laziest? The wheels, because they are always tired! What's the difference between a TV and a newspaper? Ever tried swatting a fly with a TV? What did one elevator say to the other elevator? I think I'm coming down with something! Why was the belt arrested? Because it held up some pants! What makes the calendar seem so popular? Because it has a lot of dates! Why do you go to bed every night? Because the bed won't come to you! What has four wheels and flies? A garbage truck! Why did the robber take a bath before he stole from the bank? He wanted to make a clean get away! Just watched a documentary about beavers. It was the best damn program I’ve ever seen. Slept like a log last night woke up in the fireplace. What’s the difference between an African elephant and an Indian elephant? About 5000 miles Why did the coffee file a police report? It got mugged. What did the grape do when he got stepped on? He let out a little wine. How many apples grow on a tree? All of them. What name do you give a person with a rubber toe? Roberto Why do scuba divers fall backwards into the water? Because if they fell forwards they’d still be in the boat. How does a penguin build it’s house? Igloos it together. What do you call a man with a rubber toe? Roberto Did you hear about the restaurant on the moon? Great food, no atmosphere. Why was the belt sent to jail? For holding up a pair of pants! Did you hear about the scientist who was lab partners with a pot of boiling water? He had a very esteemed colleague. What happens when a frogs car dies? He needs a jump. If that doesn't work he has to get it toad. What did the flowers do when the bride walked down the aisle? They rose. Why did the man fall down the well? Because he couldn’t see that well. My boss told me to have a good day... ...so I went home. How can you tell it’s a dogwood tree? By the bark. Did you hear about the kidnapping at school? It’s fine, he woke up. Why is Peter Pan always flying? Because he Neverlands. Which state has the most streets? Rhode Island. What do you call 26 letters that went for a swim? Alphawetical. Why was the color green notoriously single? It was always so jaded. Why did the coach go to the bank? To get his quarterback. How do celebrities stay cool? They have many fans. What's the most depressing day of the week? sadder day. Dogs can’t operate MRI machines But catscan. I was going to tell a time-traveling joke but you guys didn’t like it. Stop looking for the perfect match instead look for a lighter. I told my doctor I heard buzzing but he said it’s just a bug going around. What kind of car does a sheep like to drive? A lamborghini. What did the accountant say while auditing a document? This is taxing. What did the two pieces of bread say on their wedding day? It was loaf at first sight. Why do melons have weddings? Because they cantaloupe. What did the drummer call his twin daughters? Anna One, Anna Two! What do you call a toothless bear? A gummy bear! Two goldfish are in a tank. One says to the other, “Do you know how to drive this thing?” What’s Forrest Gump’s password? 1forrest1 What is a child guilty of if they refuse to nap? Resisting a rest. I know a lot of jokes about retired people but none of them work. Why are spiders so smart? They can find everything on the web. What has one head, one foot, and four legs? A bed. What does a house wear? Address. What’s red and smells like blue paint? Red paint. My son asked me to put his shoes on but I don’t think they’ll fit me. I’ve been bored recently, so I decided to take up fencing. The neighbors keep demanding that I put it back. What do you call an unpredictable camera? A loose Canon. Which U.S. state is known for its especially small soft drinks? Minnesota. What do sprinters eat before a race? Nothing—they fast. I’m so good at sleeping... I can do it with my eyes closed. People are usually shocked that I have a Police record. But I love their greatest hits! I told my girlfriend she drew on her eyebrows too high. She seemed surprised. What do you call a fibbing cat? A lion. Why shouldn’t you write with a broken pencil? Because it’s pointless. I like telling Dad jokes… sometimes he laughs. How do you weigh a millennial? In Instagrams. The wedding was so beautiful even the cake was in tiers. What’s the most patriotic sport? Flag football. Never trust atoms; they make up everything. Two fish are in a tank. One says, How do you drive this thing? My wife told me to stop impersonating a flamingo. I had to put my foot down. I went to buy some camo pants but couldn’t find any. I failed math so many times at school, I can’t even count. I used to have a handle on life, but then it broke. I was wondering why the frisbee kept getting bigger and bigger, but then it hit me. I heard there were a bunch of break-ins over at the car park. That is wrong on so many levels. I want to die peacefully in my sleep, like my grandfather… Not screaming and yelling like the passengers in his car. When life gives you melons, you might be dyslexic Don’t you hate it when someone answers their own questions? I do. It takes a lot of balls to golf the way I do. I told him to be himself; that was pretty mean, I guess. I know they say that money talks, but all mine says is ‘Goodbye.’ My father has schizophrenia, but he’s good people. The problem with kleptomaniacs is that they always take things literally. I can’t believe I got fired from the calendar factory. All I did was take a day off. Most people are shocked when they find out how bad I am as an electrician. Never trust atoms; they make up everything. My wife just found out I replaced our bed with a trampoline. She hit the ceiling! I was addicted to the hokey pokey, but then I turned myself around. I used to think I was indecisive. But now I’m not so sure. Russian dolls are so full of themselves. The easiest time to add insult to injury is when you’re signing someone’s cast. Light travels faster than sound, which is the reason that some people appear bright before you hear them speak. My therapist says I have a preoccupation for revenge. We’ll see about that. A termite walks into the bar and asks, ‘Is the bar tender here?’ A told my girlfriend she drew her eyebrows too high. She seemed surprised. People who use selfie sticks really need to have a good, long look at themselves. Two fish are in a tank. One says, ‘How do you drive this thing?’ I always take life with a grain of salt. And a slice of lemon. And a shot of tequila. Just burned 2,000 calories. That’s the last time I leave brownies in the oven while I nap. Always borrow money from a pessimist. They’ll never expect it back. Build 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. I don’t suffer from insanity—I enjoy every minute of it. The last thing I want to do is hurt you; but it’s still on the list. The problem isn’t that obesity runs in your family. It’s that no one runs in your family. Today a man knocked on my door and asked for a small donation toward the local swimming pool. I gave him a glass of water. I’m reading a book about anti-gravity. It’s impossible to put down. ‘Doctor, there’s a patient on line one that says he’s invisible.’ ‘Well, tell him I can’t see him right now.’ Atheism is a non-prophet organization. A recent study has found that women who carry a little extra weight live longer than the men who mention it. The future, the present, and the past walk into a bar. Things got a little tense. Before 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. Last night my girlfriend was complaining that I never listen to her… or something like that. Maybe if we start telling people their brain is an app, they’ll want to use it. If a parsley farmer gets sued, can they garnish his wages? I 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. I didn’t think orthopedic shoes would help, but I stand corrected. I 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. People who take care of chickens are literally chicken tenders. It was an emotional wedding. Even the cake was in tiers. I just got kicked out of a secret cooking society. I spilled the beans. What’s a frog’s favorite type of shoes? Open toad sandals. Blunt pencils are really pointless. 6:30 is the best time on a clock, hands down. Two wifi engineers got married. The reception was fantastic. Just got fired from my job as a set designer. I left without making a scene. What’s the difference between ignorance and apathy? I don’t know and I don’t care. I buy all my guns from a guy called T-Rex. He's a small arms dealer. One of the cows didn’t produce milk today. It was an udder failure. Adam & Eve were the first ones to ignore the Apple terms and conditions. Refusing to go to the gym is a form of resistance training. If attacked by a mob of clowns, go for the juggler. The man who invented Velcro has died. RIP. Despite the high cost of living, it remains popular. A dung beetle walks into a bar and asks, ‘Is this stool taken?’ I can tell when people are being judgmental just by looking at them. The rotation of Earth really makes my day. Well, to be Frank with you, I’d have to change my name. My friend was explaining electricity to me, but I was like, ‘Watt?’ What if there were no hypothetical questions? Are people born with photographic memories, or does it take time to develop? The world champion tongue twister got arrested. I hear they’re going to give him a tough sentence. Pollen is what happens when flowers can’t keep it in their plants. A book fell on my head the other day. I only have my shelf to blame though. Communist jokes aren’t funny unless everyone gets them. Geology rocks, but geography’s where it’s at. I buy all my guns from a guy called T-Rex. He’s a small arms dealer. My friend’s bakery burned down last night. Now his business is toast. Four fonts walk into a bar. The bartender says, ‘Hey! We don’t want your type in here!’ If you don’t pay your exorcist, do you get repossessed? When the cannibal showed up late to the buffet, they gave him the cold shoulder. A Mexican magician tells the audience he will disappear on the count of three. He says, ‘Uno, dos…” and poof! He disappeared without a tres. Fighting for peace is like screwing for virginity. A ghost walked into a bar and ordered a shot of vodka. The bartender said, ‘Sorry, we don’t serve spirits here.’ The man who invented knock-knock jokes should get a no bell prize. I bought the world’s worst thesaurus yesterday. Not only is it terrible, it’s also terrible. A blind man walked into a bar… and a table… and a chair… A Freudian slip is when you mean one thing and mean your mother. I went to a seafood disco last week, but ended up pulling a mussel. The first time I got a universal remote control, I thought to myself, ‘This changes everything.’ How do you make holy water? You boil the hell out of it. I saw a sign the other day that said, ‘Watch for children,’ and I thought, ‘That sounds like a fair trade.’ Whiteboards are remarkable. I threw a boomerang a couple years ago; I know live in constant fear. I put my grandma on speed dial the other day. I call it insta-gram. I have a few jokes about unemployed people, but none of them work. ‘I have a split personality,’ said Tom, being Frank. My teachers told me I'd never amount to much because I procrastinate so much. I told them, "Just you wait!" Will glass coffins be a success? Remains to be seen. Did you hear about the guy whose whole left side got amputated? He’s all right now. The man who survived both mustard gas and pepper spray is a seasoned veteran now. Have you heard about the new restaurant called ‘Karma?’ There’s no menu—you get what you deserve. What did one pirate say to the other when he beat him at chess?<>Checkmatey. I burned 2000 calories today<>I left my food in the oven for too long. I 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!” I broke my arm in two places. <>My doctor told me to stop going to those places. I quit my job at the coffee shop the other day. <>It was just the same old grind over and over. I never buy anything that has Velcro with it...<>it’s a total rip-off. I used to work at a soft drink can crushing company...<>it was soda pressing. I wondered why the frisbee kept on getting bigger. <>Then it hit me. I was going to tell you a fighting joke...<>but I forgot the punch line. What is the most groundbreaking invention of all time? <>The shovel. I’m starting my new job at a restaurant next week. <>I can’t wait. I visited a weight loss website...<>they told me I have to have cookies disabled. Did you hear about the famous Italian chef that recently died? <>He pasta way. Broken guitar for sale<>no strings attached. I could never be a plumber<>it’s too hard watching your life’s work go down the drain. I cut my finger slicing cheese the other day...<>but I think I may have grater problems than that. What time did you go to the dentist yesterday?<>Tooth-hurty. What kind of music do astronauts listen to?<>Neptunes. Rest in peace, boiled water. <>You will be mist. What is the only concert in the world that costs 45 cents? <>50 Cent, featuring Nickelback. It’s not a dad bod<> it’s a father figure. My 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. What do you call Santa’s little helpers? <>Subordinate clauses. Want to hear a construction joke? <>Sorry, I’m still working on it. What’s the difference between a hippo and a zippo? <>One is extremely big and heavy, and the other is a little lighter. I burnt my Hawaiian pizza today in the oven, <>I should have cooked it on aloha temperature. Anyone can be buried when they die<>but if you want to be cremated then you have to urn it. Where did Captain Hook get his hook? <>From the second-hand store. I am such a good singer that people always ask me to sing solo<>solo that they can’t hear me. I am such a good singer that people ask me to sing tenor<>tenor twelve miles away. Occasionally to relax I just like to tuck my knees into my chest and lean forward.<> That’s just how I roll. What did the glass of wine say to the glass of beer? Nothing. <>They barley knew each other. I’ve never trusted stairs. <>They are always up to something. Why did Shakespeare’s wife leave him? <>She got sick of all the drama. I just bought a dictionary but all of the pages are blank. <>I have no words to describe how mad I am. If you want to get a job at the moisturizer factory... <>you’re going to have to apply daily. I don’t know what’s going to happen next year. <>It’s probably because I don’t have 2020 vision. Want to hear a joke about going to the bathroom? <>Urine for a treat. I couldn’t figure out how to use the seat belt. <>Then it just clicked. I got an email the other day teaching me how to read maps backwards<>turns out it was just spam. I'm reading a book about anti-gravity.<> It's impossible to put down! You'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. Did you know the first French fries weren't actually cooked in France?<> They were cooked in Greece. Want to hear a joke about a piece of paper? Never mind... <>it's tearable. I just watched a documentary about beavers. <>It was the best dam show I ever saw! If you see a robbery at an Apple Store what re you?<> An iWitness? Spring is here! <>I got so excited I wet my plants! What’s Forrest Gump’s password?<> 1forrest1 Why did the Clydesdale give the pony a glass of water? <>Because he was a little horse! CASHIER: "Would you like the milk in a bag, sir?" <>DAD: "No, just leave it in the carton!’” Did you hear about the guy who invented Lifesavers? <>They say he made a mint. I bought some shoes from a drug dealer.<> I don't know what he laced them with, but I was tripping all day! Why do chicken coops only have two doors?<> Because if they had four, they would be chicken sedans! How do you make a Kleenex dance? <>Put a little boogie in it! A termite walks into a bar and asks<>"Is the bar tender here?" Why did the invisible man turn down the job offer?<> He couldn't see himself doing it. I used to have a job at a calendar factory <>but I got the sack because I took a couple of days off. A 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!” How do you make holy water?<> You boil the hell out of it. I had a dream that I was a muffler last night.<> I woke up exhausted! Did you hear about the circus fire?<> It was in tents! Don't trust atoms.<> They make up everything! How many tickles does it take to make an octopus laugh? <>Ten-tickles. I’m only familiar with 25 letters in the English language.<> I don’t know why. Why did the cow in the pasture get promoted at work?<> Because he is OUT-STANDING in his field! What do prisoners use to call each other?<> Cell phones. Why couldn't the bike standup by itself? <>It was two tired. Who was the fattest knight at King Arthur’s round table?<> Sir Cumference. Did you see they made round bails of hay illegal in Wisconsin? <>It’s because the cows weren’t getting a square meal. You know what the loudest pet you can get is?<> A trumpet. What do you get when you cross a snowman with a vampire?<> Frostbite. What do you call a deer with no eyes?<> No idea! Can February March? <>No, but April May! What do you call a lonely cheese? <>Provolone. Why can't you hear a pterodactyl go to the bathroom?<> Because the pee is silent. What did the buffalo say to his son when he dropped him off at school?<> Bison. What do you call someone with no body and no nose? <>Nobody knows. You heard of that new band 1023MB? <>They're good but they haven't got a gig yet. Why did the crab never share?<> Because he's shellfish. How do you get a squirrel to like you? <>Act like a nut. Why don't eggs tell jokes? <>They'd crack each other up. Why can't a nose be 12 inches long? <>Because then it would be a foot. Did you hear the rumor about butter? <>Well, I'm not going to spread it! I made a pencil with two erasers. <>It was pointless. I used to hate facial hair...<>but then it grew on me. I decided to sell my vacuum cleaner—<>it was just gathering dust! I had a neck brace fitted years ago<> and I've never looked back since. You know, people say they pick their nose,<> but I feel like I was just born with mine. What do you call an elephant that doesn't matter?<> An irrelephant. What do you get from a pampered cow? <>Spoiled milk. It's inappropriate to make a 'dad joke' if you're not a dad.<> It's a faux pa. How do lawyers say goodbye? <>Sue ya later! Wanna hear a joke about paper? <>Never mind—it's tearable. What's the best way to watch a fly fishing tournament? <>Live stream. I could tell a joke about pizza,<> but it's a little cheesy. When does a joke become a dad joke?<> When it becomes apparent. What’s an astronaut’s favorite part of a computer? <>The space bar. What did the shy pebble wish for?<>That she was a little boulder. I'm tired of following my dreams. <>I'm just going to ask them where they are going and meet up with them later. Did you hear about the guy whose whole left side was cut off? <>He's all right now. Why didn’t the skeleton cross the road? <>Because he had no guts. What did one nut say as he chased another nut? <> I'm a cashew! Chances are if you' ve seen one shopping center...<> you've seen a mall. I knew I shouldn't steal a mixer from work...<>but it was a whisk I was willing to take. How come the stadium got hot after the game? <>Because all of the fans left. Why was it called the dark ages? <>Because of all the knights. Why did the tomato blush? <>Because it saw the salad dressing. Did you hear the joke about the wandering nun? <>She was a roman catholic. What creature is smarter than a talking parrot? <>A spelling bee. I'll tell you what often gets over looked...<> garden fences. Why did the kid cross the playground? <>To get to the other slide. Why do birds fly south for the winter?<> Because it's too far to walk. What is a centipedes's favorite Beatle song? <> I want to hold your hand, hand, hand, hand... My first time using an elevator was an uplifting experience. <>The second time let me down. To be Frank...<> I'd have to change my name. Slept like a log last night … <>woke up in the fireplace. Why does a Moon-rock taste better than an Earth-rock? <>Because it's a little meteor. How many South Americans does it take to change a lightbulb?<> A Brazilian I don't trust stairs.<> They're always up to something. A police officer caught two kids playing with a firework and a car battery.<> He charged one and let the other one off. What is the difference between ignorance and apathy?<>I don't know and I don't care. I went to a Foo Fighters Concert once... <>It was Everlong... Some people eat light bulbs. <>They say it's a nice light snack. What do you get hanging from Apple trees? <> Sore arms. Last night me and my girlfriend watched three DVDs back to back.<> Luckily I was the one facing the TV. I got a reversible jacket for Christmas,<> I can't wait to see how it turns out. What did Romans use to cut pizza before the rolling cutter was invented? <>Lil Caesars My pet mouse 'Elvis' died last night. <>He was caught in a trap.. Never take advice from electrons. <>They are always negative. Why are oranges the smartest fruit? <>Because they are made to concentrate. What did the beaver say to the tree? <>It's been nice gnawing you. How do you fix a damaged jack-o-lantern?<> You use a pumpkin patch. What did the late tomato say to the early tomato? <>I’ll ketch up I have kleptomania...<>when it gets bad, I take something for it. I used to be addicted to soap...<> but I'm clean now. When is a door not a door?<> When it's ajar. I made a belt out of watches once...<> It was a waist of time. This furniture store keeps emailing me,<> all I wanted was one night stand! How do you find Will Smith in the snow?<> Look for fresh prints. I just read a book about Stockholm syndrome.<> It was pretty bad at first, but by the end I liked it. Why do trees seem suspicious on sunny days? <>Dunno, they're just a bit shady. If at first you don't succeed<> sky diving is not for you! What kind of music do mummy's like?<>Rap A book just fell on my head. <>I only have my shelf to blame. What did the dog say to the two trees? <>Bark bark. If a child refuses to sleep during nap time...<> are they guilty of resisting a rest? Have you ever heard of a music group called Cellophane?<> They mostly wrap. What did the mountain climber name his son?<>Cliff. Why should you never trust a pig with a secret?<> Because it's bound to squeal. Why are mummys scared of vacation?<> They're afraid to unwind. Whiteboards ...<> are remarkable. What kind of dinosaur loves to sleep?<>A stega-snore-us. What kind of tree fits in your hand?<> A palm tree! I used to be addicted to the hokey pokey<> but I turned myself around. How many tickles does it take to tickle an octopus?<> Ten-tickles! What musical instrument is found in the bathroom?<> A tuba toothpaste. My boss told me to attach two pieces of wood together... <>I totally nailed it! What was the pumpkin’s favorite sport?<>Squash. What do you call corn that joins the army?<> Kernel. I've been trying to come up with a dad joke about momentum <>but I just can't seem to get it going. Why don't sharks eat clowns? <> Because they taste funny. Just read a few facts about frogs.<> They were ribbiting. Why didn’t the melons get married?<>Because they cantaloupe. What’s a computer’s favorite snack?<>Microchips! Why was the robot so tired after his road trip?<>He had a hard drive. Why did the computer have no money left?<>Someone cleaned out its cache! I'm not anti-social. <>I'm just not user friendly. Why did the computer get cold?<>Because it forgot to close windows. What is an astronaut's favorite key on a keyboard?<>The space bar! What's the difference between a computer salesman and a used-car salesman?<>The used-car salesman KNOWS when he's lying. If at first you don't succeed...<> call it version 1.0 Why did Microsoft PowerPoint cross the road?<>To get to the other slide! What did the computer do at lunchtime?<>Had a byte! Why did the computer keep sneezing?<>It had a virus! What did one toilet say to the other?<>You look a bit flushed. Why did the picture go to jail?<>Because it was framed. What did one wall say to the other wall?<>I'll meet you at the corner. What do you call a boy named Lee that no one talks to?<>Lonely Why do bicycles fall over?<>Because they are two-tired! Why was the broom late?<>It over swept! What part of the car is the laziest?<>The wheels, because they are always tired! What's the difference between a TV and a newspaper?<>Ever tried swatting a fly with a TV? What did one elevator say to the other elevator?<>I think I'm coming down with something! Why was the belt arrested?<>Because it held up some pants! What makes the calendar seem so popular?<>Because it has a lot of dates! Why did Mickey Mouse take a trip into space?He wanted to find Pluto! Why do you go to bed every night?<>Because the bed won't come to you! What has four wheels and flies?<>A garbage truck! Why did the robber take a bath before he stole from the bank?<>He wanted to make a clean get away! Just watched a documentary about beavers.<>It was the best damn program I’ve ever seen. Slept like a log last night<>woke up in the fireplace. Why did the scarecrow win an award?<>Because he was outstanding in his field. Why does a chicken coop only have two doors? <>Because if it had four doors it would be a chicken sedan. What’s the difference between an African elephant and an Indian elephant? <>About 5000 miles Why did the coffee file a police report? <>It got mugged. What did the grape do when he got stepped on? <>He let out a little wine. How many apples grow on a tree? <>All of them. What name do you give a person with a rubber toe? <>Roberto Did you hear about the kidnapping at school? <>It’s fine, he woke up. Why do scuba divers fall backwards into the water? <>Because if they fell forwards they’d still be in the boat. How does a penguin build it’s house? <>Igloos it together. What do you call a man with a rubber toe?<>Roberto Did you hear about the restaurant on the moon?<>Great food, no atmosphere. Why was the belt sent to jail?<>For holding up a pair of pants! Did you hear about the scientist who was lab partners with a pot of boiling water?<>He had a very esteemed colleague. What happens when a frogs car dies?<>He needs a jump. If that doesn't work he has to get it toad. What did the flowers do when the bride walked down the aisle?<>They rose. Why did the man fall down the well?<>Because he couldn’t see that well. My boss told me to have a good day...<>...so I went home. How can you tell it’s a dogwood tree?<>By the bark. Did you hear about the kidnapping at school?<>It’s fine, he woke up. Why is Peter Pan always flying?<>Because he Neverlands. Which state has the most streets?<>Rhode Island. What do you call 26 letters that went for a swim?<>Alphawetical. Why was the color green notoriously single?<>It was always so jaded. Why did the coach go to the bank?<>To get his quarterback. How do celebrities stay cool?<>They have many fans. What's the most depressing day of the week?<>sadder day. Dogs can’t operate MRI machines<>But catscan. I was going to tell a time-traveling joke<>but you guys didn’t like it. Stop looking for the perfect match<>instead look for a lighter. I told my doctor I heard buzzing<>but he said it’s just a bug going around. What kind of car does a sheep like to drive?<>A lamborghini. What did the accountant say while auditing a document?<>This is taxing. What did the two pieces of bread say on their wedding day?<>It was loaf at first sight. Why do melons have weddings?<>Because they cantaloupe. What did the drummer call his twin daughters?<>Anna One, Anna Two! What do you call a toothless bear?<> A gummy bear! Two goldfish are in a tank. <>One says to the other, “Do you know how to drive this thing?” What’s Forrest Gump’s password?<>1forrest1 What is a child guilty of if they refuse to nap?<> Resisting a rest. I know a lot of jokes about retired people<>but none of them work. Why are spiders so smart?<>They can find everything on the web. What has one head, one foot, and four legs?<> A bed. What does a house wear?<> Address. What’s red and smells like blue paint?<>Red paint. My son asked me to put his shoes on<> but I don’t think they’ll fit me. I’ve been bored recently, so I decided to take up fencing.<> The neighbors keep demanding that I put it back. What do you call an unpredictable camera?<>A loose Canon. Which U.S. state is known for its especially small soft drinks?<>Minnesota. What do sprinters eat before a race?<> Nothing—they fast. I’m so good at sleeping...<>I can do it with my eyes closed. People are usually shocked that I have a Police record.<>But I love their greatest hits! I told my girlfriend she drew on her eyebrows too high.<> She seemed surprised. What do you call a fibbing cat?<> A lion. Why shouldn’t you write with a broken pencil?<> Because it’s pointless. I like telling Dad jokes…<>sometimes he laughs. How do you weigh a millennial?<> In Instagrams. The wedding was so beautiful<>even the cake was in tiers. What’s the most patriotic sport?<> Flag football. How do you know when you are going to drown in milk? When its past your eyes! Milk is also the fastest liquid on earth – its pasteurized before you even see it A steak pun is a rare medium well done. Did you hear that the police have a warrant out on a midget psychic ripping people off? It reads "Small medium at large." A panda walks into a bar and says to the bartender "I'll have a Scotch and . . . . . . . . . . . . . . Coke thank you". "Sure thing" the bartender replies and asks "but what's with the big pause?" The panda holds up his hands and says "I was born with them" A 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. I heard there was a new store called Moderation. They have everything there Our wedding was so beautiful, even the cake was in tiers. Did you hear about the new restaurant on the moon? The food is great, but there's just no atmosphere. I 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. What did the mountain climber name his son? Cliff. "What's ET short for? Because he's only got little legs." What do you call an Argentinian with a rubber toe? Roberto What do you call a Mexican man leaving the hospital? Manuel Today a girl said she recognized me from vegetarian club, but I'm sure I've never met herbivore. I 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. I needed a password eight characters long so I picked Snow White and the Seven Dwarfs. Last night me and my girlfriend watched three DVDs back to back. Luckily I was the one facing the TV. How do you organize a space party? You planet. Breaking news! Energizer Bunny arrested – charged with battery. Conjunctivitis.com – now that's a site for sore eyes. A Sandwich walks into a bar, the bartender says "Sorry, we don't serve food here" They laughed when I said I wanted to be a comedian – they're not laughing now. I'm reading a book on the history of glue – can't put it down. Where does Napoleon keep his armies? In his sleevies. I went to the zoo the other day, there was only one dog in it. It was a shitzu. Why can't you hear a pterodactyl go to the bathroom? The p is silent. Q: What's 50 Cent's name in Zimbabwe? A: 400 Million Dollars. "My Dog has no nose." "How does he smell?" "Awful" What do you call a cow with no legs? Ground beef. What did the Buffalo say to his little boy when he dropped him off at school? Bison. So a duck walks into a pharmacy and says "Give me some chap-stick... and put it on my bill" Why did the scarecrow win an award? Because he was outstanding in his field. Why did the girl smear peanut butter on the road? To go with the traffic jam. Why does a chicken coop only have two doors? Because if it had four doors it would be a chicken sedan. Why don't seagulls fly over the bay? Because then they'd be bay-gulls! What do you call a fly without wings? A walk. What do you do when a blonde throws a grenade at you? Pull the pin and throw it back. What's brown and sounds like a bell? Dung! How do you make a hankie dance? Put a little boogie in it. Where does batman go to the bathroom? The batroom. What's the difference between an African elephant and an Indian elephant? About 5000 miles. Two 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!" A man walks into a bar and orders helicopter flavor chips. The barman replies "sorry mate we only do plain" Sgt.: Commissar! Commissar! The troops are revolting! Commissar: Well, you're pretty repulsive yourself. What do you call a sheep with no legs? A cloud. I knew i shouldn't have ate that seafood. Because now i'm feeling a little... Eel What did the late tomato say to the early tomato? I'll ketch up What did the 0 say to the 8? Nice belt. Why didn't the skeleton cross the road? Because he had no guts. Why don't skeletons ever go trick or treating? Because they have nobody to go with. Why do scuba divers fall backwards into the water? Because if they fell forwards they'd still be in the boat. Have you ever heard of a music group called Cellophane? They mostly wrap. What kind of magic do cows believe in? MOODOO. Wife: 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. At what time does the soldier go to the dentist? 1430. "Hold on, I have something in my shoe" "I'm pretty sure it's a foot" Why 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! Dad I'm hungry' ... Hi hungry I'm dad When phone ringing Dad says 'If it's for me don't answer it.' Put the cat out ... I didn't realize it was on fire Where's the bin? Dad: I haven't been anywhere! Can I watch the TV? Dad: Yes, but don't turn it on. When Dad drops a pea off of his plate 'oh dear I've pee'd on the table!' I'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. Old yachtsmen don't die... They just keel over. 3.14% of sailors are pi-rates. Bad at golf? Join the club. I just ate a frozen apple. Hardcore. Have you met my friend Annette? She's married to a fisherman. Why is Irish whiskey triple distilled? To be sure, to be sure, to be sure. I just read a book about Stockholm syndrome. It was pretty bad at first, but by the end I liked it. RIP boiled water. You will be mist. Archaeology really is a career in ruins... I don't trust stairs. They're always up to something. If you want a job in the moisturiser industry, the best advice I can give is to apply daily. A big cat escaped it's cage at the zoo yesterday. If I saw that I'd puma pants. My Czech mate is surprisingly bad at chess. Why are Lada's so bad? Because the keep Stalin. What do you get hanging off banana trees? Sore arms. I made my wife a cocktail with fairy liquid in it.... She was foaming at the mouth when she tasted it. What do you call a fat psychic? A four-chin teller. Found out I was colour blind the other day... That one came right out the purple. I hate perforated lines, they're tearable. A man tried to sell me a coffin today... I told him that's the last thing I need. Whenever I want to start eating healthy, a chocolate bar looks at me and Snickers. Don't kiss your wife with a runny nose. You might think it's funny, but it's snot. My friend keeps telling me I'm in the closet. I just say it's Narnia business. @WillFerreI I burnt my Hawaiian pizza last night... I should've put it on aloha setting. Dad: 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 My son asked me to stop singing oasis songs in public. I said maybe. When my wife told me to stop impersonating a flamingo I had to put my foot down. What's the difference between a hippo and a zippo? One is really heavy, the other is a little lighter. To the man in the wheelchair that stole my camouflage jacket... You can hide but you can't run. They don't watch the flintstones in Dubai. But Abu Dhabi do. Lone 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..." Why can't you hear a pterodactyl using the bathroom? Because the P is silent Happy Father's Day! Did you hear about the crazy Mexican train thief? He had loco motives Singing in the shower is all fun and games until you get shampoo in your mouth.... Then it's a soap opera The rotation of earth really makes my day. You can't run through a camp site. You can only ran, because it's past tents. "Does this uniform make me look fat" - insecurity guard How do you tell the difference between a crocodile and an alligator? You will see one later and one in a while. I told my wife she drew her eyebrows too high. She seemed surprised. Why do trees seem suspicious on sunny days? Dunno, they're just a bit shady. You know what they say about cliffhangers... Want to hear a joke about construction? Nah, I'm still working on it. Ever noticed that glass tastes like blood? A classic from who's line is it anyway. You heard the rumor going around about butter? Nevermind, I shouldn't spread it. I have the heart of a lion and a lifetime ban from London zoo. @zsllondonzoo What did the Buddhist ask the hot dog vendor? "Make me one with everything." How does the moon cut his hair? Eclipse it! What do you call an elephant that doesn't matter? An irrelephant What happened to the cow that jumped over the barbed wire fence? Udder destruction. I thought about going on an all-almond diet..... But that's just nuts What's a duck's favourite dip? Quackamole What do you call a fake noodle? An Impasta I hate it when people ask me what I will be doing in 5 years time. Come on, I don't have 2020 vision. Steak puns... They're a rare medium, well done The shovel was a ground-breaking invention. Past, present, and future walked into a bar.... It was tense. Comedians who tell one too many lightbulb jokes soon burn out. Look! I'm wearing a Thai. What do you call a Mexican who has lost his car? Carlos. How does a penguin build it's house? Igloos it together. Knock knock. Who's there? To. To Who? To whom. Why do you never see elephants hiding in trees? Because they're so good at it. I went out with a girl called Simile, I don't know what I metaphor. I went on a two week holiday to the south of France. It was Toulon. A 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! Plateaus are the highest form of flattery. Me: Doctor you've got to help me, I'm addicted to Twitter. Doctor: I don't follow you. There's no I in denial. My computer sings, it's a Dell. It's time to rock around the Christmas tree. I got a reversible jacket for Christmas, I can't wait to see how it turns out. I ate a clock yesterday, it was so time consuming. I'm tired of following my dreams. I'm just going to ask them where they are going and meet up with them later. What's brown and sticky? A stick. How do you find Will Smith in the snow? You look for the fresh prints. Did you hear about the kidnapping at school? Its ok, he woke up. What's the best thing about elevator jokes? They work on so many levels. How do you make antifreeze? Steal her blanket. What's the difference between beer nuts and deer nuts? Beer nuts are about 49cents and deer nuts are just under a buck. Did you hear about the guy who jumped off a bridge in Paris? He was in Seine. There are only two types of people in the world, those who can extrapolate from incomplete data... What did the buffalo say to his son as he left for college? Bison A truck of Terrapins crashed into a truck of tortoises. It was a turtle disaster. What does a house wear? A dress. I asked a Frenchman if he played video games. He said "wii". Full Meal Jacket A furniture store keeps calling me. But all I wanted was one night stand. What did the dog say after a long day at work? "Today was Ruff" Where are average things built? In the satisfactory. I've eaten too much Middle Eastern food. Now I falafel. A pet store had a bird contest. No perches necessary. What's the worst thing about ancient history class? The teachers tend to Babylon. Yesterday a clown held a door open for me. I thought it was a nice jester. How many optometrists does it take to change a light bulb?... 1 or 2? 1... or 2? My 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." Just read a few facts about frogs. They were ribbiting. Sean Connery famously said he would leave The Bahamas and return to Scotland, if it ever gained independence. He must be shitting himself. I used to work in a shoe recycling shop. It was sole destroying. The universe implodes. No matter. I can give you the cause of an anaphylactic shock in a nutshell. I just swapped our bed for a trampoline. My wife hit the roof. I heard a rumour that Cadbury is bringing out an oriental chocolate bar. Could be a Chinese Wispa. My dog Minton ate a shuttlecock... Bad Minton. Astronomers got tired of watching the moon go round the earth for 24 hours. So the decided to call it a day. I've got an addiction to water, I think I'm an aquaholic. What did the hungry clock do? Went back four seconds! My sea sickness comes in waves. I play triangle for a reggae band. It's pretty casual. I just stand at the back and ting. I'm afraid I've caught poetry. Don't worry, I used to suffer from short stories. Really?When? Once upon a time I asked the checkout girl for a date. She said "They're in the fruit aisle next to the bananas." What did the chicken say about the scrambled egg? There goes my crazy, mixed up kid. Why do so many people with laser hair want to get it removed? What's the difference between a well dressed man on a a bicycle and a poorly dressed man on a tricycle? Attire! Why does Peter pan always fly?Because he neverlands! For all American Dads, this is all you need today. What did the pirate say on his 80th birthday? Aye matey I jumped into the sea today. My friends pier pressured me into it. What do you call a sketchy Italian neighbourhood? The Spaghetto. I have kleptomania, but when it gets bad, I take something for it. Why can't you have a nose 12 inches long? Because then it would be a foot. Why do bears have hairy coats? Fur protection. Someone said my clothes were gay. I said "Yeah, they came out of the closet this morning." I just misspelt Armageddon, it's not the end of the world. Volunteering in America is absurd, it just makes no cents. Jonny Wilkinson is announcing his retirement from rugby. You can't say he didn't try. Why don't you want to taco bout it? 'Cause i'm nacho friend anymore. Doorbells, don't knock 'em. I'm back from holiday in the South Pacific. I wish I had Samoa time off. "I'm on a whiskey diet, I've lost 4 days already." Tommy Cooper What's your favorite Cooperism? My wife is on a tropical food diet, the house is full of the stuff. It's enough to make a mango crazy. Whiteboards are remarkable. Sweet dreams are made of cheese, who am I to dis a Brie. Happy Easter! What's your best egg yolk? Mine is: A boiled egg is hard to beat. What do you call an Alligator wearing a vest? An investigator. Did you hear about the magic tractor? It turned into a field. Can February march? No, but April May. Full credit to the whoever made this for Putin in the effort. What does a grape say when it is stepped on? Nothing, it just lets out a little wine. What do you call a dinosaur with an extensive vocabulary? A thesaurus. I swallowed some Tippex last night. I woke up this morning with a massive correction. Just got a text from Snoop Dogg. No biggy. What do you get when you cross a rhetorical question with a joke? Pink Panthers to do list: To do To do To do, to do, to do To do, to doooo What did the bra say to the hat? You go on ahead, I'll give these two a lift. What did one eye say to the other? Something smells between us. Two elephants fall off a cliff... Boom boom! How many tickles does it take to make an octopus laugh? Tentacles. I don't like atoms, they're liars. They make up everything. If you want to set up a company and run it, then that's your own business. My friend is going on holiday to the Middle East. Oman, that sounds fun... Whoever invented the door knocker deserves a no-bell prize! Why did the elf push his bed into the fireplace? He wanted to sleep like a log. I can't stand Russian dolls.... They're so full of themselves. I remember the first time I saw a universal remote controller. I thought to myself "well, this changes everything..." I got this extra electron I didn't want. My friend said "don't be so negative." A boat builder is showing his son one of his forests. He turns to him and says, "Son, one day this will all be oars" Molestation is a touchy subject. I’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. I was thinking about moving to Moscow but there is no point Russian into things. I met a Dutch girl with inflatable shoes last week, I phoned her up for a date but she'd popped her clogs. My New Years resolution is to stop leaving things so late. Did you hear about the man who gave up making haggis? He didn't have the guts for it anymore. Retrospective baddadjoke: Why are there no pain killers in the jungle? Because parrots-eat-em-all Sometimes I squat on the floor, put my arms around my legs and lean forward. That's how I roll. Got lost in a corn field today, it was a-maize-ing. I needed a password eight characters long so I picked Snow White and the Seven Dwarves. Just out buying some new chairs for the house, sofa so good. My wife told me I was average, I think she's mean. I was going to tell a dairy joke, but it was too cheesy. Just had my first round of golf. I'm not very good, in fact I've got a fairway to go. My daughter just lost her mood ring, really don't know how she feels about it. I told a friend I was off to California this summer. He told me to be more pacific... so I went to Hawaii instead... I gave all my dead batteries away today... Free of charge. Why is there a long line at the cemetery? Because people are dying to get in. Why did the can crusher quit his job? Because it was soda pressing. I'm starting a band called 1023mb We'll never get a gig. What's Forest Gump's Facebook password? 1forest1 A photon checks into a hotel. Receptionist: "May I take your bags sir?" Photon: "I don't have any bags, I'm travelling light." Melon 1: "Let's run away and get married." Melon 2: "Sorry but I Cantaloupe." Did you hear about the Italian chef who died? He pasta way. I lost my job last week. Unemployment is not working for me. A termite walks into a bar and asks "Is the bar tender here?" Hitler was surprised by the Invasion of Normandy. He did nazi that coming. A Freudian slip is when you say one thing but mean your mother. So, I asked my North Korean mate how his life was going? He said "can't complain" Just quit my job at Starbucks because day after day it was the same old grind. I went to the zoo the other day, there was only one dog in it, it was a shitzu. Why is Saudi Arabia free of mental illness? Because No-mad people live there. Without geometry life is pointless. I broke my guitar string last night. Don't fret, I had another. Had a new beaver curry last night. It's like a normal curry, just a bit 'otter. Went to the corner shop today... Bought four corners. Have you heard the conspiracy about Russian allotments. It's all just a communist plot. My uncle works with Digital radios. You could say he’s a DAB hand. I 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. My cat was just sick on the carpet, I don't think it's feline well. Why do the French only put one egg in an omelette? Because one egg is un oeuf. The other day someone left plasticine in my house. I didn't know what to make of it. What happens when you tell an egg a joke? It cracks up. How do you make holy water? Boil the hell out of it. Sorry I've been away for a while, I was at the fabric shop looking for new material. I've just been to a very emotional wedding. Even the cake was in tiers. When you have a bladder infection, urine trouble. I stayed up all night to find out where the sun went, then it dawned on me... I went to the doctor today and he told me I had type A blood but it was a type O. Today a girl said she recognised me from vegetarian club, but I'm sure I've never met herbivore. Jokes about German sausages are the wurst. I tried to throw a ball at a cloud. I mist. I woke up with a face full of rice. I must've fallen asleep as soon as my head hit the pilau. I 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! I cut my finger chopping cheese, but I think that I may have grater problems. First rule of Thesaurus Club. You don't talk, converse, discuss, speak, chat, deliberate, confer, gab, gossip or natter about Thesaurus Club Don't have a Findus lasagne before bed. You'll have a nightMARE. How does a muppet die? Apparently, it kermits suicide. What did the Mexican say to his chicken? Oh-lay! A pet shop was ransacked last week... ...there are currently no leads. How do you drown a hipster? In the mainstream. Sleeping comes naturally to me. I can do it with my eyes closed. I ate some rotten chicken last night. Now I feel fowl. There is a new disease found in margarine... Apparently it spreading very easily. What do you call an Italian with a rubber toe? Roberto Why do crabs never give to charity? Because they're shellfish. What is Santa's favourite pizza? One that's deep pan, crisp and even. People are making apocalypse jokes like there's no tomorrow. Someone called me pretentious the other day... I almost choked on my latte. My mate dug a hole in the garden and filled it with water....I think he meant well. What's your favourite Christmas Cracker Joke? Here's one of mine: "What's ET short for? Because he's only got little legs." If 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. A mate of mine has admitted to being addicted to break fluid. I'm worried but he says he can stop whenever he wants. Start a new job in Seoul next week. I thought it was a good Korea move. Soya Milk. Looked in your fridge. A book just fell on my head. I've only got myshelf to blame. Bloody thespians, always making a scene. My dad fought in the war and survived mustard gas and pepper spray. He is now classed as a seasoned veteran. Tea is for mugs. This thesaurus isn't just terrible, it is also terrible. I am terrified of elevators. I'm going to start taking steps to avoid them. Need an ark to save two of every animal? I Noah guy. What did the father say to the son who was going fishing? Let minnow when you get there. I am delighted with the corn crop this year. It's A-maize-ing. How does Moses make his tea? Hebrews it. I think rowing is oarsome. What's the advantage of living in Switzerland? Well, the flag is a big plus. Nostalgia isn't what it used to be. Why do accountants look so good in heels? Because they never lose their balance. I'll stop at nothing to avoid using negative numbers. Wind turbines. I'm a big fan! What's the definition o a good farmer? A man outstanding in his field. Why did the octopus beat the shark in a fight? Because it was well armed. Why does a Moon-rock taste better than an Earth-rock? Because it's a little meteor. I fired my masseuse today. She rubbed me up the wrong way. A red and a blue ship have just collided in the Caribbean. Apparently the survivors are marooned. Breaking news! A hurricane has just hit the the main cheese factory in France. All that's left is de-Brie. I'm glad I know sign language, it's pretty handy. I like sea food. I often just have it for the halibut. A girl walks into a bar and asked for a double entendre. So the barman gave her one. I took the shell off of my racing snail to see if it went any faster. If anything though, it just made it more sluggish. I've deleted the phone numbers of all the Germans I know from my mobile phone. Now it's Hans free. Was kept awake last night by someone flashing a light in my face. It was torch-ure. My wife said to me "Your lack of originality is pathetic."I said "Yeah, well your lack of originality is pathetic." It was really hard overcoming my addiction to the hokey cokey. But I turned myself around and that’s what it’s all about. "I saw a documentary on how ships are kept together. Riveting!" Stewart Francis Last night me and my girlfriend watched three DVDs back to back. Luckily I was the one facing the telly. My wife just split up with me because I've got a pasta fetish. I'm feeling cannelloni right now. I'm thinking about getting a new haircut... I'm going to mullet over. Had a bowl of scotch broth for lunch today... It was souper hot. I got really quick service at the fish and chip shop. It was very e-fish-ent How do you organise a space party? You planet. What did one bird say to the other cheating parrot? Toucan play at that game. What's wrong with the Southern French's trousers? They're Toulouse. How much does a hipster weigh? An instagram. A photon enters a hotel. Porter: 'Need any help with your luggage?' Photon: 'No thanks, I'm travelling light' Give me ambiguity or give me something else. A banker came home from work today worried about his job. He said its in the balance. sorry "a *pod* of killer whales" What do you call a group of killer whales playing instruments? An Orca-stra. The only thing that can survive a A man has taken @British_Airways to court after they misplaced his luggage. He lost his case. Did you hear about the guy whose whole left side was cut off? He's all right now. Why was the big cat disqualified from the race? Because it was a cheetah. What do you call a man with rabbits living in his bum? Warren Just been fishing... It was reely good. A 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." It's so hard to think of another chemistry joke... All the good ones Argon. Why do people dislike mushrooms? Because they're made from Toads Stools... There was so much fighting on our Easter camping trip... it was in-tents. It's easter already?! I'm off to Nairobi in the Summer. Kenya believe it? My first girlfriend's name was Ivy... she was all over me. I've just voted for Charlie's odyssey by Charlie Denholm as the funniest film Helvetica walks into a bar. The barman says "We don't serve your type around here." Argon walks into a bar. The barman says "Get the hell out!" Argon doesn't react. Just watched a documentary about beavers... It was the best damn program I've ever seen. Last night it was raining cats and dogs... I stepped in a poodle. I thought about being a juggler, but I didn't have the balls. My mate got a job as a lion's hairdresser at the zoo today. He is literally the mane man. I'm not as think as you drunk I am. I'm thinking about moving to France... I've got nothing Toulouse. Went surfing the other day, it was swell. Watershed joke: A baker was caught bonking his bread loaves. They say he was inbread. The only thing that can survive a double dip is a hobnob. Osborne, call McVities. I enjoy using the comedy technique of self-deprecation – but I’m not very good at it. My wife... its difficult to say what she does... She sells seashells on the seashore. A poker player loses his arm in a nasty accident. He's now got a prosthetic replacement. He just can't deal with it. A girl invited me back to her place last night for champagne... It turned out it was real pain. Theres a new type of pillow made from corduroy... Its making headlines. What 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. Breaking news! Energizer Bunny arrested - charged with battery. Wow who saw that coming? Harry Potter and News of the World two of the Biggest selling modern fiction publications ending in the same week. I 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.' A man was found today vacuum cleaning the top of nelsons column without any safety equipment. Police say he was Dyson with death. A man went to A&E at the weekend who swallowed 12 plastic horses. Don't worry the doctors describe his condition as stable. Conjunctivitis.com - now that's a site for sore eyes. A guy walks into the psychiatrist wearing only clingfilm for shorts. The shrink says, "Well, I can clearly see you're nuts." WIMBLEDON SPECIAL Why should you never fall in love with a tennis player? To them, "Love" means nothing. I went to the doctor the other day I said 'have you got anything for wind' so he gave me a kite. A sandwich walks into a bar. The barman says "we don't serve food here." The recruitment consultant asked me "What do you think of voluntary work?" I said "I wouldn't do it if you paid me." An ice cream man was found lying on the floor of his van covered with hundreds and thousands. Police say that he topped himself. "Doctor, I've broken my arm in several places" Doctor "Well don't go to those places." A boiled egg in the morning is hard to beat. I'm on a whiskey diet. I've lost three days already. What do you do with chemists when they die? We barium. Pretty appropriate. Seven days without a pun makes one weak. Hand 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. What type of onion is the best painkiller? A-sprin' onion... Just passed a manicurist and a dentist quarreling in the street- they were fighting tooth and nail. I fear for the calendar, it's days are numbered. What's the definition of 'A Will'? (I'll give you a clue, it's a dead giveaway.) I buy a different brand of cling flim every time I go to the shops. Just to keep things fresh. The advantages of origami are twofold. There's a new type of broom out, it's sweeping the nation. Atheism is a non-prophet organisation. I went to a seafood disco last night and pulled a muscle. My friend drowned in a bowl of muesli. A strong currant pulled him in. Sometimes I drink my whiskey neat. Other times I take off my tie and untuck my shirt. I don't want to sound big headed but I wear extra large hats. My friend said "You remind me of a ketchup bottle", I said "I'll take that as a condiment". Slept like a log last night ... woke up in the fireplace. Exit signs - they're on the way out aren't they. What did the fish say when it swam into a wall? Damn! A cat hijacked a plane, stuck a pistol to the pilots ribs and said "TAKE ME TO THE CANARIES!" They laughed when I said I wanted to be a comedian - they're not laughing now. One arm butlers - they can take it, but they can't dish it out. A shark will only attack you if you're wet Beware of alphabet grenades, they might spell disaster. What cheese can never be yours? Nacho cheese. Last night I saw this guy chatting up a cheetah at the bar. I thought 'he's trying to pull a fast one.' My housemate opened the fridge last night and threw a block of cheese at me. I said "That's mature." A police officer caught two kids playing with a firework and a car battery. He charged one and let the other one off. I used to be indecisive, but now I'm not quite sure. Why are there no pain killers in the jungle? Because parrots-eat-em-all I'm reading a book on the history of glue - can't put it down. Albinos - can't say fairer than that. Velcro... What a rip-off. A 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." Two aerials meet on a roof, fall in love and get married... The ceremony was rubbish but the reception was excellent. Black beauty... He's a dark horse. ================================================ FILE: main.py ================================================ import argparse import importlib import json import multiprocessing import os import sys import time from prettytable import PrettyTable from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from util.config import Config, manage_config from util.logger import Logger from util.notification import ErrorNotifyHandler from util.scheduler import check_schedule from util.utility import create_bar from util.version import get_version, start_version_check list_of_python_modules = [ "border_replacerr", "health_checkarr", "labelarr", "nohl", "poster_cleanarr", "poster_renamerr", "renameinatorr", "sync_gdrive", "upgradinatorr", "unmatched_assets", "jduparr", ] class ScheduleFileHandler(FileSystemEventHandler): def __init__(self, callback, debounce_interval=1): super().__init__() self.callback = callback self.last_modified = 0 self.debounce_interval = debounce_interval def on_modified(self, event): sys.stderr.write(f"[WATCHDOG] Detected change in: {event.src_path}\n") if event.src_path.endswith("config.yml"): now = time.time() if now - self.last_modified > self.debounce_interval: self.last_modified = now self.callback() def start_schedule_watcher(callback): observer = Observer() observer.daemon = True handler = ScheduleFileHandler(callback) # Determine config directory and ensure it exists config_path = os.environ.get("CONFIG_DIR", "./config") if not os.path.isdir(config_path): os.makedirs(config_path, exist_ok=True) observer.schedule(handler, path=config_path, recursive=False) observer.start() return observer class ModuleManager: def __init__(self, logger): self.running_modules = {} self.module_start_times = {} self.logger = logger self.last_run_times = {} def run(self, module_name, run_module): process = run_module(module_name, self.logger) if process: self.running_modules[module_name] = process self.module_start_times[module_name] = time.time() def run_if_due(self, module_name, schedule_time, check_schedule_func, run_module): if check_schedule_func(module_name, schedule_time, self.logger): import time now = time.time() last_run = self.last_run_times.get(module_name, 0) # Prevent multiple runs within the same schedule window (60 seconds) if now - last_run >= 60: self.logger.info( f"[SCHEDULE] Running module: {module_name} at {schedule_time}" ) self.last_run_times[module_name] = now self.run(module_name, run_module) def is_already_running(self, module_name): return module_name in self.running_modules def cleanup(self): processes_to_remove = [] for module_name, process in self.running_modules.items(): if process and not process.is_alive(): duration = time.time() - self.module_start_times.pop(module_name) self.logger.info( f"[SCHEDULE] module: {module_name.upper()} has finished in {duration:.2f} seconds" ) processes_to_remove.append(module_name) for module_name in processes_to_remove: del self.running_modules[module_name] def has_running_modules(self): return bool(self.running_modules) def load_schedule(): # Do not merge defaults here; only read existing config config = Config("main") schedule = config.scheduler return schedule def run_module(module_to_run, output=False, logger=None): config = Config(module_to_run).module_config def run_python_module(module_to_run): config.instances_config = Config(module_to_run).instances_config module = importlib.import_module(f"modules.{module_to_run}") process = multiprocessing.Process(target=module.main, args=(config,)) process.start() return process if module_to_run in list_of_python_modules: return run_python_module(module_to_run) def print_schedule(logger, modules_schedules): logger.info(create_bar("SCHEDULE")) table = PrettyTable(["module", "Schedule"]) table.align = "l" table.padding_width = 1 for module_name, schedule_time in modules_schedules.items(): table.add_row([module_name, schedule_time]) logger.info(f"{table}") logger.info(create_bar("SCHEDULE")) def main(): # CLI argument parsing parser = argparse.ArgumentParser(description="Run DAPS modules or start web UI.") parser.add_argument( "modules", nargs="*", help="Module names to run once (cli mode)." ) parser.add_argument( "--version", action="version", version=f"%(prog)s {get_version()}", help="Show the DAPS version and exit.", ) args = parser.parse_args() # Set console logging: modules only when explicitly requested via CLI, if args.modules: os.environ["LOG_TO_CONSOLE"] = "true" else: os.environ["LOG_TO_CONSOLE"] = "false" if args.modules: # CLI mode: run specified modules and exit for name in args.modules: if name in list_of_python_modules: run_module(name, output=True) else: print(f"Error: module '{name}' not found.") sys.exit(1) return try: main_config = Config("main").module_config except Exception as e: print(f"Error loading main config for logger: {e}") sys.exit(1) logger = Logger(main_config.log_level, "main") main_logger = logger._logger if hasattr(logger, "_logger") else logger error_notify_handler = ErrorNotifyHandler( main_config, module_name="main", logger=main_logger ) main_logger.addHandler(error_notify_handler) manage_config(logger) # Web mode: no modules passed initial_run = True waiting_message_shown = False # Load main config once and reuse for scheduling reloads main_cfg = Config("main") def on_schedule_change(): try: main_cfg.load_config() new_schedule = main_cfg.scheduler except Exception as e: logger.error(f"[MAIN] Error reloading config: {e}", exc_info=True) return nonlocal current_schedule if new_schedule != current_schedule: current_schedule = new_schedule schedule_changed.set() try: current_schedule = main_cfg.scheduler except Exception as e: logger.error(f"Error loading schedule: {e}", exc_info=True) sys.exit(1) if not isinstance(current_schedule, dict): print(f"❌ Schedule is not a dictionary: {current_schedule}") sys.exit(1) import atexit import threading schedule_changed = threading.Event() observer = start_schedule_watcher(on_schedule_change) atexit.register(observer.stop) # Give the observer up to 2 seconds to finish atexit.register(lambda: observer.join(timeout=2)) try: from web.server import start_web_server if main_config.update_notifications: start_version_check(main_config, logger, interval=3600) start_web_server(logger) manager = ModuleManager(logger) # Expose the ModuleManager to the web server for status/cancel of scheduled tasks import web.server web.server.app.state.manager = manager while True: if initial_run or schedule_changed.is_set(): print_schedule(logger, current_schedule) logger.debug( f"📋 Current schedule contents:\n{json.dumps(current_schedule, indent=4)}" ) schedule_changed.clear() initial_run = False waiting_message_shown = False if not waiting_message_shown: logger.info("[SCHEDULE] Waiting for scheduled modules...") waiting_message_shown = True for module_name, schedule_time in current_schedule.items(): if manager.is_already_running(module_name) or not schedule_time: continue if module_name in list_of_python_modules: manager.run_if_due( module_name, schedule_time, check_schedule, run_module ) manager.cleanup() time.sleep(5) except KeyboardInterrupt: logger.info("Keyboard Interrupt detected. Shutting DAPS down...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) if __name__ == "__main__": main() ================================================ FILE: modules/__init__.py ================================================ ================================================ FILE: modules/border_replacerr.py ================================================ import filecmp import logging import os import shutil import sys from datetime import datetime from types import SimpleNamespace from typing import Any, Dict, List, Optional, Tuple, Union from util.assets import get_assets_files from util.logger import Logger from util.utility import create_table, get_log_dir, print_json, print_settings, progress try: from PIL import Image, UnidentifiedImageError except ImportError as e: print(f"ImportError: {e}") print("Please install the required modules with 'pip install -r requirements.txt'") exit(1) logging.getLogger("PIL").setLevel(logging.WARNING) def load_last_run(log_dir: str, logger: Logger = None) -> Optional[datetime]: """ Load the last run timestamp from the .last_run file in the log directory. """ try: last_run_file = os.path.join(log_dir, ".last_run") if os.path.exists(last_run_file): with open(last_run_file, "r") as f: ts = f.read().strip() try: return datetime.fromisoformat(ts) except Exception: pass except Exception as e: logger.error(f"Failed to read file: {e}") return None def save_last_run(log_dir: str, dt: Optional[datetime] = None, logger: Logger = None) -> None: """ Save the current timestamp (or provided datetime) to the .last_run file in the log directory. """ try: last_run_file = os.path.join(log_dir, ".last_run") now = dt or datetime.now() with open(last_run_file, "w") as f: f.write(now.isoformat()) except Exception as e: logger.error(f"Failed to write file: {e}") def check_holiday( config: SimpleNamespace, logger: Logger, last_run: Optional[datetime] = None ) -> Tuple[bool, Optional[List[str]], Dict[str, bool]]: """ Determines if today falls within a holiday schedule and returns applicable border colors and switch flags. Now supports modules run less than daily by checking if a holiday started or ended since the last run. Args: config (SimpleNamespace): Configuration object containing holidays. logger (Logger): Logger instance for logging messages. last_run (Optional[datetime]): Timestamp of last module run. Returns: Tuple[bool, Optional[List[str]], Dict[str, bool]]: - True if today is a holiday, else False. - List of border colors if a holiday is active, else None. - Dictionary indicating if a holiday started or ended since last run. """ holiday_switch: Dict[str, bool] = { "start_since_last_run": False, "end_since_last_run": False, } now = datetime.now() for holiday, schedule_color in config.holidays.items(): schedule = schedule_color.get("schedule") if not schedule or not schedule.startswith("range("): continue inside = schedule[len("range(") : -1] start_str, end_str = inside.split("-", 1) sm, sd = map(int, start_str.split("/")) em, ed = map(int, end_str.split("/")) year = now.year # Handle ranges that cross the year boundary start_date = datetime(year, sm, sd) end_date = datetime(year, em, ed) if end_date < start_date: # Range crosses new year if now.month < sm: start_date = start_date.replace(year=year - 1) else: end_date = end_date.replace(year=year + 1) # Check if holiday started or ended since last run if last_run: if start_date > last_run and start_date <= now: holiday_switch["start_since_last_run"] = True if end_date > last_run and end_date <= now: holiday_switch["end_since_last_run"] = True else: # On first run, treat as start if within holiday if start_date <= now <= end_date: holiday_switch["start_since_last_run"] = True # Is today inside the holiday range? if start_date <= now <= end_date: holiday_colors = schedule_color.get("color", config.border_colors) if isinstance(holiday_colors, str): holiday_colors = [holiday_colors] logger.info(create_table([[f"Running {holiday.capitalize()} Schedule"]])) logger.info( f"Schedule: {holiday.capitalize()} | Using {', '.join(holiday_colors)} border colors." ) return True, holiday_colors, holiday_switch return False, None, holiday_switch def convert_to_rgb(hex_color: str, logger: Logger) -> Tuple[int, int, int]: """ Converts a hexadecimal color string to an RGB tuple. Args: hex_color (str): Hexadecimal color string. logger (Logger): Logger instance for logging errors. Returns: Tuple[int, int, int]: RGB color tuple. """ hex_color = hex_color.strip("#") if len(hex_color) == 3: hex_color = hex_color * 2 try: color_code = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) except ValueError: logger.error( f"Error: {hex_color} is not a valid hexadecimal color code.\nDefaulting to white." ) return (255, 255, 255) return color_code def fix_borders( assets_dict: Union[List[Dict[str, Any]], Dict[str, List[Dict[str, Any]]]], config: SimpleNamespace, border_colors: Optional[List[str]], destination_dir: str, dry_run: bool, logger: Logger, exclusion_list: Optional[List[str]], ) -> List[str]: """ Processes image assets and applies or removes borders based on configuration. Args: assets_dict (Dict[str, List[Dict[str, Any]]]): Dictionary of assets categorized by type. config (SimpleNamespace): Module configuration. border_colors (Optional[List[str]]): List of border colors to use. destination_dir (str): Target output directory. dry_run (bool): If True, simulate changes without saving. logger (Logger): Logger instance for logging messages. exclusion_list (Optional[List[str]]): List of items to exclude from processing. Returns: List[str]: Status messages for each processed asset. """ # Support flat list or grouped dict by type if isinstance(assets_dict, list): grouped: Dict[str, List[Dict[str, Any]]] = {} for asset in assets_dict: asset_type = asset.get("type") grouped.setdefault(asset_type, []).append(asset) assets_dict = grouped rgb_border_colors: List[Tuple[int, int, int]] = [] if border_colors: for color in border_colors: rgb_color = convert_to_rgb(color, logger) rgb_border_colors.append(rgb_color) if not border_colors: action = "Removed border" banner = "Removing Borders" else: action = "Replaced border" banner = "Replacing Borders" if action: table = [ [f"{banner}"], ] logger.info(create_table(table)) messages: List[str] = [] for key, items in assets_dict.items(): current_index = 0 if not items: logger.info(f"No {key} found in the input directory") continue with progress( items, desc=f"Processing {key.capitalize()}", total=len(items), unit=" items", logger=logger, leave=True, ) as pbar: for data in pbar: files = data.get("files", None) year = data.get("year", None) folder = data.get("folder", None) if year: year_str = f"({year})" else: year_str = "" excluded = False if exclusion_list and f"{data['title']} {year_str}" in exclusion_list: excluded = True logger.debug(f"Excluding {data['title']} {year_str}") # Prepare output directory for saving processed files output_path = destination_dir for input_file in files: file_name, extension = os.path.splitext(input_file) if extension not in [ ".jpg", ".png", ".jpeg", ".JPG", ".PNG", ".JPEG", ]: logger.warning( f"Skipping {input_file} as it is not a jpg or png file." ) continue file_name = os.path.basename(input_file) if rgb_border_colors: rgb_border_color = rgb_border_colors[current_index] else: rgb_border_color = None if not dry_run: if rgb_border_color: results = replace_borders( input_file, output_path, rgb_border_color, config.border_width, folder, logger, ) else: results = remove_borders( input_file, output_path, config.border_width, logger, excluded, folder, ) if results: messages.append(f"{action} on {file_name}") else: messages.append(f"Would have {action} on {file_name}") if rgb_border_colors: current_index = (current_index + 1) % len(rgb_border_colors) pbar.update(1) return messages def replace_borders( input_file: str, output_path: str, border_colors: Tuple[int, int, int], border_width: int, folder: Optional[str], logger: Logger, ) -> bool: """ Removes the existing border and applies a new one with the specified color. Args: input_file (str): Path to the input image file. output_path (str): Path to save the processed image. border_colors (Tuple[int, int, int]): RGB color for the new border. border_width (int): Width of the border to apply. folder (Optional[str]): Subfolder to organize output files. logger (Logger): Logger instance for logging messages. Returns: bool: True if the file was saved or updated; False otherwise. """ try: with Image.open(input_file) as image: width, height = image.size # Remove border by cropping cropped_image = image.crop( ( border_width, border_width, width - border_width, height - border_width, ) ) # Add border by expanding the canvas new_width = cropped_image.width + 2 * border_width new_height = cropped_image.height + 2 * border_width final_image = Image.new("RGB", (new_width, new_height), border_colors) final_image.paste(cropped_image, (border_width, border_width)) file_name = os.path.basename(input_file) if folder: final_path = f"{output_path}/{folder}/{file_name}" else: final_path = f"{output_path}/{file_name}" final_image = final_image.resize((1000, 1500)).convert("RGB") if os.path.isfile(final_path): # Only save if the file is different to avoid unnecessary overwrites tmp_path = f"/tmp/{file_name}" final_image.save(tmp_path) if not filecmp.cmp(final_path, tmp_path): final_image.save(final_path) os.remove(tmp_path) return True else: os.remove(tmp_path) return False else: if not os.path.exists(os.path.dirname(final_path)): os.makedirs(os.path.dirname(final_path), exist_ok=True) final_image.save(final_path) return True except UnidentifiedImageError as e: logger.error(f"Error: {e}") logger.error(f"Error processing {input_file}") return False except Exception as e: logger.error(f"Error: {e}") logger.error(f"Error processing {input_file}") return False def remove_borders( input_file: str, output_path: str, border_width: int, logger: Logger, exclude: bool, folder: Optional[str], ) -> bool: """ Crops an image to remove its borders and optionally adds a black bottom border. Args: input_file (str): Path to the input image file. output_path (str): Path to save the processed image. border_width (int): Width of the border to remove. logger (Logger): Logger instance for logging messages. exclude (bool): If True, remove all borders; if False, add black bottom border. folder (Optional[str]): Subfolder to organize output files. Returns: bool: True if the file was saved or updated; False otherwise. """ try: with Image.open(input_file) as image: width, height = image.size if not exclude: # Remove top, left, right borders, add black bottom border final_image = image.crop( (border_width, border_width, width - border_width, height) ) bottom_border = Image.new( "RGB", (width - 2 * border_width, border_width), color="black" ) bottom_border_position = (0, height - border_width - border_width) final_image.paste(bottom_border, bottom_border_position) else: # Remove all borders final_image = image.crop( ( border_width, border_width, width - border_width, height - border_width, ) ) final_image = final_image.resize((1000, 1500)).convert("RGB") file_name = os.path.basename(input_file) if folder: final_path = f"{output_path}/{folder}/{file_name}" else: final_path = f"{output_path}/{file_name}" if os.path.isfile(final_path): tmp_path = f"/tmp/{file_name}" final_image.save(tmp_path) if not filecmp.cmp(final_path, tmp_path): final_image.save(final_path) os.remove(tmp_path) return True else: os.remove(tmp_path) return False else: if not os.path.exists(os.path.dirname(final_path)): os.makedirs(os.path.dirname(final_path), exist_ok=True) final_image.save(final_path) return True except UnidentifiedImageError as e: logger.error(f"Error: {e}") logger.error(f"Error processing {input_file}") return False except Exception as e: logger.error(f"Error: {e}") logger.error(f"Error processing {input_file}") return False def copy_files( assets_dict: Dict[str, List[Dict[str, Any]]], destination_dir: str, dry_run: bool, logger: Logger, ) -> List[str]: """ Copies asset files from the input to the output directory with change detection. Args: assets_dict (Dict[str, List[Dict[str, Any]]]): Dictionary of asset data. destination_dir (str): Path to the output directory. dry_run (bool): Whether to simulate copying without actual file write. logger (Logger): Logger instance for logging. Returns: List[str]: A list of copy operations performed or simulated. """ messages: List[str] = [] if destination_dir.endswith("/"): destination_dir = destination_dir.rstrip("/") asset_types = ["movies", "series", "collections"] for asset_type in asset_types: if asset_type in assets_dict: items = assets_dict[asset_type] with progress( items, desc=f"Processing {asset_type.capitalize()}", total=len(items), unit=" items", logger=logger, leave=True, ) as pbar: for data in pbar: files = data.get("files", None) year = data.get("year", None) if year: year_str = f"({year})" else: year_str = "" output_path = destination_dir for input_file in files: file_name, extension = os.path.splitext(input_file) if extension not in [ ".jpg", ".png", ".jpeg", ".JPG", ".PNG", ".JPEG", ]: logger.warning( f"Skipping {input_file} as it is not a jpg or png file." ) continue file_name = os.path.basename(input_file) final_path = f"{output_path}/{file_name}" output_basename = os.path.basename(output_path) if not dry_run: if os.path.isfile(final_path): if not filecmp.cmp(final_path, input_file): try: shutil.copy(input_file, final_path) except shutil.SameFileError: logger.debug( f"Input file {input_file} is the same as {final_path}, skipping" ) logger.debug( f"Input file {input_file} is different from {final_path}, copying to {output_basename}" ) messages.append( f"Copied {data['title']}{year_str} - {file_name} to {output_basename}" ) else: try: shutil.copy(input_file, final_path) except shutil.SameFileError: logger.debug( f"Input file {input_file} is the same as {final_path}, skipping" ) logger.debug( f"Input file {input_file} does not exist in {output_path}, copying to {output_basename}" ) messages.append( f"Copied {data['title']}{year_str} - {file_name} to {output_basename}" ) else: messages.append( f"Would have copied {data['title']}{year_str} - {file_name} to {output_basename}" ) pbar.update(1) return messages def process_files( source_dirs: str, config: SimpleNamespace, logger: Optional[Logger] = None, renamerr_config: Optional[SimpleNamespace] = None, renamed_assets: Optional[Dict[str, Any]] = None, incremental_run: Optional[bool] = False, ) -> None: """Main processor for applying or removing borders to media assets.""" logger = Logger(config.log_level, config.module_name) def no_assets_exit(): logger.info("\nNo assets found in the input directory") logger.info("Please check the input directory and try again.") logger.info("Exiting...") return log_dir = get_log_dir(config.module_name) last_run = load_last_run(log_dir, logger=logger) if config.log_level.lower() == "debug": print_settings(logger, config) # Set key variables from config or renamerr_config merged = renamerr_config if renamerr_config else config dry_run = getattr(merged, "dry_run", False) destination_dir = getattr(merged, "destination_dir", None) if not os.path.exists(destination_dir): logger.error(f"Output directory {destination_dir} does not exist.") return # Get border colors and schedule border_colors = None run_holiday = False switch = {"start_since_last_run": False, "end_since_last_run": False} if getattr(config, "holidays", None): run_holiday, border_colors, switch = check_holiday( config, logger, last_run=last_run ) if not border_colors: border_colors = getattr(config, "border_colors", None) # Get and group assets def group_assets(assets): if isinstance(assets, list): grouped = {} for asset in assets: asset_type = asset.get("type") grouped.setdefault(asset_type, []).append(asset) return grouped return assets if ( renamed_assets is None or switch["start_since_last_run"] or switch["end_since_last_run"] ): assets_dict, _ = get_assets_files(source_dirs, logger) assets_dict = group_assets(assets_dict) elif renamed_assets and incremental_run: assets_dict = group_assets(renamed_assets) else: assets_dict = None if not assets_dict or not any(assets_dict.values()): return no_assets_exit() # Just copy files if not scheduled to run if not run_holiday and getattr(config, "skip", False): messages = copy_files(assets_dict, destination_dir, dry_run, logger) logger.info( f"Skipping {config.module_name} as it is not scheduled to run today." ) if messages: logger.info(create_table([["Processed Files", f"{len(messages)}"]])) for message in messages: logger.info(message) return if destination_dir.endswith("/"): destination_dir = destination_dir[:-1] # Border logic if not border_colors: border_args = None action_label = "removed border" logger.info("No border colors set, removing borders from assets.") logger.info("Executing border removal mode (removing all borders from assets).") else: border_args = border_colors action_label = "replaced border" logger.info(f"Using {', '.join(border_colors)} border color(s).") logger.info( "Executing border replacement mode (replacing borders with configured colors)." ) messages = fix_borders( assets_dict, config, border_args, destination_dir, dry_run, logger, getattr(config, "exclusion_list", None), ) if messages: logger.info(create_table([["Processed Files", f"{len(messages)}"]])) logger.info(print_output(assets_dict, action_label, dry_run)) else: logger.info("\nNo files processed") if config.log_level == "debug": print_json(assets_dict, logger, config.module_name, "assets_dict") print_json(messages, logger, config.module_name, "messages") save_last_run(log_dir, logger=logger) # --- Formatting function for border actions output --- def print_output( assets_dict: Dict[str, List[Dict[str, Any]]], action: str, dry_run: bool ) -> str: """ Groups output by asset title and lists all processed files per asset. Args: assets_dict: Dictionary grouped by type, each with a list of asset dicts. action: 'Removed border' or 'Replaced border'. dry_run: If True, prefix with 'Would have'. Returns: str: Formatted output for logging or CLI. """ output_lines = [] for asset_type, assets in assets_dict.items(): for asset in assets: title = asset.get("title") year = asset.get("year") if year: display = f"{title} ({year})" else: display = f"{title}" prefix = f"Would have {action} on" if dry_run else f"{action} on" output_lines.append(f"{prefix} '{display}'") files = asset.get("files", []) for file_path in files: file_name = os.path.basename(file_path) output_lines.append(f" {file_name}") return "\n".join(output_lines) def main(config: SimpleNamespace) -> None: """ Entry point for running the border replacer module. Args: config (SimpleNamespace): Main configuration object. """ logger = Logger(config.log_level, config.module_name) try: process_files( config.source_dirs, config, logger=logger, renamed_assets=None, renamerr_config=None, ) except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/health_checkarr.py ================================================ import json import re import sys from types import SimpleNamespace from typing import Any, Dict, List, Optional from tqdm import tqdm from util.arrpy import create_arr_client from util.constants import tmdb_id_regex, tvdb_id_regex from util.logger import Logger from util.notification import send_notification from util.utility import create_table, print_settings, progress def main(config: SimpleNamespace) -> None: """ Process Radarr and Sonarr instances to identify and delete media items flagged by health checks as removed from TMDB or TVDB. Supports dry run mode and logs all actions. Args: config (SimpleNamespace): Configuration object containing: - log_level (str): Logging verbosity level. - module_name (str): Name of the module for logging context. - dry_run (bool): If True, no deletions are performed. - instances_config (Dict[str, Dict[str, Dict[str, str]]]): Configuration for each instance type and instance. - instances (List[str]): List of instance names to process. Behavior: - Iterates over configured Radarr and Sonarr instances. - Retrieves health check data and media libraries. - Identifies media items flagged as removed. - Deletes flagged media items unless dry run is enabled. - Sends notifications about deleted items. - Logs all key steps and errors. """ logger: Logger = Logger(config.log_level, config.module_name) try: # Display configuration settings if in debug mode if config.log_level.lower() == "debug": print_settings(logger, config) # Print dry run notice if enabled if config.dry_run: table: List[List[str]] = [["Dry Run"], ["NO CHANGES WILL BE MADE"]] logger.info(create_table(table)) logger.info("") # Iterate over each instance type (radarr/sonarr) for instance_type, instance_data in config.instances_config.items(): # Iterate over each configured instance for this type for instance in config.instances: if instance in instance_data: # Create client for the current instance app: Optional[Any] = create_arr_client( instance_data[instance]["url"], instance_data[instance]["api"], logger, ) if app and app.connect_status: # Retrieve health check warnings health: Optional[List[Dict[str, Any]]] = app.get_health() # Retrieve current media library without episode details media_dict: List[Dict[str, Any]] = app.get_parsed_media( include_episode=False ) id_list: List[int] = [] # Parse health check messages for removed media IDs if health: for health_item in health: if ( health_item["source"] == "RemovedMovieCheck" or health_item["source"] == "RemovedSeriesCheck" ): if instance_type == "radarr": for m in re.finditer( tmdb_id_regex, health_item["message"] ): id_list.append(int(m.group(1))) if instance_type == "sonarr": for m in re.finditer( tvdb_id_regex, health_item["message"] ): id_list.append(int(m.group(1))) logger.debug(f"id_list:\n{json.dumps(id_list, indent=4)}") output: List[Dict[str, Any]] = [] # Match health-check IDs with media library entries with progress( media_dict, desc=f"Processing {instance_type}", unit="items", logger=logger, leave=True, ) as pbar: for item in pbar: if ( instance_type == "radarr" and item["tmdb_id"] in id_list ) or ( instance_type == "sonarr" and item["tvdb_id"] in id_list ): db_id = ( item["tmdb_id"] if instance_type == "radarr" else item["tvdb_id"] ) logger.debug( f"Found {item['title']} with: {db_id}" ) output.append(item) logger.debug(f"output:\n{json.dumps(output, indent=4)}") if output: logger.info( f"Deleting {len(output)} {instance_type} items from {app.instance_name}" ) # Delete each matched item unless dry run is enabled for item in tqdm( output, desc=f"Deleting {instance_type} items", unit="items", disable=None, total=len(output), ): if not config.dry_run: logger.info( f"{item['title']} deleted with id: {item['media_id']} and tvdb/tmdb id: {item['db_id']}" ) app.delete_media(item["media_id"]) else: logger.info( f"{item['title']} would have been deleted with id: {item['media_id']}" ) # Send notification with deleted items send_notification( logger=logger, module_name=config.module_name, config=config, output=output, ) else: logger.info( f"No health data returned for {app.instance_name}, this is fine if there was nothing to delete. Skipping deletion checks." ) except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/jduparr.py ================================================ import os import subprocess import sys from types import SimpleNamespace from util.logger import Logger from util.notification import send_notification from util.utility import create_table, print_settings def print_output(output: list[dict], logger: Logger) -> None: """ Print the results of the duplicate file search and linking process. Args: output (list[dict]): List of dictionaries containing path, message, files, and counts. logger (Logger): Logger instance to output messages. """ count = 0 for item in output: path = item.get("source_dir") field_message = item.get("field_message") files = item.get("output") sub_count = item.get("sub_count") logger.info(f"Findings for path: {path}") logger.info(f"\t{field_message}") for i in files: count += 1 logger.info(f"\t\t{i}") count += sub_count logger.info( f"\tTotal items for '{os.path.basename(os.path.normpath(path))}': {sub_count}" ) logger.info(f"Total items relinked: {count}") def main(config: SimpleNamespace) -> None: """ Main execution function for identifying and hardlinking duplicate media files using jdupes. Args: config (SimpleNamespace): Configuration object containing source directories, logging, and other settings. Returns: None """ logger = Logger(config.log_level, config.module_name) results = None try: # If dry run, display a notice table if config.dry_run: table = [["Dry Run"], ["NO CHANGES WILL BE MADE"]] logger.info(create_table(table)) output = [] # Iterate over each source directory to find duplicates if not config.source_dirs: logger.error( f"No source directories provided in config: {config.source_dirs}" ) return for path in config.source_dirs: if config.log_level.lower() == "debug": print_settings(logger, config) if not os.path.isdir(path): logger.error(f"ERROR: path does not exist: {path}") return # Run jdupes to find duplicate media files with specified extensions result = subprocess.getoutput( f"jdupes -r -M -X onlyext:mp4,mkv,avi '{path}' 2>/dev/null" ) # If not dry run and duplicates found, hardlink duplicates if not config.dry_run: if "No duplicates found." not in result: subprocess.run( f"jdupes -r -L -X onlyext:mp4,mkv,avi '{path}' 2>/dev/null", shell=True, ) # Parse filenames from jdupes output parsed_files = sorted( set(line.split("/")[-1] for line in result.splitlines() if "/" in line) ) field_message = ( "✅ No unlinked files discovered..." if not parsed_files else "❌ Unlinked files discovered..." ) sub_count = len(parsed_files) output_data = { "source_dir": path, "field_message": field_message, "output": parsed_files, "sub_count": sub_count, } output.append(output_data) if results: logger.debug(f"jdupes output: {result}") logger.debug(f"Parsed log: {parsed_files}") # Print summarized output and send notification print_output(output, logger) send_notification(logger, config.module_name, config, output) except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("An error occurred:", exc_info=True) finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/labelarr.py ================================================ import sys from collections import defaultdict from types import SimpleNamespace from typing import Dict, List, Optional from util.arrpy import BaseARRClient, create_arr_client from util.logger import Logger from util.normalization import normalize_titles from util.notification import send_notification from util.utility import create_table, print_settings, progress try: from plexapi.exceptions import BadRequest from plexapi.server import PlexServer except ImportError as e: print(f"ImportError: {e}") print("Please install the required modules with 'pip install -r requirements.txt'") exit(1) def sync_to_plex( plex: PlexServer, labels: List[str], media_dict: List[Dict], app: BaseARRClient, logger: Logger, library_names: List[str], config: SimpleNamespace, ) -> List[Dict]: """ Synchronize label metadata between an ARR client and Plex libraries. Args: plex (PlexServer): Plex server instance. labels (List[str]): List of label names to sync. media_dict (List[Dict]): List of media entries from ARR. app (BaseARRClient): ARR client instance (Radarr or Sonarr). logger (Logger): Logger instance. library_names (List[str]): Names of Plex libraries to process. config (SimpleNamespace): Configuration object. Returns: List[Dict]: List of label changes applied or identified. """ tag_ids: Dict[str, Optional[int]] = {} for label in labels: tag_id = app.get_tag_id_from_name(label) if tag_id: tag_ids[label] = tag_id # Create lookups tmdb_imdb_lookup = { (media.get("tmdb_id"), media.get("imdb_id")): media for media in media_dict if media.get("tmdb_id") is not None and media.get("imdb_id") } tvdb_imdb_lookup = { (media.get("tvdb_id"), media.get("imdb_id")): media for media in media_dict if media.get("tvdb_id") is not None and media.get("imdb_id") } tmdb_lookup = { media["tmdb_id"]: media for media in media_dict if media.get("tmdb_id") is not None } tvdb_lookup = { media["tvdb_id"]: media for media in media_dict if media.get("tvdb_id") is not None } imdb_lookup = { media["imdb_id"]: media for media in media_dict if media.get("imdb_id") is not None } fallback_lookup = { (media["normalized_title"], media["year"]): media for media in media_dict if "normalized_title" in media and "year" in media } data_dict: List[Dict] = [] with progress( library_names, desc="Processing Libraries", unit="items", logger=logger, leave=True, ) as outer_pbar: for library in outer_pbar: library_data = plex.library.section(library).all() with progress( library_data, desc=f"Syncing labels between {app.instance_name.capitalize()} and {library}", unit="items", logger=logger, leave=True, ) as inner_pbar: for library_item in inner_pbar: try: plex_item_labels = [ label.tag.lower() for label in library_item.labels ] except AttributeError: logger.error( f"Error fetching labels for {getattr(library_item, 'title', str(library_item))} (no labels)" ) continue # Safely extract IDs ids: Dict[str, Optional[str]] = { "tmdb": None, "tvdb": None, "imdb": None, } for guid in getattr(library_item, "guids", []): guid_str = getattr(guid, "id", "") if guid_str.startswith("tmdb://"): ids["tmdb"] = guid_str.split("tmdb://")[1] elif guid_str.startswith("tvdb://"): ids["tvdb"] = guid_str.split("tvdb://")[1] elif guid_str.startswith("imdb://"): ids["imdb"] = guid_str.split("imdb://")[1] media_item: Optional[Dict] = None match_type: str = "unknown" # 1. Prefer TMDB+IMDB or TVDB+IMDB tmdb_id = ids.get("tmdb") imdb_id = ids.get("imdb") tvdb_id = ids.get("tvdb") # Prefer TMDB+IMDB if tmdb_id and tmdb_id.isdigit() and imdb_id: key = (int(tmdb_id), imdb_id) media_item = tmdb_imdb_lookup.get(key) if media_item: match_type = f"TMDB+IMDB MATCH: TMDB {tmdb_id} & IMDB {imdb_id}" # Next try TVDB+IMDB if not media_item and tvdb_id and tvdb_id.isdigit() and imdb_id: key = (int(tvdb_id), imdb_id) media_item = tvdb_imdb_lookup.get(key) if media_item: match_type = f"TVDB+IMDB MATCH: TVDB {tvdb_id} & IMDB {imdb_id}" # 2. Fallback to just TMDB, TVDB, IMDB if not media_item and tmdb_id and tmdb_id.isdigit(): media_item = tmdb_lookup.get(int(tmdb_id)) if media_item: match_type = f"TMDB MATCH: {tmdb_id}" if not media_item and tvdb_id and tvdb_id.isdigit(): media_item = tvdb_lookup.get(int(tvdb_id)) if media_item: match_type = f"TVDB MATCH: {tvdb_id}" if not media_item and imdb_id: media_item = imdb_lookup.get(imdb_id) if media_item: match_type = f"IMDB MATCH: {imdb_id}" # 3. Final fallback to normalized title and year if not media_item: norm_title = normalize_titles(getattr(library_item, "title", "")) item_year = getattr(library_item, "year", None) if item_year is not None: key = (norm_title, item_year) media_item = fallback_lookup.get(key) if media_item: match_type = "TITLE/YEAR MATCH" else: logger.debug( f"Skipping fallback match for '{getattr(library_item, 'title', str(library_item))}' as no 'year' attribute is present (likely not a movie/show item)" ) # 4. Only proceed if a real match was found if media_item: logger.debug( 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', '-')})" ) add_remove: Dict[str, str] = {} # Decide add/remove per label for tag, id in tag_ids.items(): if tag not in plex_item_labels and id in media_item["tags"]: add_remove[tag] = "add" if not config.dry_run: library_item.addLabel(tag) elif ( tag in plex_item_labels and id not in media_item["tags"] ): add_remove[tag] = "remove" if not config.dry_run: library_item.removeLabel(tag) if add_remove: data_dict.append( { "title": getattr(library_item, "title", str(library_item)), "year": getattr(library_item, "year", None), "add_remove": add_remove, } ) return data_dict def handle_messages(data_dict: List[Dict], logger: Logger) -> None: """ Log label changes from sync results in a grouped, readable format. Args: data_dict (List[Dict]): List of dictionaries containing sync results. logger (Logger): Logger instance for output. """ table: List[List[str]] = [["Results"]] logger.info(create_table(table)) label_changes: Dict[tuple, List[str]] = defaultdict(list) # Group media titles by label and action (add/remove) for item in data_dict: for label, action in item["add_remove"].items(): key = (label, action) label_changes[key].append(f"{item['title']} ({item['year']})") # Log grouped label changes for (label, action), items in label_changes.items(): verb = "added to" if action == "add" else "removed from" logger.info(f"\nLabel: {label} has been {verb}:") for entry in items: logger.info(f" - {entry}") def main(config: SimpleNamespace) -> None: """ Main function to sync labels between Plex and Radarr/Sonarr based on configuration. Args: config (SimpleNamespace): Configuration object loaded from user settings. """ logger = Logger(config.log_level, config.module_name) try: # Print detailed settings if debug logging is enabled if config.log_level.lower() == "debug": print_settings(logger, config) # Notify user if running in dry run mode (no actual changes will be made) if config.dry_run: table = [["Dry Run"], ["NO CHANGES WILL BE MADE"]] logger.info(create_table(table)) output: List[Dict] = [] # Iterate over each mapping configured for syncing for mapping in config.mappings: app_type: str = mapping["app_type"] app_instance: Optional[str] = mapping.get("app_instance") labels: List[str] = mapping["labels"] plex_instances: List[Dict] = mapping["plex_instances"] app: Optional[BaseARRClient] = None media_dict: List[Dict] = [] # Connect to the ARR client (Radarr or Sonarr) if specified if app_type in ["radarr", "sonarr"] and app_instance: app_config: Optional[Dict] = config.instances_config[app_type].get( app_instance ) if not app_config: logger.error( f"No config found for {app_type} instance '{app_instance}'" ) continue app = create_arr_client(app_config["url"], app_config["api"], logger) if not app or not app.connect_status: logger.error( f"Failed to connect to {app_type} instance {app_instance}" ) continue # Fetch parsed media list from ARR client, excluding episodes for Sonarr if ( hasattr(app, "instance_type") and app.instance_type.lower() == "sonarr" ): media_dict = app.get_parsed_media(include_episode=False) else: media_dict = app.get_parsed_media() if not media_dict: logger.info(f"No media found for {app_instance}") continue # Process each Plex instance and library associated with the mapping if plex_instances: for mapping_block in plex_instances: plex_instance: Optional[str] = mapping_block.get("instance") library_names: List[str] = mapping_block.get("library_names", []) if plex_instance not in config.instances_config.get("plex", {}): logger.error( f"No Plex instance found for {plex_instance}. Skipping...\n" ) continue try: plex = PlexServer( config.instances_config["plex"][plex_instance]["url"], config.instances_config["plex"][plex_instance]["api"], timeout=180, ) logger.info(f"Connected to Plex instance '{plex.friendlyName}'") except BadRequest: logger.error( f"Error connecting to Plex instance: {plex_instance}" ) continue if library_names: label_str = ", ".join(labels) logger.info( f"Syncing labels [{label_str}] from {app_type.capitalize()} instance '{app_instance}' to Plex instance '{plex_instance}'" ) # Collect changes from sync_to_plex and accumulate in output list data_dict = sync_to_plex( plex, labels, media_dict, app, logger, library_names, config ) output.extend(data_dict) else: logger.error( f"No library names provided for {plex_instance}. Skipping..." ) continue # Log and send notifications if any label changes were found if output: handle_messages(output, logger) # Only send notifications if not in dry run mode send_notification( logger=logger, module_name=config.module_name, config=config, output=output, ) else: logger.info("No labels to sync to Plex") except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/nohl.py ================================================ import os import re import sys from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from util.arrpy import create_arr_client from util.constants import ( episode_regex, season_regex, year_regex, ) from util.logger import Logger from util.notification import send_notification from util.utility import ( create_table, normalize_titles, print_json, print_settings, progress, ) VIDEO_EXTS = (".mkv", ".mp4") if TYPE_CHECKING: from util.arrpy import BaseARRClient def find_nohl_files( path: str, logger: Logger ) -> Optional[Dict[str, List[Dict[str, Any]]]]: """ Find all video files in a directory tree that are not hardlinked. Args: path: Root directory to scan. logger: Logger instance for debug output. Returns: Dictionary with non-hardlinked movies and series details. """ path_basename = os.path.basename(path.rstrip("/")) nohl_data: Dict[str, List[Dict[str, Any]]] = {"movies": [], "series": []} logger.debug(f"Scanning directory: {path}") try: entries = [i for i in os.listdir(path) if os.path.isdir(os.path.join(path, i))] except FileNotFoundError as e: logger.error(f"Error: {e}") return None for item in progress( entries, desc=f"Searching '{path_basename}'", unit="item", total=len(entries), logger=logger, ): if item.startswith("."): continue # Remove year from directory name for title title = re.sub(year_regex, "", item) try: year = int(year_regex.search(item).group(1)) except AttributeError: year = 0 asset_list: Dict[str, Any] = { "title": title, "year": year, "normalized_title": normalize_titles(title), "root_path": os.path.join(*path.rstrip(os.sep).split(os.sep)[-2:]), "path": os.path.join(path, item), } item_path = os.path.join(path, item) # Detect if this is a series (has subfolders) or a movie (just files) if os.path.isdir(item_path) and any( os.path.isdir(os.path.join(item_path, sub_folder)) for sub_folder in os.listdir(item_path) ): sub_folders = [ sub_folder for sub_folder in os.listdir(item_path) if os.path.isdir(os.path.join(item_path, sub_folder)) and not sub_folder.startswith(".") ] asset_list["season_info"] = [] for sub_folder in sub_folders: sub_folder_path = os.path.join(item_path, sub_folder) sub_folder_files = [ file for file in os.listdir(sub_folder_path) if os.path.isfile(os.path.join(sub_folder_path, file)) and not file.startswith(".") ] season = re.search(season_regex, sub_folder) try: season_number = int(season.group(1)) except AttributeError: season_number = 0 nohl_files = [] # Non-hardlink detection for each file in season folder for file in sub_folder_files: if not file.endswith(VIDEO_EXTS): continue file_path = os.path.join(sub_folder_path, file) try: st = os.stat(file_path) if st.st_nlink == 1: nohl_files.append(file_path) except Exception: continue if nohl_files: logger.debug( f"Found {len(nohl_files)} non-hardlinked files in '{sub_folder_path}'" ) episodes = [] # Extract episode numbers from non-hardlinked files for file in nohl_files: try: episode_match = re.search(episode_regex, file) if episode_match is not None: episode = int(episode_match.group(1)) episodes.append(episode) except Exception as e: logger.error(f"{e}") logger.error(f"Error processing file: {file}.") continue season_list = { "season_number": season_number, "episodes": episodes, "nohl": nohl_files, } if nohl_files: asset_list["season_info"].append(season_list) # Only add if there are any non-hardlinked episodes in any season if asset_list.get("season_info") and any( season["nohl"] for season in asset_list["season_info"] ): nohl_data["series"].append(asset_list) else: files_path = item_path files = [ file for file in os.listdir(files_path) if os.path.isfile(os.path.join(files_path, file)) and not file.startswith(".") ] nohl_files = [] # Non-hardlink detection for movie files for file in files: if not file.endswith(VIDEO_EXTS): continue file_path = os.path.join(files_path, file) try: st = os.stat(file_path) if st.st_nlink == 1: nohl_files.append(file_path) except Exception: continue if nohl_files: logger.debug( f"Found {len(nohl_files)} non-hardlinked files in '{item_path}'" ) asset_list["nohl"] = nohl_files if nohl_files: nohl_data["movies"].append(asset_list) # Sort seasons and episodes numerically for each series for series in nohl_data["series"]: if "season_info" in series: series["season_info"].sort(key=lambda s: int(s["season_number"])) for season in series["season_info"]: if "episodes" in season: season["episodes"].sort(key=int) return nohl_data def handle_searches( app: "BaseARRClient", search_list: List[Dict[str, Any]], instance_type: str, logger: Logger, config, ) -> List[Dict[str, Any]]: """ Perform search and deletion actions for Radarr or Sonarr items. Args: app: ARR API client. search_list: List of media dicts to search. instance_type: "radarr" or "sonarr". logger: Logger instance. config: Config object. Returns: List of items that were searched. """ logger.debug(f"Initiating search for {len(search_list)} items in {instance_type}") print("Searching for files... this may take a while.") searched_for: List[Dict[str, Any]] = [] searches = 0 for item in progress( search_list, desc="Searching...", unit="item", total=len(search_list), logger=logger, ): if instance_type == "radarr": # Radarr: delete file(s) and trigger search for the movie if config.dry_run: logger.info( f"[Dry Run] Would search: {item['title']} ({item['year']}) and delete file IDs: {item['file_ids']}" ) searched_for.append(item) searches += 1 else: app.delete_movie_file(item["file_ids"]) results = app.refresh_items(item["media_id"]) ready = app.wait_for_command(results["id"]) if ready: logger.debug( f"Performing a Search for {item['media_id']} ({item['year']})" ) app.search_media(item["media_id"]) searched_for.append(item) searches += 1 logger.debug(f"Searched: {item['title']} ({item['year']})") elif instance_type == "sonarr": # Sonarr: for each season, trigger episode or season pack search seasons = item.get("seasons", []) if seasons: for season in seasons: season_pack = season["season_pack"] file_ids = list( set( [ episode["episode_file_id"] for episode in season["episode_data"] ] ) ) episode_ids = [ episode["episode_id"] for episode in season["episode_data"] ] if season_pack: if config.dry_run: logger.info( f"[Dry Run] Would search season: {season['season_number']} of {item['title']} ({item['year']})" ) else: app.delete_episode_files(file_ids) results = app.refresh_items(item["media_id"]) ready = app.wait_for_command(results["id"]) if ready: logger.debug( f"Performing a season search for {item['media_id']} ({item['year']}) Season Number: {season['season_number']}" ) app.search_season( item["media_id"], season["season_number"] ) else: if config.dry_run: episode_numbers = [ ep["episode_number"] for ep in season["episode_data"] ] logger.info( f"[Dry Run] Would search episodes: {episode_numbers} of {item['title']} ({item['year']})" ) else: app.delete_episode_files(file_ids) results = app.refresh_items(item["media_id"]) ready = app.wait_for_command(results["id"]) if ready: logger.debug( f"Performing an episode search for {item['title']} ({item['year']}), Episodes IDs: {episode_ids}" ) app.search_episodes(episode_ids) searched_for.append(item) logger.debug(f"Searched: {item['title']} ({item['year']})") print(f"Searches performed: {searches}") return searched_for def filter_media( app: "BaseARRClient", media_dict: List[Dict[str, Any]], nohl_data: List[Dict[str, Any]], instance_type: str, config, logger: Logger, ) -> Dict[str, List[Dict[str, Any]]]: """ Filter media to exclude items based on monitoring, exclusion lists, and quality profiles. Args: app: ARR client for Radarr/Sonarr. media_dict: List of media items from ARR. nohl_data: List of non-hardlinked media items. instance_type: "radarr" or "sonarr". config: Script configuration. logger: Logger instance. Returns: Dict with 'search_media' and 'filtered_media' lists. """ logger.debug( f"Filtering {len(nohl_data)} nohl items against {len(media_dict)} media items from {instance_type}" ) quality_profiles = app.get_quality_profile_names() exclude_profile_ids = [] if config.exclude_profiles: for profile in config.exclude_profiles: if profile in quality_profiles: exclude_profile_ids.append(quality_profiles[profile]) def build_season_filtering( media_season: Dict[str, Any], file_season: Dict[str, Any] ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """ Split a season into filtered (excluded) and search-needed episodes based on monitoring and matches. """ season_data: List[Dict[str, Any]] = [] filtered_seasons: List[Dict[str, Any]] = [] if not media_season["monitored"]: # Unmonitored season, add to filtered filtered_seasons.append( { "season_number": media_season["season_number"], "monitored": False, } ) else: if media_season["season_pack"]: # Monitored season pack, add all to search season_data.append( { "season_number": media_season["season_number"], "season_pack": True, "episode_data": media_season["episode_data"], } ) else: # For non-season-pack, filter out unmonitored and select monitored matching episodes episode_set = set(file_season["episodes"]) filtered_episodes = [] episode_data = [] for episode in media_season["episode_data"]: if not episode["monitored"]: # Unmonitored episode, add to filtered filtered_episodes.append(episode) elif episode["episode_number"] in episode_set: # Monitored and present in file_season, add to search episode_data.append(episode) if filtered_episodes: # Unmonitored chunk filtered_seasons.append( { "season_number": media_season["season_number"], "monitored": True, "episodes": filtered_episodes, } ) if episode_data: # Monitored chunk that needs searching season_data.append( { "season_number": media_season["season_number"], "season_pack": False, "episode_data": episode_data, } ) return season_data, filtered_seasons data_list: Dict[str, List[Dict[str, Any]]] = { "search_media": [], "filtered_media": [], } for nohl_item in progress( nohl_data, desc="Filtering media...", unit="item", total=len(nohl_data), logger=logger, ): for media_item in media_dict: # ARR resolution: match normalized title and year if ( media_item["normalized_title"] == nohl_item["normalized_title"] and media_item["year"] == nohl_item["year"] ): # Only match root_path if not dry_run if ( nohl_item["root_path"] not in media_item["root_folder"] and not config.dry_run ): logger.debug( f"Skipping {media_item['title']} ({media_item['year']}), root folder mismatch." ) continue # Exclusion checks: monitored, exclusion lists, quality profile if ( media_item["monitored"] is False or ( 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 ) or media_item["quality_profile"] in exclude_profile_ids ): data_list["filtered_media"].append( { "title": media_item["title"], "year": media_item["year"], "monitored": media_item["monitored"], "excluded": ( ( 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 ) ), "quality_profile": ( quality_profiles.get(media_item["quality_profile"]) if media_item["quality_profile"] in exclude_profile_ids else None ), } ) logger.debug( f"Filtered out: {media_item['title']} ({media_item['year']}), reason(s): " f"{'not monitored' if media_item['monitored'] is False else ''}" 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 ''}" f"{', quality profile' if media_item['quality_profile'] in exclude_profile_ids else ''}" ) continue if instance_type == "radarr": # Add movie to search list file_ids = media_item["file_id"] data_list["search_media"].append( { "media_id": media_item["media_id"], "title": media_item["title"], "year": media_item["year"], "file_ids": file_ids, } ) logger.debug( f"Radarr: Will resolve {media_item['title']} ({media_item['year']}), file_ids={file_ids}" ) elif instance_type == "sonarr": # Season filtering for Sonarr: build per-season search/exclude lists media_seasons_info = media_item.get("seasons", {}) file_season_info = nohl_item.get("season_info", []) season_data = [] filtered_seasons = [] for media_season in media_seasons_info: for file_season in file_season_info: if ( media_season["season_number"] == file_season["season_number"] ): sdata, sfiltered = build_season_filtering( media_season, file_season ) season_data.extend(sdata) filtered_seasons.extend(sfiltered) if filtered_seasons: data_list["filtered_media"].append( { "title": media_item["title"], "year": media_item["year"], "seasons": filtered_seasons, } ) logger.debug( f"Filtered out: {media_item['title']} ({media_item['year']}), reason(s): " f"{'not monitored' if media_item['monitored'] is False else ''}" 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 ''}" f"{', quality profile' if media_item['quality_profile'] in exclude_profile_ids else ''}" ) if season_data: logger.debug( f"{media_item['title']} ({media_item['year']}): {len(season_data)} seasons selected for search" ) data_list["search_media"].append( { "media_id": media_item["media_id"], "title": media_item["title"], "year": media_item["year"], "monitored": media_item["monitored"], "seasons": season_data, } ) logger.debug( f"Sonarr: Will resolve {media_item['title']} ({media_item['year']}), seasons: " f"{[s['season_number'] for s in season_data]}" ) # Limit number of searches if configured if len(data_list["search_media"]) >= config.searches: data_list["search_media"] = data_list["search_media"][: config.searches] return data_list def handle_messages(output: Dict[str, Any], logger: Logger) -> None: """ Print a formatted summary of scanned non-hardlinked files and resolved ARR actions. Args: output: Output dictionary containing scan and resolve results. logger: Logger instance. """ # Output scanned section: show all non-hardlinked movies and series found logger.info(create_table([["Scanned Non-Hardlinked Files"]])) for path, results in output.get("scanned", {}).items(): logger.info(f"Scanning results for: {path}") for item in results.get("movies", []): logger.info(f"{item['title']} ({item['year']})") if item.get("nohl"): for file_path in item["nohl"]: logger.info(f"\t{os.path.basename(file_path)}") logger.info("") for item in results.get("series", []): logger.info(f"{item['title']} ({item['year']})") for season in item.get("season_info", []): if season.get("nohl"): logger.info(f"\tSeason {season['season_number']}") for file_path in season["nohl"]: logger.info(f"\t\t{os.path.basename(file_path)}") logger.info("") # Output resolved section: show all ARR actions performed or skipped logger.info(create_table([["Resolved ARR Actions"]])) for instance, instance_data in output.get("resolved", {}).items(): search_media = instance_data["data"]["search_media"] filtered_media = instance_data["data"]["filtered_media"] # Output searched ARR media if search_media: for search_item in search_media: if instance_data["instance_type"] == "radarr": logger.info(f"{search_item['title']} ({search_item['year']})") logger.info("\tDeleted and searched.\n") else: logger.info(f"{search_item['title']} ({search_item['year']})") if search_item.get("seasons", None): for season in search_item["seasons"]: if season["season_pack"]: logger.info( f"\tSeason {season['season_number']}, deleted and searched." ) else: logger.info(f"\tSeason {season['season_number']}") for episode in season["episode_data"]: logger.info( f"\t Episode {episode['episode_number']}, deleted and searched." ) logger.info("") # Output filtered ARR media (excluded or unmonitored) table = [["Filtered Media"]] if filtered_media: logger.debug(create_table(table)) for filtered_item in filtered_media: monitored = filtered_item.get("monitored", None) logger.debug(f"{filtered_item['title']} ({filtered_item['year']})") if monitored is False: logger.debug("\tSkipping, not monitored.") elif filtered_item.get("exclude_media", None): logger.debug("\tSkipping, excluded.") elif filtered_item.get("quality_profile", None): logger.debug( f"\tSkipping, quality profile: {filtered_item['quality_profile']}" ) elif filtered_item.get("seasons", None): for season in filtered_item["seasons"]: if season["monitored"] is False: logger.debug( f"\tSeason {season['season_number']}, skipping, not monitored." ) elif season.get("episodes", None): logger.debug(f"\tSeason {season['season_number']}") for episode in season["episodes"]: logger.debug( f"\t Episode {episode['episode_number']}, skipping, not monitored." ) logger.debug("") else: logger.debug(f"No filtered files for {instance_data['server_name']}") logger.debug("") # Output summary table summary = output.get("summary", {}) if not all(value == 0 for value in summary.values()): logger.info( create_table( [ ["Metric", "Count"], ["Total Scanned Movies", summary.get("total_scanned_movies", 0)], ["Total Scanned Episodes", summary.get("total_scanned_series", 0)], ["Total Resolved Movies", summary.get("total_resolved_movies", 0)], [ "Total Resolved Episodes", summary.get("total_resolved_series", 0), ], ] ) ) else: logger.info("\n\n\t\t✅ Congratulations, there is nothing to report.\n\n") def main(config) -> None: """ Entrypoint for nohl.py. Scans for non-hardlinked files and triggers ARR actions. Args: config: Parsed configuration namespace. """ logger = Logger(config.log_level, config.module_name) try: if config.log_level.lower() == "debug": print_settings(logger, config) # Warn if running in dry run mode if config.dry_run: table = [["Dry Run"], ["NO CHANGES WILL BE MADE"]] logger.info(create_table(table)) logger.debug("Logger initialized. Starting main process.") # Ensure ARR instances are configured if config.instances is None: logger.error("No instances set in config file.") return # Parse source_dirs into entries with path+mode source_entries = [] if getattr(config, "source_dirs", None): for entry in config.source_dirs: if isinstance(entry, dict): if "mode" not in entry: logger.warning(f"No 'mode' set for source_dir path '{entry.get('path')}', defaulting to 'scan'.") source_entries.append( { "path": entry.get("path"), "mode": entry.get("mode", "scan"), } ) else: source_entries.append({"path": entry, "mode": "resolve"}) # Separate scan vs resolve entries scan_entries = [e for e in source_entries if e["mode"] == "scan"] resolve_entries = [e for e in source_entries if e["mode"] == "resolve"] # Scan-only: gather all non-hardlinked files for reporting scanned_results: Dict[str, Any] = {} for entry in scan_entries: path = entry["path"] results = find_nohl_files(path, logger) scanned_results[path] = results or {"movies": [], "series": []} # Resolve-only: aggregate all nohl results for ARR resolution nohl_list: Dict[str, List[Dict[str, Any]]] = {"movies": [], "series": []} for entry in resolve_entries: path = entry["path"] results = find_nohl_files(path, logger) or {"movies": [], "series": []} if results and (results.get("movies") or results.get("series")): nohl_list["movies"].extend(results.get("movies", [])) nohl_list["series"].extend(results.get("series", [])) else: logger.warning( f"No non-hardlinked files found in {path}, skipping resolution for this path" ) continue # Compute summary statistics for output reporting total_movies = sum( len(movie.get("nohl", [])) for results in scanned_results.values() for movie in results.get("movies", []) ) total_series = sum( sum(len(season.get("nohl", [])) for season in series.get("season_info", [])) for results in scanned_results.values() for series in results.get("series", []) ) total_nohl_movies = sum( len(movie.get("nohl", [])) for movie in nohl_list["movies"] ) total_nohl_series = sum( sum(len(season.get("nohl", [])) for season in series.get("season_info", [])) for series in nohl_list["series"] ) total_scanned_movies = sum( len(movie.get("nohl", [])) for path, results in scanned_results.items() for movie in results.get("movies", []) ) total_scanned_series = sum( sum(len(season.get("nohl", [])) for season in series.get("season_info", [])) for path, results in scanned_results.items() for series in results.get("series", []) ) logger.debug(f"Total scanned movie files: {total_movies}") logger.debug(f"Total scanned series files: {total_series}") logger.debug(f"Total non-hardlinked movie files: {total_nohl_movies}") logger.debug(f"Total non-hardlinked series files: {total_nohl_series}") logger.debug(f"Total scanned results - movies: {total_scanned_movies}") logger.debug(f"Total scanned results - series: {total_scanned_series}") output_dict: Dict[str, Any] = {} data_list: Dict[str, Any] = {} media_dict: Any = {} nohl_data: Any = {} # ARR resolution: for each instance, filter and trigger searches for instance_type, instance_data in config.instances_config.items(): for instance in config.instances: if isinstance(instance, dict): instance_name = next(iter(instance.keys())) else: instance_name = instance if instance_name in instance_data: data_list = {"search_media": [], "filtered_media": []} instance_settings = instance_data.get(instance, None) app = create_arr_client( instance_settings["url"], instance_settings["api"], logger ) if app and app.connect_status: server_name = app.get_instance_name() table = [[f"{server_name}"]] logger.info(create_table(table)) if (instance_type == "radarr" and not nohl_list["movies"]) or ( instance_type == "sonarr" and not nohl_list["series"] ): logger.info( f"No non-hardlinked files found for server: {server_name}" ) nohl_data = ( nohl_list["movies"] if instance_type == "radarr" else ( nohl_list["series"] if instance_type == "sonarr" else None ) ) if nohl_data: # Pull all media from ARR and filter for resolution if instance_type == "sonarr": media_dict = app.get_parsed_media(include_episode=True) else: media_dict = app.get_parsed_media() if media_dict: data_list = filter_media( app, media_dict, nohl_data, instance_type, config, logger, ) else: logger.info(f"No media found for server: {server_name}") search_list = data_list.get("search_media", []) if search_list: # Conduct searches, with dry run support search_list = handle_searches( app, search_list, instance_type, logger, config ) data_list["search_media"] = search_list output_dict[instance] = { "server_name": server_name, "instance_type": instance_type, "data": data_list, } logger.debug( f"{server_name} processing complete. Search media: {len(data_list['search_media'])}, Filtered: {len(data_list['filtered_media'])}" ) # Dump debug JSON payloads if needed if config.log_level == "debug": print_json(data_list, logger, config.module_name, "data_list") print_json(media_dict, logger, config.module_name, "media_dict") print_json(nohl_data, logger, config.module_name, "nohl_data") print_json(output_dict, logger, config.module_name, "output_dict") # Prepare summary for output reporting (only count actual resolved items in search_media) resolved_movies = 0 resolved_episodes = 0 for instance, instance_data in output_dict.items(): search_media = instance_data["data"].get("search_media", []) if instance_data["instance_type"] == "radarr": resolved_movies += len(search_media) elif instance_data["instance_type"] == "sonarr": for search_item in search_media: # Only count episodes in search_media (i.e., actually resolved) if "seasons" in search_item: for season in search_item["seasons"]: resolved_episodes += len(season.get("episode_data", [])) summary = { "total_scanned_movies": total_scanned_movies, "total_scanned_series": total_scanned_series, "total_resolved_movies": resolved_movies, "total_resolved_series": resolved_episodes, } # Combine scan and resolve results for reporting and notification final_output = { "scanned": scanned_results, "resolved": output_dict, "summary": summary, } # Output results to console/log handle_messages(final_output, logger) # Send notification with scan+resolve results send_notification( logger=logger, module_name=config.module_name, config=config, output=final_output, ) except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/poster_cleanarr.py ================================================ import os import shutil import sys from types import SimpleNamespace from typing import Dict, List, Optional, Union from util.arrpy import create_arr_client from util.assets import get_assets_files from util.index import create_new_empty_index from util.logger import Logger from util.match import match_assets_to_media from util.utility import ( create_table, get_plex_data, print_json, print_settings, ) try: from plexapi.server import PlexServer except ImportError as e: print(f"ImportError: {e}") print("Please install the required modules with 'pip install -r requirements.txt'") exit(1) def remove_assets( unmatched_dict: List[Dict[str, Union[str, int, List[str], None]]], config: SimpleNamespace, logger: Logger, ) -> List[Dict[str, Union[str, int, List[str]]]]: """ Remove unmatched assets from disk or simulate removal. Args: unmatched_dict: List of unmatched asset dictionaries. config: Configuration namespace. logger: Logger instance. Returns: List of dictionaries summarizing removed assets and messages. """ remove_data: List[Dict[str, Union[str, int, List[str]]]] = [] remove_list: List[str] = [] # If input is a dict by type, flatten to a list if isinstance(unmatched_dict, dict): all_unmatched = [] for v in unmatched_dict.values(): all_unmatched.extend(v) unmatched_list = all_unmatched else: unmatched_list = unmatched_dict for asset_data in unmatched_list: messages: List[str] = [] if not asset_data["files"] and asset_data.get("path"): # Remove empty folder asset remove_list.append(asset_data["path"]) messages.append( f"Removing empty folder: {os.path.basename(asset_data['path'])}" ) else: # Remove individual files for asset for file in asset_data["files"]: remove_list.append(file) # Compose tmp path asset_dir = os.path.dirname(file) basename = os.path.basename(file) tmp_path = os.path.join(asset_dir, "tmp", basename) if os.path.isfile(tmp_path): remove_list.append(tmp_path) if config.log_level.lower() == "debug": messages.append(f"Removing duplicate in tmp: {tmp_path}") messages.append(f"Removing file: {basename}") remove_data.append( { "title": asset_data["title"], "year": asset_data["year"], "messages": messages, } ) if not config.dry_run: for path in remove_list: try: if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) # Remove parent folder if empty after file removal folder_path = os.path.dirname(path) if not os.listdir(folder_path): os.rmdir(folder_path) except OSError as e: logger.error(f"Error: {e}") logger.error(f"Failed to remove: {path}") continue # Clean up any remaining empty folders in source directories for assets_path in config.source_dirs: for root, dirs, files in os.walk(assets_path, topdown=False): for dir_name in dirs: dir_path = os.path.join(root, dir_name) if not os.listdir(dir_path): try: logger.info(f"Removing empty folder: {dir_name}") os.rmdir(dir_path) except OSError as e: logger.error(f"Error: {e}") logger.error(f"Failed to remove: {dir_path}") continue return remove_data def print_output( remove_data: List[Dict[str, Union[str, int, List[str]]]], logger: Logger ) -> None: """ Print summary of removed assets and messages. Args: remove_data: List of dictionaries with removal information. logger: Logger instance. """ # Add a banner/table at the very top table = [["Assets Removed Summary"]] logger.info(create_table(table)) count: int = 0 for data in remove_data: title: str = data["title"] year: Optional[int] = data.get("year") if year: logger.info(f"• {title} ({year})") else: logger.info(f"• {title}") asset_messages: List[str] = data["messages"] for message in asset_messages: logger.info(f" - {message}") count += 1 logger.info(f"\nTotal number of assets removed: {count}") def main(config: SimpleNamespace) -> None: """ Main function to load media, match assets, and remove unmatched assets. Args: config: Configuration namespace. """ logger = Logger(config.log_level, config.module_name) remove_data = [] try: if config.log_level.lower() == "debug": print_settings(logger, config) if config.dry_run: table = [["Dry Run"], ["NO CHANGES WILL BE MADE"]] logger.info(create_table(table)) # Load assets from source directories prefix_index = create_new_empty_index() assets_dict, prefix_index = get_assets_files(config.source_dirs, logger) if not assets_dict: logger.error( f"No assets found in the source directories: {config.source_dirs}" ) return media_dict = {"movies": [], "series": [], "collections": []} if not config.instances: logger.error("No instances found. Exiting script.") return for instance in config.instances: if isinstance(instance, dict): instance_name, instance_settings = next(iter(instance.items())) else: instance_name = instance instance_settings = {} found = False instance_type = None instance_data = None for itype, idata in config.instances_config.items(): if instance_name in idata: found = True instance_type = itype instance_data = idata break if not found or instance_type is None or instance_data is None: logger.warning( f"Instance '{instance_name}' not found in config.instances_config. Skipping." ) continue if instance_type == "plex": url = instance_data[instance_name]["url"] api = instance_data[instance_name]["api"] try: app = PlexServer(url, api) except Exception as e: logger.error(f"Error connecting to Plex: {e}") app = None if app: library_names = instance_settings.get("library_names", []) if library_names: logger.info("Fetching Plex collections...") results = get_plex_data( app, library_names, logger, include_smart=True, collections_only=True, ) media_dict["collections"].extend(results) else: logger.warning( f"No library names specified for Plex instance '{instance_name}'. Skipping." ) else: url = instance_data[instance_name]["url"] api = instance_data[instance_name]["api"] app = create_arr_client(url, api, logger) if app and app.connect_status: logger.info(f"Fetching {app.instance_name} data...") results = app.get_parsed_media(include_episode=False) if results: if instance_type == "radarr": media_dict["movies"].extend(results) elif instance_type == "sonarr": media_dict["series"].extend(results) else: logger.warning( f"No {instance_type.capitalize()} data found for instance '{instance_name}'." ) if not any(media_dict.values()): logger.error( "No media found. Check 'instances' setting in your config. Exiting." ) return if media_dict and prefix_index: logger.info("Matching assets to media, please wait...") unmatched_dict = match_assets_to_media( media_dict, prefix_index, logger, return_unmatched_assets=True, config=config, strict_folder_match=True, ) if any(unmatched_dict.values()): remove_data = remove_assets(unmatched_dict, config, logger) if remove_data: print_output(remove_data, logger) else: logger.info("✅ No assets needed to be removed. Everything is in sync!") # Only dump debug JSON if we're in debug mode if config.log_level.lower() == "debug": logger.debug("Dumping debug data for assets/media/unmatched/remove_data.") print_json(assets_dict, logger, config.module_name, "assets_dict") print_json(media_dict, logger, config.module_name, "media_dict") print_json(unmatched_dict, logger, config.module_name, "unmatched_dict") print_json(remove_data, logger, config.module_name, "remove_data") except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: logger.log_outro() ================================================ FILE: modules/poster_renamerr.py ================================================ import copy import filecmp import os import re import shutil import sys from types import SimpleNamespace from typing import Any, Dict, List, Tuple from util.arrpy import create_arr_client from util.assets import get_assets_files from util.constants import year_regex from util.index import create_new_empty_index from util.logger import Logger from util.match import match_assets_to_media from util.notification import send_notification from util.utility import ( create_table, get_plex_data, print_json, print_settings, ) try: from pathvalidate import is_valid_filename, sanitize_filename from plexapi.server import PlexServer from util.utility import progress except ImportError as e: print(f"ImportError: {e}") print("Please install the required modules with 'pip install -r requirements.txt'") exit(1) def process_file(file: str, new_file_path: str, action_type: str, logger: Any) -> None: """ Perform a file operation (copy, move, hardlink, or symlink) between paths. Args: file: Original file path. new_file_path: Destination file path. action_type: Operation type: 'copy', 'move', 'hardlink', or 'symlink'. logger: Logger for error reporting. Returns: None """ try: if action_type == "copy": shutil.copy(file, new_file_path) elif action_type == "move": shutil.move(file, new_file_path) elif action_type == "hardlink": os.link(file, new_file_path) elif action_type == "symlink": os.symlink(file, new_file_path) except OSError as e: logger.error(f"Error {action_type}ing file: {e}") def rename_files( matched_assets: Dict[str, List[Dict[str, Any]]], config: SimpleNamespace, logger: Any, ) -> Tuple[Dict[str, List[Dict[str, Any]]], List[str]]: """ Rename matched assets to Plex-compatible filenames and handle folder structure. Args: matched_assets: Dictionary of matched poster assets. config: Module configuration. logger: Logger instance. Returns: Tuple of output message dict and renamed assets dict. """ output: Dict[str, List[Dict[str, Any]]] = {} renamed_files = [] # Determine destination based on dry run and border replacer if config.run_border_replacerr: tmp_dir = os.path.join(config.destination_dir, "tmp") if not config.dry_run: if not os.path.exists(tmp_dir): os.makedirs(tmp_dir) else: logger.debug(f"{tmp_dir} already exists") destination_dir = tmp_dir else: logger.debug(f"Would create folder {tmp_dir}") destination_dir = tmp_dir else: destination_dir = config.destination_dir asset_types: List[str] = ["collections", "movies", "series"] logger.info("Renaming assets please wait...") for asset_type in asset_types: output[asset_type] = [] if matched_assets[asset_type]: with progress( matched_assets[asset_type], desc=f"Renaming {asset_type}", total=len(matched_assets[asset_type]), unit="item", logger=logger, ) as pbar: for item in pbar: messages: List[str] = [] discord_messages: List[str] = [] files = item["files"] folder = item["folder"] # Sanitize folder name for collections if asset_type == "collections": if not is_valid_filename(folder): folder = sanitize_filename(folder) # Construct destination folder if config.asset_folders: dest_dir = os.path.join(destination_dir, folder) if not os.path.exists(dest_dir): if not config.dry_run: os.makedirs(dest_dir) else: dest_dir = destination_dir # Rename each asset file for file in files: file_name = os.path.basename(file) file_extension = os.path.splitext(file)[1] if re.search(r" - Season| - Specials", file_name): try: season_number = ( re.search(r"Season (\d+)", file_name).group(1) if "Season" in file_name else "00" ).zfill(2) except AttributeError: logger.debug( f"Error extracting season number from {file_name}" ) continue if config.asset_folders: new_file_name = f"Season{season_number}{file_extension}" else: new_file_name = ( f"{folder}_Season{season_number}{file_extension}" ) new_file_path = os.path.join(dest_dir, new_file_name) else: if config.asset_folders: new_file_name = f"poster{file_extension}" else: new_file_name = f"{folder}{file_extension}" new_file_path = os.path.join(dest_dir, new_file_name) # Check if destination exists and is different if os.path.lexists(new_file_path): existing_file = os.path.join(dest_dir, new_file_name) try: if not filecmp.cmp(file, existing_file): if file_name != new_file_name: messages.append( f"{file_name} -renamed-> {new_file_name}" ) discord_messages.append(f"{new_file_name}") else: if not config.print_only_renames: messages.append( f"{file_name} -not-renamed-> {new_file_name}" ) discord_messages.append(f"{new_file_name}") if not config.dry_run: if config.action_type in [ "hardlink", "symlink", ]: os.remove(new_file_path) process_file( file, new_file_path, config.action_type, logger, ) renamed_files.append(new_file_path) except FileNotFoundError: if not config.dry_run: os.remove(new_file_path) process_file( file, new_file_path, config.action_type, logger ) renamed_files.append(new_file_path) else: if file_name != new_file_name: messages.append( f"{file_name} -renamed-> {new_file_name}" ) discord_messages.append(f"{new_file_name}") else: if not config.print_only_renames: messages.append( f"{file_name} -not-renamed-> {new_file_name}" ) discord_messages.append(f"{new_file_name}") if not config.dry_run: process_file( file, new_file_path, config.action_type, logger ) renamed_files.append(new_file_path) if messages or discord_messages: output[asset_type].append( { "title": item["title"], "year": item["year"], "folder": item["folder"], "messages": messages, "discord_messages": discord_messages, } ) else: logger.info(f"No {asset_type} to rename") return output, renamed_files def handle_output( output: Dict[str, List[Dict[str, Any]]], config: SimpleNamespace, logger: Any ) -> None: """ Print final rename results to the logger by asset type. Args: output: Collected messages by asset type. config: Configuration settings. logger: Logger for printing. Returns: None """ for asset_type, assets in output.items(): if assets: table = [ [f"{asset_type.capitalize()}"], ] if any(asset["messages"] for asset in assets): logger.info(create_table(table)) for asset in assets: title = asset["title"] title = year_regex.sub("", title).strip() year = asset["year"] folder = asset["folder"] messages = asset["messages"] if year: year = f" ({year})" else: year = "" messages.sort() if messages: logger.info(f"{title}{year}") if config.asset_folders: if config.dry_run: logger.info(f"\tWould create folder '{folder}'") else: logger.info(f"\tCreated folder '{folder}'") for message in messages: logger.info(f"\t{message}") logger.info("") else: logger.info(f"No {asset_type} to rename") def main(config: SimpleNamespace) -> None: """ Entrypoint for poster_renamerr.py. Loads configuration, fetches media and assets, matches posters, performs renames, and optionally syncs to Google Drive and runs border replacerr if enabled. Args: config: Parsed config from user settings. Returns: None """ logger = Logger(config.log_level, config.module_name) try: if config.log_level.lower() == "debug": print_settings(logger, config) if not os.path.exists(config.destination_dir): logger.info(f"Creating destination directory: {config.destination_dir}") os.makedirs(config.destination_dir) else: logger.debug( f"Destination directory already exists: {config.destination_dir}" ) if config.dry_run: table = [["Dry Run"], ["NO CHANGES WILL BE MADE"]] logger.info(create_table(table)) if config.sync_posters: logger.info("Running sync_gdrive") from modules.sync_gdrive import main as gdrive_main from util.config import Config gdrive_config = Config("sync_gdrive").module_config gdrive_main(gdrive_config) logger.info("Finished running sync_gdrive") else: logger.debug("Sync posters is disabled. Skipping...") prefix_index = create_new_empty_index() logger.info("Gathering all the posters, please wait...") assets_dict, prefix_index = get_assets_files(config.source_dirs, logger) if not assets_dict: logger.error("No assets found in the source directories. Exiting module...") return media_dict: Dict[str, List[Dict[str, Any]]] = { "movies": [], "series": [], "collections": [], } if config.instances: for instance in config.instances: if isinstance(instance, dict): instance_name, instance_settings = next(iter(instance.items())) else: instance_name = instance instance_settings = {} found = False for instance_type, instance_data in config.instances_config.items(): if instance_name in instance_data: found = True break if not found: logger.warning( f"Instance '{instance_name}' not found in config.instances_config. Skipping." ) continue if instance_type == "plex": url = instance_data[instance_name]["url"] api = instance_data[instance_name]["api"] try: app = PlexServer(url, api) except Exception as e: logger.error(f"Error connecting to Plex: {e}") app = None if app: library_names = instance_settings.get("library_names", []) if library_names: logger.info("Fetching Plex collections...") results = get_plex_data( app, library_names, logger, include_smart=True, collections_only=True, ) media_dict["collections"].extend(results) else: logger.warning( "No library names specified for Plex instance. Skipping Plex." ) else: url = instance_data[instance_name]["url"] api = instance_data[instance_name]["api"] app = create_arr_client(url, api, logger) if app and app.connect_status: logger.info(f"Fetching {app.instance_name} data...") results = app.get_parsed_media(include_episode=False) if results: if instance_type == "radarr": media_dict["movies"].extend(results) elif instance_type == "sonarr": media_dict["series"].extend(results) else: logger.error(f"No {instance_type.capitalize()} data found.") else: logger.error("No instances found. Exiting module...") return if not any(media_dict.values()): logger.error( "No media found, Check instances setting in your config. Exiting." ) return renamed_assets = None if media_dict and prefix_index: logger.info("Matching assets to media, please wait...") matched_assets = match_assets_to_media( media_dict, prefix_index, logger, return_unmatched_assets=False, config=config, ) if matched_assets and any(matched_assets.values()): # Optionally deep copy to strip heavy keys for debug (example for 'seasons') matched_assets_copy = copy.deepcopy(matched_assets) for media_type, media_list in matched_assets_copy.items(): for media in media_list: if "seasons" in media: del media["seasons"] if config.log_level == "debug": print_json(assets_dict, logger, config.module_name, "assets_dict") print_json(media_dict, logger, config.module_name, "media_dict") print_json(prefix_index, logger, config.module_name, "prefix_index") print_json( matched_assets_copy, logger, config.module_name, "matched_assets" ) output, renamed_files = rename_files(matched_assets, config, logger) if any(output.values()): handle_output(output, config, logger) send_notification( logger=logger, module_name=config.module_name, config=config, output=output, ) else: logger.info("No new posters to rename.") else: logger.info("No assets matched to media.") if config.run_border_replacerr: tmp_dir = os.path.join(config.destination_dir, "tmp") from modules.border_replacerr import process_files from util.config import Config from util.scanner import process_selected_files replacerr_config = Config("border_replacerr").module_config # Simplified conditional logic for incremental/full run if config.incremental_border_replacerr: if renamed_files: renamed_assets = process_selected_files( renamed_files, logger, asset_folders=config.asset_folders ) logger.info( "\nDoing an incremental run on only assets that were provided\nStarting Border Replacerr...\n" ) process_files( tmp_dir, config=replacerr_config, logger=None, renamerr_config=config, renamed_assets=renamed_assets, incremental_run=True, ) logger.info("Finished running border_replacerr") else: logger.info( "\nNo new assets to incrementally perform with border_replacerr.\nSkipping Border Replacerr.." ) else: logger.info( "\nDoing a full run with Border Replacerr\nStarting Border Replacerr...\n" ) process_files( tmp_dir, config=replacerr_config, logger=None, renamerr_config=config, renamed_assets=renamed_assets, incremental_run=False, ) logger.info("Finished running border_replacerr.py") except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/renameinatorr.py ================================================ import re import sys import time from collections import defaultdict from types import SimpleNamespace from typing import Any, Dict, List from util.arrpy import BaseARRClient, create_arr_client from util.constants import season_regex from util.logger import Logger from util.notification import send_notification from util.utility import create_table, print_settings, progress def print_output(output: Dict[str, Dict[str, Any]], logger: Logger) -> None: """ Print formatted output summarizing rename results for each instance. Args: output: Output results per instance. logger: Logger for printing results. """ for instance, instance_data in output.items(): table = [[f"{instance_data['server_name'].capitalize()} Rename List"]] logger.info(create_table(table)) for item in instance_data["data"]: if item["file_info"] or item["new_path_name"]: logger.info(f"{item['title']} ({item['year']})") # Show folder rename if present if item["new_path_name"]: logger.info( f"\tFolder Renamed: {item['path_name']} -> {item['new_path_name']}" ) # Show file renames if present if item["file_info"]: logger.info("\tFiles:") for existing_path, new_path in item["file_info"].items(): logger.info(f"\t\tOriginal: {existing_path}\n\t\tNew: {new_path}\n") logger.info("") total_items = len(instance_data["data"]) total_rename_items = len( [v["file_info"] for v in instance_data["data"] if v["file_info"]] ) total_folder_rename = len( [v["new_path_name"] for v in instance_data["data"] if v["new_path_name"]] ) if any(v["file_info"] or v["new_path_name"] for v in instance_data["data"]): table = [ [f"{instance_data['server_name'].capitalize()} Rename Summary"], [f"Total Items: {total_items}"], ] if any(v["file_info"] for v in instance_data["data"]): table.append([f"Total Renamed Items: {total_rename_items}"]) if any(v["new_path_name"] for v in instance_data["data"]): table.append([f"Total Folder Renames: {total_folder_rename}"]) logger.info(create_table(table)) else: logger.info(f"No items renamed in {instance_data['server_name']}.") logger.info("") def get_count_for_instance_type( config: SimpleNamespace, instance_type: str, logger: Logger ) -> int: """ Get the number of items to process for a given instance type, allowing overrides. Args: config: Configuration object. instance_type: 'radarr' or 'sonarr'. logger: Logger instance. Returns: Count limit for the instance. """ count = config.count if instance_type == "radarr" and getattr(config, "radarr_count", None): logger.debug( f"radarr_count found! overriding count from {config.count} to {config.radarr_count}" ) count = config.radarr_count elif instance_type == "sonarr" and getattr(config, "sonarr_count", None): logger.debug( f"sonarr_count found! overriding count from {config.count} to {config.sonarr_count}" ) count = config.sonarr_count logger.info(f"using count= {count} for instance_type= {instance_type}") return count def process_instance( app: BaseARRClient, instance_type: str, config: SimpleNamespace, logger: Logger ) -> List[Dict[str, Any]]: """ Rename media and optionally folders for a single Radarr/Sonarr instance. Args: app: ARR API abstraction client. instance_type: Instance type ('radarr' or 'sonarr'). config: Configuration settings. logger: Logger instance. Returns: List of processed media items with rename results. """ table = [[f"Processing {app.instance_name}"]] logger.debug(create_table(table)) default_batch_size: int = 100 instance_start_time: float = time.time() media_dict: List[Dict[str, Any]] = app.get_parsed_media() count: int = get_count_for_instance_type(config, instance_type, logger) tag_id: Any = None # Ignore-tag filtering: skip items with the ignore tag, if configured skipped_count = 0 if getattr(config, "ignore_tag", None): ignore_tag_id = app.get_tag_id_from_name(config.ignore_tag) if ignore_tag_id: before_count = len(media_dict) media_dict = [item for item in media_dict if ignore_tag_id not in item.get("tags", [])] skipped_count = before_count - len(media_dict) if skipped_count > 0: logger.info(f"Skipped {skipped_count} items due to ignore tag '{config.ignore_tag}'.") # Tagging logic: filter untagged, clear if all tagged if getattr(config, "tag_name", None): tag_id = app.get_tag_id_from_name(config.tag_name) all_items_without_tags = None if tag_id: all_items_without_tags = [ item for item in media_dict if tag_id not in item["tags"] ] if not all_items_without_tags: media_ids = [item["media_id"] for item in media_dict] logger.info("All media is tagged. Removing tags...") app.remove_tags(media_ids, tag_id) all_items_without_tags = app.get_parsed_media() media_dict = all_items_without_tags # Chunking behavior: single or batched if not getattr(config, "enable_batching", False): if not count: chunks_to_process_this_run: List[List[Dict[str, Any]]] = [media_dict] else: chunks_to_process_this_run = get_chunks_for_run(media_dict, count, logger) chunks_to_process_this_run = ( [chunks_to_process_this_run[0]] if chunks_to_process_this_run else [] ) else: count = count if count else default_batch_size chunks_to_process_this_run = get_chunks_for_run(media_dict, count, logger) logger.info(f"num_chunks= {len(chunks_to_process_this_run)}") final_media_dict: List[Dict[str, Any]] = [] chunk_progress_bar = progress( chunks_to_process_this_run, desc=f"Processing batches for '{app.instance_name}'...", unit="items", logger=logger, leave=True, ) for chunk in chunk_progress_bar: chunk_start_time: float = time.time() media_dict = chunk logger.debug(f"Processing {len(media_dict)} media items in current chunk") if media_dict: logger.info("Processing data... This may take a while.") progress_bar = progress( media_dict, desc=f"Processing single batch for '{app.instance_name}'...", unit="items", logger=logger, leave=True, ) grouped_root_folders: Dict[str, List[int]] = defaultdict(list) media_ids: List[int] = [] any_renamed: bool = False for item in progress_bar: file_info: Dict[str, str] = {} rename_response = app.get_rename_list(item["media_id"]) for items in rename_response: existing_path = items.get("existingPath") new_path = items.get("newPath") # Remove season info from path if present if existing_path and re.search(season_regex, existing_path): existing_path = re.sub(season_regex, "", existing_path) if new_path and re.search(season_regex, new_path): new_path = re.sub(season_regex, "", new_path) # Remove leading slashes if existing_path: existing_path = existing_path.lstrip("/") if new_path: new_path = new_path.lstrip("/") file_info[existing_path] = new_path item["new_path_name"] = None item["file_info"] = file_info if file_info: any_renamed = True media_ids.append(item["media_id"]) if getattr(config, "rename_folders", False): grouped_root_folders[item["root_folder"]].append(item["media_id"]) if not getattr(config, "dry_run", False): # Perform file renaming if media_ids: app.rename_media(media_ids) if any_renamed: logger.info(f"Refreshing {app.instance_name}...") response = app.refresh_items(media_ids) ready = app.wait_for_command(response["id"]) if ready: logger.info(f"Media refreshed on {app.instance_name}...") else: logger.info(f"No media to rename on {app.instance_name}...") # Tagging after rename if tag_id and getattr(config, "tag_name", None): logger.info( f"Adding tag '{config.tag_name}' to items in {app.instance_name}..." ) app.add_tags(media_ids, tag_id) # Folder rename steps if getattr(config, "rename_folders", False) and grouped_root_folders: logger.info(f"Renaming folders in {app.instance_name}...") for root_folder, folder_media_ids in grouped_root_folders.items(): logger.debug(f"renaming root folder {root_folder}") app.rename_folders(folder_media_ids, root_folder) logger.info(f"Refreshing {app.instance_name}...") response = app.refresh_items(media_ids) logger.info(f"Waiting for {app.instance_name} to refresh...") ready = app.wait_for_command(response["id"]) logger.info(f"Folders renamed in {app.instance_name}...") # Update items with new path names if changed if ready: logger.info(f"Fetching updated data for {app.instance_name}...") new_media_dict = app.get_parsed_media() for new_item in new_media_dict: for old_item in media_dict: if new_item["media_id"] == old_item["media_id"]: logger.debug( f"Checking if item {new_item['media_id']} changed..." ) if new_item["path_name"] != old_item["path_name"]: logger.debug( f"item {new_item['media_id']} changed from {old_item['path_name']} to {new_item['path_name']}" ) old_item["new_path_name"] = new_item[ "path_name" ] final_media_dict.extend(media_dict) # Output formatting: chunk timing and rename stats total_renamed = sum( len(i["file_info"]) for i in media_dict if i.get("file_info") ) total_folder_renamed = sum(bool(i["new_path_name"]) for i in media_dict) logger.info( f"Chunk completed in {time.time() - chunk_start_time:.2f} seconds | " f"Files renamed: {total_renamed} | Folders renamed: {total_folder_renamed}" ) logger.info( f"Finished processing {app.instance_name} in {time.time() - instance_start_time:.2f} seconds." ) final_media_dict.sort(key=lambda it: it.get("new_path_name") or it["path_name"]) trimmed: List[Dict[str, Any]] = [] for item in final_media_dict: raw_info = item.get("file_info", {}) sorted_info = {old: raw_info[old] for old in sorted(raw_info.keys())} trimmed.append( { "title": item["title"], "year": item["year"], "path_name": item["path_name"], "new_path_name": item.get("new_path_name"), "file_info": sorted_info, } ) return trimmed def get_chunks_for_run( media_dict: List[Dict[str, Any]], chunk_size: int, logger: Logger ) -> List[List[Dict[str, Any]]]: """ Split media list into chunks of defined size. Args: media_dict: Full list of media items. chunk_size: Desired chunk size. logger: Logger instance. Returns: List of chunked lists. """ chunks: List[List[Dict[str, Any]]] = [] for i in range(0, len(media_dict), chunk_size): chunks.append(media_dict[i : i + chunk_size]) return chunks def get_untagged_chunks_for_run( media_dict: List[Dict[str, Any]], tag_id: int, chunk_size: int, all_in_single_run: bool, logger: Logger, ) -> List[List[Dict[str, Any]]]: """ Filter untagged media items and split into chunks. Args: media_dict: Media items. tag_id: Tag ID to check. chunk_size: Desired chunk size. all_in_single_run: Whether to return a single chunk. logger: Logger instance. Returns: Chunked untagged items. """ all_items_without_tags = [item for item in media_dict if tag_id not in item["tags"]] return get_chunks_for_run(all_items_without_tags, chunk_size, logger) def main(config: SimpleNamespace) -> None: """ Entrypoint for renameinatorr. Loads config, processes enabled instances, prints results. Args: config: Parsed config for renameinatorr. """ logger = Logger(config.log_level, config.module_name) try: if getattr(config, "log_level", "").lower() == "debug": print_settings(logger, config) if getattr(config, "dry_run", False): table = [["Dry Run"], ["NO CHANGES WILL BE MADE"]] logger.info(create_table(table)) logger.info("") output: Dict[str, Dict[str, Any]] = {} for instance_type, instance_data in config.instances_config.items(): for instance in config.instances: if instance in instance_data: app = create_arr_client( instance_data[instance]["url"], instance_data[instance]["api"], logger, ) if app and app.connect_status: data = process_instance(app, instance_type, config, logger) output[instance] = { "server_name": app.instance_name, "data": data, } if any(value["data"] for value in output.values()): print_output(output, logger) send_notification( logger=logger, module_name=config.module_name, config=config, output=output, ) else: logger.info("No media items to rename.") except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/sync_gdrive.py ================================================ import json import os import re import shlex import subprocess import sys from shutil import which from types import SimpleNamespace from typing import List, Optional from util.logger import Logger from util.utility import print_settings # Load environment variables from .env file if available try: from dotenv import load_dotenv load_dotenv(override=True) except ImportError: pass def get_rclone_path() -> str: """Find the full path to the rclone binary, checking RCLONE_PATH env var first.""" # Allow override via environment variable env_path = os.getenv("RCLONE_PATH") if env_path: if os.path.isfile(env_path) and os.access(env_path, os.X_OK): return env_path else: raise FileNotFoundError( f"RCLONE_PATH is set to '{env_path}', but it is not an executable file." ) # Fallback to searching in PATH rclone_path = which("rclone") if rclone_path is None: raise FileNotFoundError( "rclone binary not found in PATH. Ensure it is installed and accessible, or set RCLONE_PATH." ) return rclone_path def run_rclone(config: SimpleNamespace, logger: Logger) -> None: """Run rclone sync for each configured Google Drive folder and log output.""" sync_list: List[dict] = ( config.gdrive_list if isinstance(config.gdrive_list, list) else [config.gdrive_list] ) rclone_path = get_rclone_path() logger.debug(f"Using rclone binary at: {rclone_path}") if config.gdrive_sa_location and not os.path.isfile(config.gdrive_sa_location): logger.warning( f"\nGoogle service account file '{config.gdrive_sa_location}' does not exist\n" "Please verify the path or remove it from config\n" ) config.gdrive_sa_location = None # Ensure rclone remote 'posters' exists by creating it if missing try: logger.debug("Ensuring rclone remote 'posters' exists") subprocess.run( [ rclone_path, "config", "create", "posters", "drive", "config_is_local=false", ], check=False, ) except Exception as e: logger.error(f"Error ensuring rclone remote 'posters' exists: {e}") for sync_item in sync_list: sync_location: Optional[str] = sync_item.get("location") sync_id: Optional[str] = sync_item.get("id") if not sync_location or not sync_id: logger.error("Sync location or GDrive folder ID not provided.") continue # Ensure local sync directory exists try: os.makedirs(sync_location, exist_ok=True) logger.info(f"Ensured sync location exists: {sync_location}") except OSError as e: logger.error(f"Could not create sync location '{sync_location}': {e}") continue # Build rclone command with necessary flags and credentials cmd = [ rclone_path, "sync", "--drive-client-id", config.client_id or "", "--drive-client-secret", config.client_secret or "", "--drive-token", json.dumps(config.token) if config.token else "", "--drive-root-folder-id", sync_id, "--fast-list", "--tpslimit=5", "--no-update-modtime", "--drive-use-trash=false", "--drive-chunk-size=512M", "--exclude=**.partial", "--check-first", "--bwlimit=80M", "--size-only", "--delete-after", "-v", ] if config.gdrive_sa_location: cmd.extend(["--drive-service-account-file", config.gdrive_sa_location]) cmd.extend(["posters:", sync_location]) try: logger.debug("Running rclone command:") logger.debug("\n" + " \\\n ".join(shlex.quote(arg) for arg in cmd)) process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) for line in process.stdout: # Clean rclone output by removing timestamp and log level prefixes cleaned_line = re.sub( r"^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} (INFO|ERROR|DEBUG) *:?", "", line, ).strip() if cleaned_line: logger.info(cleaned_line) process.wait() if process.returncode == 0: logger.info("✅ RClone sync completed successfully.") else: logger.error( f"❌ RClone sync failed with return code {process.returncode}" ) except Exception as e: logger.error(f"Exception occurred while running rclone: {e}") def main(config: SimpleNamespace, logger: Optional[Logger] = None) -> None: """Initialize logger, optionally print config in debug mode, and run rclone sync.""" logger = Logger(config.log_level, config.module_name) try: if config.log_level.lower() == "debug": print_settings(logger, config) run_rclone(config, logger) except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/unmatched_assets.py ================================================ import copy import sys from types import SimpleNamespace from typing import Dict, List, Union from util.arrpy import create_arr_client from util.assets import get_assets_files from util.index import create_new_empty_index from util.logger import Logger from util.match import match_media_to_assets from util.notification import send_notification from util.utility import create_table, get_plex_data, print_json, print_settings try: from plexapi.server import PlexServer except ImportError as e: print(f"ImportError: {e}") print("Please install the required modules with 'pip install -r requirements.txt'") exit(1) def print_output( unmatched_dict: Dict[str, List[Dict]], media_dict: Dict[str, List[Dict]], logger: Logger, ) -> Dict[str, List[List[Union[str, int]]]]: """Print unmatched results and statistics, returning a summary table.""" output = {"unmatched_dict": unmatched_dict} asset_types = ["movies", "series", "collections"] for asset_type in asset_types: data_set = unmatched_dict.get(asset_type, None) if data_set: table = [[f"Unmatched {asset_type.capitalize()}"]] logger.info(create_table(table)) if data_set: for idx, item in enumerate(data_set): if idx % 10 == 0: logger.info( f"\t*** {asset_type.title()} {idx + 1} - {min(idx + 10, len(data_set))} ***" ) logger.info("") if asset_type == "series": missing_seasons = item.get("missing_seasons", False) missing_main = item.get("missing_main_poster", False) title = item["title"] year = item["year"] # Combined missing info if missing_seasons and missing_main: logger.info(f"\t{title} ({year})") for season in item.get("missing_seasons", []): logger.info(f"\t\tSeason: {season}") elif missing_seasons: logger.info( f"\t{title} ({year}) (Seasons listed below have missing posters)" ) for season in item["missing_seasons"]: logger.info(f"\t\tSeason: {season}") elif missing_main: logger.info( f"\t{title} ({year}) Main series poster missing" ) else: year = f" ({item['year']})" if item.get("year") else "" logger.info(f"\t{item['title']}{year}") logger.info("") logger.info("") # Calculate statistics for movies unmatched_movies_total = len(unmatched_dict.get("movies", [])) total_movies = len(media_dict.get("movies", [])) if media_dict.get("movies") else 0 percent_movies_complete = ( ((total_movies - unmatched_movies_total) / total_movies * 100) if total_movies else 0 ) # Calculate statistics for series (count only series with missing main poster) unmatched_series_total = 0 for item in unmatched_dict.get("series", []): if item.get("missing_main_poster", False): unmatched_series_total += 1 total_series = len(media_dict.get("series", [])) if media_dict.get("series") else 0 series_percent_complete = ( ((total_series - unmatched_series_total) / total_series * 100) if total_series else 0 ) # Calculate unmatched seasons count (sum all missing season posters) unmatched_seasons_total = 0 for item in unmatched_dict.get("series", []): missing_seasons = item.get("missing_seasons", []) unmatched_seasons_total += len(missing_seasons) # Calculate total seasons with episodes present total_seasons = 0 for item in media_dict.get("series", []): seasons = item.get("seasons", None) if seasons: for season in seasons: if season.get("season_has_episodes"): total_seasons += 1 season_total_percent_complete = ( ((total_seasons - unmatched_seasons_total) / total_seasons * 100) if total_seasons else 0 ) # Calculate statistics for collections unmatched_collections_total = len(unmatched_dict.get("collections", [])) total_collections = ( len(media_dict.get("collections", [])) if media_dict.get("collections") else 0 ) collection_percent_complete = ( ((total_collections - unmatched_collections_total) / total_collections * 100) if total_collections else 0 ) # Calculate grand totals and percentage complete grand_total = total_movies + total_series + total_seasons + total_collections grand_unmatched_total = ( unmatched_movies_total + unmatched_series_total + unmatched_seasons_total + unmatched_collections_total ) grand_percent_complete = ( ((grand_total - grand_unmatched_total) / grand_total * 100) if grand_total else 0 ) logger.info("") logger.info(create_table([["Statistics"]])) table = [["Type", "Total", "Unmatched", "Percent Complete"]] if unmatched_dict.get("movies") or media_dict.get("movies"): table.append( [ "Movies", total_movies, unmatched_movies_total, f"{percent_movies_complete:.2f}%", ] ) if unmatched_dict.get("series") or media_dict.get("series"): table.append( [ "Series", total_series, unmatched_series_total, f"{series_percent_complete:.2f}%", ] ) table.append( [ "Seasons", total_seasons, unmatched_seasons_total, f"{season_total_percent_complete:.2f}%", ] ) if unmatched_dict.get("collections") or media_dict.get("collections"): table.append( [ "Collections", total_collections, unmatched_collections_total, f"{collection_percent_complete:.2f}%", ] ) table.append( [ "Grand Total", grand_total, grand_unmatched_total, f"{grand_percent_complete:.2f}%", ] ) logger.info(create_table(table)) output["summary"] = table return output def main(config: SimpleNamespace) -> None: """Load media and assets, identify unmatched assets, and log summary statistics.""" logger = Logger(config.log_level, config.module_name) try: if config.log_level.lower() == "debug": print_settings(logger, config) prefix_index = create_new_empty_index() print("Gathering all the posters, please wait...") assets_dict, prefix_index = get_assets_files( config.source_dirs, logger, merge=False ) if not assets_dict: return media_dict = {"movies": [], "series": [], "collections": []} if config.instances: for instance in config.instances: # Determine instance name and settings if isinstance(instance, dict): instance_name, instance_settings = next(iter(instance.items())) else: instance_name = instance instance_settings = {} # Determine instance type and data found = False for instance_type, instance_data in config.instances_config.items(): if instance_name in instance_data: found = True break if not found: logger.warning( f"Instance '{instance_name}' not found in config.instances_config. Skipping." ) continue if instance_type == "plex": url = instance_data[instance_name]["url"] api = instance_data[instance_name]["api"] try: app = PlexServer(url, api) except Exception as e: logger.error(f"Error connecting to Plex: {e}") app = None if app: library_names = instance_settings.get("library_names", []) if library_names: logger.info("Fetching Plex collections...") results = get_plex_data( app, library_names, logger, include_smart=True, collections_only=True, ) media_dict["collections"].extend(results) else: logger.warning( "No library names specified for Plex instance. Skipping Plex." ) else: # For other instance types (e.g., radarr, sonarr), create client and get media url = instance_data[instance_name]["url"] api = instance_data[instance_name]["api"] app = create_arr_client(url, api, logger) if not app: logger.error(f"Failed to connect to {instance_name}, skipping.") continue if app.connect_status: logger.info(f"Fetching {app.instance_name} data...") results = app.get_parsed_media(include_episode=False) if results: if instance_type == "radarr": media_dict["movies"].extend(results) elif instance_type == "sonarr": media_dict["series"].extend(results) else: logger.error(f"No {instance_type.capitalize()} data found.") else: logger.error("No instances found. Exiting script...") return if not any(media_dict.values()): logger.error( "No media found, Check instances setting in your config. Exiting." ) return # Remove heavy keys for logging clarity media_dict_copy = copy.deepcopy(media_dict) for media_type, media_list in media_dict_copy.items(): for media in media_list: if "seasons" in media: del media["seasons"] # Match assets and print output if media_dict and prefix_index: logger.info("Matching assets to media, please wait...") unmatched_dict = match_media_to_assets( media_dict, prefix_index, config.ignore_root_folders, logger ) output = print_output(unmatched_dict, media_dict, logger) if any(unmatched_dict.values()): if config.notifications and output: logger.info("Sending notification...") send_notification( logger=logger, module_name=config.module_name, config=config, output=output, ) else: logger.info("All assets matched.") if config.log_level == "debug": print_json(assets_dict, logger, config.module_name, "assets_dict") print_json(media_dict_copy, logger, config.module_name, "media_dict") print_json(prefix_index, logger, config.module_name, "prefix_index") print_json(unmatched_dict, logger, config.module_name, "unmatched_dict") except KeyboardInterrupt: print("Exiting due to keyboard interrupt.") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") return finally: # Log outro message with run time logger.log_outro() ================================================ FILE: modules/upgradinatorr.py ================================================ import sys from types import SimpleNamespace from typing import Any, Dict, List, Optional from util.arrpy import BaseARRClient, create_arr_client from util.logger import Logger from util.notification import send_notification from util.utility import create_table, print_settings VALID_STATUSES = {"continuing", "airing", "ended", "canceled", "released"} def filter_media( media_dict: List[Dict[str, Any]], checked_tag_id: int, ignore_tag_id: int, count: int, season_monitored_threshold: int, logger: Logger, ) -> List[Dict[str, Any]]: """ Filter and return media entries that are eligible for processing. Args: media_dict: List of media entries. checked_tag_id: Tag ID for already-processed items. ignore_tag_id: Tag ID for ignored items. count: Max number of entries to process. season_monitored_threshold: Minimum monitored episode percentage. logger: Logger instance. Returns: Filtered list of media entries to process. """ filtered_media_dict: List[Dict[str, Any]] = [] filter_count: int = 0 for item in media_dict: if filter_count == count: break # Filter out media that is tagged, ignored, unmonitored, or not in valid status if ( checked_tag_id in item["tags"] or ignore_tag_id in item["tags"] or not item["monitored"] or item["status"] not in VALID_STATUSES ): reasons = [] if checked_tag_id in item["tags"]: reasons.append("tagged") if ignore_tag_id in item["tags"]: reasons.append("ignore") if not item["monitored"]: reasons.append("unmonitored") if item["status"] not in VALID_STATUSES: reasons.append(f"status={item['status']}") logger.debug( f"Skipping {item['title']} ({item['year']}), Reason: {', '.join(reasons)}" ) continue # Disable season if monitored percentage falls below threshold if item["seasons"]: series_monitored = False for i, season in enumerate(item["seasons"]): monitored_count = 0 for episode in season["episode_data"]: if episode["monitored"]: monitored_count += 1 if len(season["episode_data"]) > 0: monitored_percentage = ( monitored_count / len(season["episode_data"]) ) * 100 else: logger.debug( f"Skipping {item['title']} ({item['year']}), Season {i} unmonitored. Reason: No episodes in season." ) continue if ( season_monitored_threshold is not None and monitored_percentage < season_monitored_threshold ): item["seasons"][i]["monitored"] = False logger.debug( f"{item['title']}, Season {i} unmonitored. Reason: monitored percentage {int(monitored_percentage)}% less than season_monitored_threshold {int(season_monitored_threshold)}%" ) if item["seasons"][i]["monitored"]: series_monitored = True if not series_monitored: logger.debug( f"Skipping {item['title']} ({item['year']}), Status: {item['status']}, Monitored: {item['monitored']}, Tags: {item['tags']}" ) continue filtered_media_dict.append(item) logger.info( f"Queued for upgrade: {item['title']} ({item['year']}) [ID: {item['media_id']}]" ) filter_count += 1 return filtered_media_dict def process_search_response( search_response: Optional[Dict[str, Any]], media_id: int, app: BaseARRClient, logger: Logger, ) -> None: """ Wait for search command to complete and log the result. Args: search_response: API response from initiating a search. media_id: ID of the media being searched. app: ARR client instance. logger: Logger instance. Returns: None """ if search_response: logger.debug( f" [CMD] Waiting for command to complete for search response ID: {search_response['id']}" ) ready = app.wait_for_command(search_response["id"]) if ready: logger.debug( f" [CMD] Command completed successfully for search response ID: {search_response['id']}" ) else: logger.debug( f" [CMD] Command did not complete successfully for search response ID: {search_response['id']}" ) else: logger.warning(f"No search response for media ID: {media_id}") def process_queue( queue: Dict[str, Any], instance_type: str, media_ids: List[int] ) -> List[Dict[str, Any]]: """ Extract download records for matching media IDs from the queue. Args: queue: Queue data from the API. instance_type: "radarr" or "sonarr". media_ids: List of media IDs to filter. Returns: List of dicts with download info for matching media IDs. """ id_type = "movieId" if instance_type == "radarr" else "seriesId" queue_dict: List[Dict[str, Any]] = [] records = queue.get("records", []) for item in records: media_id = item.get(id_type) if media_id not in media_ids: continue # Only add if 'downloadId' exists in the item if "downloadId" not in item: continue queue_dict.append( { "download_id": item["downloadId"], "media_id": media_id, "download": item.get("title"), "torrent_custom_format_score": item.get("customFormatScore"), } ) # Remove duplicate download records queue_dict = [dict(t) for t in {tuple(d.items()) for d in queue_dict}] return queue_dict def process_instance( instance_type: str, instance_settings: Dict[str, Any], app: BaseARRClient, logger: Logger, config: SimpleNamespace, ) -> Optional[Dict[str, Any]]: """ Process a single instance: filter media, trigger searches, tag media, and gather results. Args: instance_type: "radarr" or "sonarr". instance_settings: Instance-specific settings from config. app: ARR client instance. logger: Logger instance. config: Global config. Returns: Dictionary of summary and media results, or None. """ tagged_count: int = 0 untagged_count: int = 0 total_count: int = 0 count: int = instance_settings.get("count", 2) checked_tag_name: str = instance_settings.get("tag_name", "checked") ignore_tag_name: str = instance_settings.get("ignore_tag", "ignore") unattended: bool = instance_settings.get("unattended", False) season_monitored_threshold: int = instance_settings.get( "season_monitored_threshold", 0 ) logger.info(f"Gathering media from {app.instance_name} ({instance_type})") # Set default for season_monitored_threshold to 1 if not provided if season_monitored_threshold is None: logger.warning( f"No 'season_monitored_threshold' provided for {app.instance_name}. Defaulting to 1." ) season_monitored_threshold = 1 media_dict: List[Dict[str, Any]] = ( app.get_parsed_media(include_episode=True) if app.instance_type.lower() == "sonarr" else app.get_parsed_media() ) ignore_tag_id = None checked_tag_id: int = app.get_tag_id_from_name(checked_tag_name) if ignore_tag_name: ignore_tag_id: int = app.get_tag_id_from_name(ignore_tag_name) filtered_media_dict: List[Dict[str, Any]] = filter_media( media_dict, checked_tag_id, ignore_tag_id, count, season_monitored_threshold, logger, ) if not filtered_media_dict and unattended: logger.info( f"All media for {app.instance_name} is already tagged—removing tags for unattended operation." ) media_ids = [item["media_id"] for item in media_dict] logger.info("All media is tagged. Removing tags...") app.remove_tags(media_ids, checked_tag_id) media_dict = ( app.get_parsed_media(include_episode=True) if app.instance_type.lower() == "sonarr" else app.get_parsed_media() ) filtered_media_dict = filter_media( media_dict, checked_tag_id, ignore_tag_id, count, season_monitored_threshold, logger, ) if not filtered_media_dict and not unattended: logger.info(f"No media left to process for {app.instance_name}.") logger.warning( f"No media found for {app.instance_name}. Reason: nothing left to tag." ) return None logger.debug(f"Filtered media count: {len(filtered_media_dict)}") if media_dict: total_count = len(media_dict) for item in media_dict: if checked_tag_id in item["tags"]: tagged_count += 1 else: untagged_count += 1 output_dict: Dict[str, Any] = { "server_name": app.instance_name, "tagged_count": tagged_count, "untagged_count": untagged_count, "total_count": total_count, "data": [], } if not config.dry_run: search_count: int = 0 media_ids: List[int] = [item["media_id"] for item in filtered_media_dict] # Search logic: trigger searches and tag after search for item in filtered_media_dict: logger.debug("") # Blank line before block logger.debug("═" * 70) logger.debug( f"[PROCESSING] {item['title']} ({item['year']}) | ID: {item['media_id']}" ) logger.debug("═" * 70) if item["seasons"] is None: logger.debug( f"Searching media without seasons for media ID: {item['media_id']}" ) search_response = app.search_media(item["media_id"]) process_search_response(search_response, item["media_id"], app, logger) logger.debug( f" [TAG] Adding tag {checked_tag_id} to media ID: {item['media_id']}" ) app.add_tags(item["media_id"], checked_tag_id) search_count += 1 if search_count >= count: logger.debug( f"🔁 Reached search count limit after non-season search ({search_count} >= {count}), breaking." ) logger.debug("─" * 70) logger.debug(f"[END] Finished: {item['title']} ({item['year']}) | ID: {item['media_id']}") logger.debug("─" * 70) logger.debug("") break else: searched = False for season in item["seasons"]: if season["monitored"]: logger.debug( f" [SEASON] {season['season_number']}: Searching..." ) search_response = app.search_season( item["media_id"], season["season_number"] ) process_search_response( search_response, item["media_id"], app, logger ) searched = True if searched: logger.debug( f" [TAG] Adding tag {checked_tag_id} to media ID: {item['media_id']}" ) app.add_tags(item["media_id"], checked_tag_id) search_count += 1 if search_count >= count: logger.debug( f"🔁 Reached series-based search count limit ({search_count} >= {count}), breaking." ) logger.debug("─" * 70) logger.debug(f"[END] Finished: {item['title']} ({item['year']}) | ID: {item['media_id']}") logger.debug("─" * 70) logger.debug("") break logger.debug("─" * 70) logger.debug(f"[END] Finished: {item['title']} ({item['year']}) | ID: {item['media_id']}") logger.debug("─" * 70) logger.debug("") # Blank line after block logger.info(f"Finished processing: {item['title']} ({item['year']})") logger.info( f"Completed upgrade operations for {app.instance_name}. Now retrieving download queue..." ) queue = app.get_queue() logger.debug(f"Queue item count: {len(queue.get('records', []))}") queue_dict: List[Dict[str, Any]] = process_queue( queue, instance_type, media_ids ) logger.debug(f"Queue dict item count: {len(queue_dict)}") queue_map: Dict[int, List[Dict[str, Any]]] = {} for q in queue_dict: queue_map.setdefault(q["media_id"], []).append(q) for item in filtered_media_dict: # Downloads are processed per media_id from the queue downloads = { q["download"]: q["torrent_custom_format_score"] for q in queue_map.get(item["media_id"], []) } output_dict["data"].append( { "media_id": item["media_id"], "title": item["title"], "year": item["year"], "download": downloads, } ) else: for item in filtered_media_dict: output_dict["data"].append( { "media_id": item["media_id"], "title": item["title"], "year": item["year"], "download": None, "torrent_custom_format_score": None, } ) return output_dict def print_output(output_dict: Dict[str, Any], logger: Logger) -> None: """ Print a human-readable summary of upgrade results for each instance. Args: output_dict: Mapping of instance name to media results. logger: Logger instance. Returns: None """ for instance, run_data in output_dict.items(): if run_data: instance_data = run_data.get("data", None) if instance_data: table = [[f"{run_data['server_name']}"]] logger.info(create_table(table)) logger.info( 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." ) for item in instance_data: logger.info(f"{item['title']} ({item['year']})") if item["download"]: for download, format_score in item["download"].items(): logger.info(f"\t{download}\tScore: {format_score}") else: logger.info("\tNo upgrades found for this item.") logger.info("") else: logger.info(f"No items found for {instance}.") def main(config: SimpleNamespace) -> None: """ Entrypoint for upgradinatorr. Loads config, processes instances, prints results, and sends notifications. Args: config: Loaded configuration object. Returns: None """ logger = Logger(config.log_level, config.module_name) try: if config.log_level.lower() == "debug": print_settings(logger, config) if config.dry_run: table = [["Dry Run"], ["NO CHANGES WILL BE MADE"]] logger.info(create_table(table)) if not getattr(config, "instances_list", None): logger.error("No instances found in config file.") sys.exit() final_output_dict: Dict[str, Any] = {} for instance_entry in config.instances_list: instance_name = instance_entry.get("instance") if not instance_name: continue for instance_type, instance_data in config.instances_config.items(): if instance_name in instance_data: url = instance_data[instance_name]["url"] api = instance_data[instance_name]["api"] app = create_arr_client(url, api, logger) if app and app.connect_status: output = process_instance( instance_type, instance_entry, app, logger, config ) final_output_dict.setdefault(instance_name, {}).update( output or {} ) logger.debug(f"Processed instances: {list(final_output_dict.keys())}") if final_output_dict: print_output(final_output_dict, logger) send_notification( logger=logger, module_name=config.module_name, config=config, output=final_output_dict, ) except KeyboardInterrupt: print("Keyboard Interrupt detected. Exiting...") sys.exit() except Exception: logger.error("\n\nAn error occurred:\n", exc_info=True) logger.error("\n\n") finally: # Log outro message with run time logger.log_outro() ================================================ FILE: requirements.txt ================================================ annotated-types==0.7.0 anyio==4.9.0 apprise==1.8.0 blinker==1.8.2 certifi==2025.1.31 charset-normalizer==3.4.1 click==8.1.7 croniter==6.0.0 dotenv==0.9.9 fastapi==0.115.12 h11==0.14.0 idna==3.10 iniconfig==2.1.0 itsdangerous==2.2.0 Jinja2==3.1.4 Markdown==3.6 MarkupSafe==2.1.5 mypy_extensions==1.1.0 oauthlib==3.2.2 packaging==24.0 pathspec==0.12.1 pathvalidate==3.2.3 pillow==11.2.1 platformdirs==4.3.8 PlexAPI==4.16.1 pluggy==1.5.0 prettytable==3.16.0 pydantic==2.11.3 pydantic_core==2.33.1 pytest==8.3.5 python-dateutil==2.9.0.post0 python-dotenv==1.1.0 pytz==2025.2 PyYAML==6.0.2 qbittorrent-api==2024.3.60 ratelimit==2.2.1 requests==2.32.3 requests-oauthlib==2.0.0 ruamel.yaml.clib==0.2.8 six==1.17.0 sniffio==1.3.1 starlette==0.46.2 tqdm==4.67.1 typing-inspection==0.4.0 typing_extensions==4.13.2 Unidecode==1.3.8 urllib3==2.4.0 uvicorn==0.34.2 watchdog==6.0.0 wcwidth==0.2.13 Werkzeug==3.0.3 ================================================ FILE: start.sh ================================================ #!/bin/bash set -euo pipefail PUID=${PUID:-99} PGID=${PGID:-100} UMASK=${UMASK:-002} BRANCH=${BRANCH:-master} export RCLONE_CONFIG="${CONFIG_DIR}/rclone/rclone.conf" VERSION=$(cat "$(dirname "$0")/VERSION") echo " --------------------------------------------------------- _____ _____ _____ | __ \ /\ | __ \ / ____| | | | | / \ | |__) | (___ | | | |/ /\ \ | ___/ \___ \ | |__| / ____ \| | ____) | |_____/_/ \_\_| |_____/ (Drazzilb's Arr PMM Scripts) PUID: ${PUID} PGID: ${PGID} UMASK: ${UMASK} BRANCH: ${BRANCH} DOCKER: ${DOCKER_ENV} VERSION: ${VERSION} CONFIG_DIR: ${CONFIG_DIR} RCLONE_CONFIG: ${RCLONE_CONFIG} APPDATA Path: ${APPDATA_PATH} LOG_DIR: ${LOG_DIR} --------------------------------------------------------- " echo "Setting umask to ${UMASK}" umask "$UMASK" groupmod -o -g "$PGID" dockeruser usermod -o -u "$PUID" dockeruser echo "Starting daps as $(whoami) with UID: $PUID and GID: $PGID" chown -R "${PUID}:${PGID}" "${CONFIG_DIR}" /app chmod -R 777 "${CONFIG_DIR}" [ -f "${CONFIG_DIR}/config.yml" ] && chmod 660 "${CONFIG_DIR}/config.yml" exec su -s /bin/bash -c "python3 main.py" dockeruser ================================================ FILE: util/__init__.py ================================================ ================================================ FILE: util/arrpy.py ================================================ import html import logging import os import time from typing import Any, Dict, List, Optional, Union import requests from unidecode import unidecode from util.constants import windows_path_regex, year_regex from util.extract import extract_year from util.normalization import normalize_titles logging.getLogger("requests").setLevel(logging.WARNING) class BaseARRClient: """Base class for interacting with ARR (Radarr/Sonarr) instances.""" def __init__(self, url: str, api: str, logger: Any) -> None: """ Initialize the base ARR client. Args: url (str): API URL. api (str): API key. logger (Any): Logger instance. """ self.logger = logger self.max_retries = 5 self.timeout = 60 self.url = url.rstrip("/") self.api = api self.headers = { "Accept": "application/json", "Content-Type": "application/json", "X-Api-Key": api, } self.session = requests.Session() self.session.headers.update({"X-Api-Key": self.api}) self.connect_status = False self.instance_type = None self.instance_name = None self.app_name = None self.app_version = None status = self.get_system_status() if not status: return self.app_name = status.get("appName") self.app_version = status.get("version") self.instance_name = status.get("instanceName") self.connect_status = True self.logger.debug( f"Connected to {self.app_name} v{self.app_version} at {self.url}" ) def get_health(self) -> Optional[Dict[str, Any]]: """ Get the health status of the ARR instance. Returns: Optional[Dict[str, Any]]: Health status. """ endpoint = f"{self.url}/api/v3/health" return self.make_get_request(endpoint, headers=self.headers) def wait_for_command(self, command_id: int) -> bool: """ Poll the given command ID until it completes, fails, or times out. Args: command_id (int): Command ID to wait for. Returns: bool: True if successful, False otherwise. """ self.logger.debug("Waiting for command to complete...") cycle = 0 while True: endpoint = f"{self.url}/api/v3/command/{command_id}" response = self.make_get_request(endpoint) if response and response.get("status") == "completed": return True if response and response.get("status") == "failed": return False time.sleep(5) cycle += 1 if cycle % 5 == 0: self.logger.debug( f"Still waiting for command {command_id}... (cycle {cycle})" ) if cycle > 120: self.logger.error(f"Command {command_id} timed out after 10 minutes.") return False def create_tag(self, tag: str) -> int: """ Create a new tag. Args: tag (str): Tag label. Returns: int: Created tag ID. """ payload = {"label": tag} self.logger.debug(f"Create tag payload: {payload}") endpoint = f"{self.url}/api/v3/tag" response = self.make_post_request(endpoint, json=payload) return response["id"] def get_instance_name(self) -> Optional[str]: """ Get instance name. Returns: Optional[str]: Instance name. """ status = self.get_system_status() return status.get("instanceName") if status else None def get_system_status(self) -> Optional[Dict[str, Any]]: """ Get ARR system status. Returns: Optional[Dict[str, Any]]: System status. """ endpoint = f"{self.url}/api/v3/system/status" return self.make_get_request(endpoint) def make_get_request( self, endpoint: str, headers: Optional[Dict[str, str]] = None ) -> Any: """ Make a GET request to endpoint. Args: endpoint (str): API endpoint. headers (Optional[Dict[str, str]]): Headers. Returns: Any: Response or JSON. """ return self._request_with_retries("GET", endpoint, headers=headers) def make_post_request( self, endpoint: str, headers: Optional[Dict[str, str]] = None, json: Any = None ) -> Any: """ Make a POST request to endpoint. Args: endpoint (str): API endpoint. headers (Optional[Dict[str, str]]): Headers. json (Any): JSON payload. Returns: Any: Response or JSON. """ return self._request_with_retries("POST", endpoint, headers=headers, json=json) def make_put_request( self, endpoint: str, headers: Optional[Dict[str, str]] = None, json: Any = None ) -> Any: """ Make a PUT request to endpoint. Args: endpoint (str): API endpoint. headers (Optional[Dict[str, str]]): Headers. json (Any): JSON payload. Returns: Any: Response or JSON. """ return self._request_with_retries("PUT", endpoint, headers=headers, json=json) def make_delete_request(self, endpoint: str, json: Any = None) -> Any: """ Make a DELETE request to endpoint. Args: endpoint (str): API endpoint. json (Any): JSON payload. Returns: Any: Response or JSON. """ return self._request_with_retries("DELETE", endpoint, json=json) def _request_with_retries( self, method: str, endpoint: str, headers: Optional[Dict[str, str]] = None, json: Any = None, ) -> Any: """ Perform HTTP request with retry logic. Args: method (str): HTTP method. endpoint (str): API endpoint. headers (Optional[Dict[str, str]]): Headers. json (Any): JSON payload. Returns: Any: Response or JSON. """ response = None for i in range(self.max_retries): try: response = self.session.request( method, endpoint, headers=headers, json=json, timeout=self.timeout ) response.raise_for_status() return response if method == "DELETE" else response.json() except ( requests.exceptions.Timeout, requests.exceptions.HTTPError, requests.exceptions.RequestException, ) as ex: if i < self.max_retries - 1: self.logger.warning( f"{method} request failed ({ex}), retrying ({i+1}/{self.max_retries})..." ) time.sleep(1) else: self._handle_request_exception(method, endpoint, ex, response, json) return None def _handle_request_exception( self, method: str, endpoint: str, ex: Exception, response: Any, payload: Any = None, ) -> None: """ Handle exceptions during HTTP request. Args: method (str): HTTP method. endpoint (str): API endpoint. ex (Exception): Exception. response (Any): Response object. payload (Any): Payload data. """ status_code = ( response.status_code if response is not None and hasattr(response, "status_code") and response.status_code else "No response" ) hint = ( self._get_error_hint(status_code) if isinstance(status_code, int) else "No HTTP response received, check URL" ) self.logger.error(f"{method} request failed after {self.max_retries} retries.") self.logger.error(f"Endpoint: {endpoint}") if payload: self.logger.error(f"Payload: {payload}") if response is not None and hasattr(response, "text"): self.logger.error(f"Response: {response.text} Code: {status_code}") self.logger.error(f"Status: {status_code}, Error: {ex}") self.logger.error(f"\nHint: {hint}\n") def _get_error_hint(self, status_code: int) -> str: """ Get a user-friendly hint for a given HTTP status code. Args: status_code (int): HTTP status code. Returns: str: Hint. """ hints = { 400: "Bad Request – likely malformed or missing parameters.", 401: "Unauthorized – check that your API key is correct.", 403: "Forbidden – the API key may not have the necessary permissions.", 404: "Not Found – the endpoint may be incorrect or the resource doesn't exist.", 429: "Too Many Requests – you may have hit a rate limit.", 500: "Internal Server Error – something went wrong on the server.", 503: "Service Unavailable – the server is currently down or overloaded.", } return hints.get(status_code, "Unknown error – check logs for more info.") def get_tag_id_from_name(self, tag_name: str) -> int: """ Retrieve a tag ID by its name, create if not exists. Args: tag_name (str): Tag name. Returns: int: Tag ID. """ all_tags = self.get_all_tags() or [] tag_name = tag_name.lower() for tag in all_tags: if tag["label"] == tag_name: tag_id = tag["id"] return tag_id tag_id = self.create_tag(tag_name) return tag_id def get_all_tags(self) -> Optional[List[Dict[str, Any]]]: """ Get all tags from the ARR instance. Returns: Optional[List[Dict[str, Any]]]: List of tags. """ endpoint = f"{self.url}/api/v3/tag" return self.make_get_request(endpoint) def get_quality_profile_names(self) -> Optional[Dict[str, int]]: """ Get names and IDs of all quality profiles. Returns: Optional[Dict[str, int]]: Mapping of profile names to IDs. """ dict_of_names_and_ids: Dict[str, int] = {} endpoint = f"{self.url}/api/v3/qualityprofile" response = self.make_get_request(endpoint, headers=self.headers) if response: for profile in response: dict_of_names_and_ids[profile["name"]] = profile["id"] return dict_of_names_and_ids class RadarrClient(BaseARRClient): """Client for interacting with Radarr API.""" def __init__(self, url: str, api: str, logger: Any) -> None: """ Initialize the Radarr client. Args: url (str): API URL. api (str): API key. logger (Any): Logger instance. """ super().__init__(url, api, logger) self.instance_type = "Radarr" def get_media(self) -> Optional[List[Dict[str, Any]]]: """ Get all movies from Radarr. Returns: Optional[List[Dict[str, Any]]]: List of movies. """ endpoint = f"{self.url}/api/v3/movie" return self.make_get_request(endpoint) def add_tags(self, media_id: Union[int, List[int]], tag_id: int) -> Any: """ Add a tag to one or more movies. Args: media_id (Union[int, List[int]]): Movie ID(s). tag_id (int): Tag ID. Returns: Any: API response. """ if isinstance(media_id, int): media_id = [media_id] payload = {"movieIds": media_id, "tags": [tag_id], "applyTags": "add"} self.logger.debug(f"Add tag payload: {payload}") endpoint = f"{self.url}/api/v3/movie/editor" return self.make_put_request(endpoint, json=payload) def remove_tags(self, media_ids: List[int], tag_id: int) -> Any: """ Remove a tag from movies. Args: media_ids (List[int]): Movie IDs. tag_id (int): Tag ID. Returns: Any: API response. """ payload = {"movieIds": media_ids, "tags": [tag_id], "applyTags": "remove"} self.logger.debug(f"Remove tag payload: {payload}") endpoint = f"{self.url}/api/v3/movie/editor" return self.make_put_request(endpoint, json=payload) def get_rename_list(self, media_id: int) -> Any: """ Preview renaming for a movie. Args: media_id (int): Movie ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/rename?movieId={media_id}" return self.make_get_request(endpoint, headers=self.headers) def rename_media(self, media_ids: List[int]) -> Any: """ Trigger renaming of movies. Args: media_ids (List[int]): Movie IDs. Returns: Any: API response. """ payload = { "name": "RenameMovie", "movieIds": media_ids, } self.logger.debug(f"Rename payload: {payload}") endpoint = f"{self.url}/api/v3/command" return self.make_post_request(endpoint, json=payload) def rename_folders(self, media_ids: List[int], root_folder_path: str) -> Any: """ Rename folders for given movies. Args: media_ids (List[int]): Movie IDs. root_folder_path (str): Root folder path. Returns: Any: API response. """ payload = { "movieIds": media_ids, "moveFiles": True, "rootFolderPath": root_folder_path, } self.logger.debug(f"Rename Folder Payload: {payload}") endpoint = f"{self.url}/api/v3/movie/editor" return self.make_put_request(endpoint, json=payload) def refresh_items(self, media_ids: Union[int, List[int]]) -> Any: """ Refresh one or more movies. Args: media_ids (Union[int, List[int]]): Movie IDs. Returns: Any: API response. """ if isinstance(media_ids, int): media_ids = [media_ids] payload = {"name": "RefreshMovie", "movieIds": media_ids} self.logger.debug(f"Refresh payload: {payload}") endpoint = f"{self.url}/api/v3/command" return self.make_post_request(endpoint, headers=self.headers, json=payload) def refresh_media(self) -> Any: """ Refresh all movies. Returns: Any: API response. """ payload = { "name": "RefreshMovie", } self.logger.debug(f"Refresh payload: {payload}") endpoint = f"{self.url}/api/v3/command" return self.make_post_request(endpoint, headers=self.headers, json=payload) def search_media(self, media_ids: Union[int, List[int]]) -> Optional[Any]: """ Trigger a search for one or more movies. Args: media_ids (Union[int, List[int]]): Movie IDs. Returns: Optional[Any]: API response or None if search fails. """ self.logger.debug(f"Media ID: {media_ids}") endpoint = f"{self.url}/api/v3/command" payloads = [] if isinstance(media_ids, int): media_ids = [media_ids] payloads.append({"name": "MoviesSearch", "movieIds": media_ids}) self.logger.debug(f"Search payload: {payloads}") result = None for payload in payloads: result = self.make_post_request( endpoint, headers=self.headers, json=payload ) if result: return result else: self.logger.error(f"Search failed for media ID: {media_ids}") return None def get_movie_data(self, media_id: int) -> Any: """ Get movie file data for a specific movie. Args: media_id (int): Movie ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/moviefile?movieId={media_id}" return self.make_get_request(endpoint, headers=self.headers) def get_grab_history(self, media_id: int) -> Any: """ Get grab history for a movie. Args: media_id (int): Movie ID. Returns: Any: API response. """ url_addon = f"movie?movieId={media_id}&eventType=grabbed&includeMovie=false" endpoint = f"{self.url}/api/v3/history/{url_addon}" return self.make_get_request(endpoint, headers=self.headers) def get_import_history(self, media_id: int) -> Any: """ Get import history for a movie. Args: media_id (int): Movie ID. Returns: Any: API response. """ url_addon = f"movie?movieId={media_id}&eventType=downloadFolderImported&includeMovie=false" endpoint = f"{self.url}/api/v3/history/{url_addon}" return self.make_get_request(endpoint, headers=self.headers) def get_queue(self) -> Any: """ Get the current queue from Radarr. Returns: Any: API response. """ url_addon = "page=1&pageSize=200&includeMovie=true" endpoint = f"{self.url}/api/v3/queue?{url_addon}" return self.make_get_request(endpoint, headers=self.headers) def delete_media(self, media_id: int) -> Any: """ Delete a movie from Radarr. Args: media_id (int): Movie ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/movie/{media_id}" return self.make_delete_request(endpoint) def delete_movie_file(self, media_id: int) -> Any: """ Delete a movie file by file ID. Args: media_id (int): Movie file ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/moviefile/{media_id}" return self.make_delete_request(endpoint) def get_parsed_media(self, include_episode: bool = False) -> List[Dict[str, Any]]: """ Return a structured list of normalized movie items. Args: include_episode (bool): Ignored for Radarr. Returns: List[Dict[str, Any]]: List of normalized media entries. """ media_dict = [] media = self.get_media() if not media: return media_dict for item in media: file_id = item.get("movieFile", {}).get("id", None) alternate_titles = [t["title"] for t in item["alternateTitles"]] normalized_alternate_titles = [ normalize_titles(t["title"]) for t in item["alternateTitles"] ] if year_regex.search(item["title"]): title = year_regex.sub("", item["title"]) year = extract_year(item["title"]) else: title = item["title"] year = item["year"] reg = windows_path_regex.match(item["path"]) if reg and reg.group(1): folder = item["path"][item["path"].rfind("\\") + 1 :] else: folder = os.path.basename(os.path.normpath(item["path"])) media_dict.append( { "title": unidecode(html.unescape(title)), "year": year, "media_id": item["id"], "tmdb_id": item["tmdbId"], "imdb_id": item.get("imdbId", None), "monitored": item["monitored"], "status": item["status"], "root_folder": item["rootFolderPath"], "quality_profile": item["qualityProfileId"], "normalized_title": normalize_titles(item["title"]), "path_name": os.path.basename(item["path"]), "original_title": item.get("originalTitle", None), "secondary_year": item.get("secondaryYear", None), "alternate_titles": alternate_titles, "normalized_alternate_titles": normalized_alternate_titles, "file_id": file_id, "folder": folder, "normalized_folder": normalize_titles(folder), "has_file": item["hasFile"], "tags": item["tags"], "seasons": None, "season_numbers": None, } ) return media_dict class SonarrClient(BaseARRClient): """Client for interacting with Sonarr API.""" def __init__(self, url: str, api: str, logger: Any) -> None: """ Initialize the Sonarr client. Args: url (str): API URL. api (str): API key. logger (Any): Logger instance. """ super().__init__(url, api, logger) self.instance_type = "Sonarr" def get_media(self) -> Optional[List[Dict[str, Any]]]: """ Get all series from Sonarr. Returns: Optional[List[Dict[str, Any]]]: List of series. """ endpoint = f"{self.url}/api/v3/series" return self.make_get_request(endpoint) def add_tags(self, media_id: Union[int, List[int]], tag_id: int) -> Any: """ Add a tag to one or more series. Args: media_id (Union[int, List[int]]): Series ID(s). tag_id (int): Tag ID. Returns: Any: API response. """ if isinstance(media_id, int): media_id = [media_id] payload = {"seriesIds": media_id, "tags": [tag_id], "applyTags": "add"} self.logger.debug(f"Add tag payload: {payload}") endpoint = f"{self.url}/api/v3/series/editor" return self.make_put_request(endpoint, json=payload) def remove_tags(self, media_ids: List[int], tag_id: int) -> Any: """ Remove a tag from series. Args: media_ids (List[int]): Series IDs. tag_id (int): Tag ID. Returns: Any: API response. """ payload = {"seriesIds": media_ids, "tags": [tag_id], "applyTags": "remove"} self.logger.debug(f"Remove tag payload: {payload}") endpoint = f"{self.url}/api/v3/series/editor" return self.make_put_request(endpoint, json=payload) def get_rename_list(self, media_id: int) -> Any: """ Preview renaming for a series. Args: media_id (int): Series ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/rename?seriesId={media_id}" return self.make_get_request(endpoint, headers=self.headers) def rename_media(self, media_ids: List[int]) -> Any: """ Trigger renaming of series. Args: media_ids (List[int]): Series IDs. Returns: Any: API response. """ payload = { "name": "RenameSeries", "seriesIds": media_ids, } self.logger.debug(f"Rename payload: {payload}") endpoint = f"{self.url}/api/v3/command" return self.make_post_request(endpoint, json=payload) def rename_folders(self, media_ids: List[int], root_folder_path: str) -> Any: """ Rename folders for given series. Args: media_ids (List[int]): Series IDs. root_folder_path (str): Root folder path. Returns: Any: API response. """ payload = { "seriesIds": media_ids, "moveFiles": True, "rootFolderPath": root_folder_path, } self.logger.debug(f"Rename Folder Payload: {payload}") endpoint = f"{self.url}/api/v3/series/editor" return self.make_put_request(endpoint, json=payload) def refresh_items(self, media_ids: Union[int, List[int]]) -> Any: """ Refresh one or more series. Args: media_ids (Union[int, List[int]]): Series IDs. Returns: Any: API response. """ if isinstance(media_ids, int): media_ids = [media_ids] payload = {"name": "RefreshSeries", "seriesIds": media_ids} self.logger.debug(f"Refresh payload: {payload}") endpoint = f"{self.url}/api/v3/command" return self.make_post_request(endpoint, headers=self.headers, json=payload) def refresh_media(self) -> Any: """ Refresh all series. Returns: Any: API response. """ payload = { "name": "RefreshSeries", } self.logger.debug(f"Refresh payload: {payload}") endpoint = f"{self.url}/api/v3/command" return self.make_post_request(endpoint, headers=self.headers, json=payload) def search_media(self, media_ids: Union[int, List[int]]) -> Optional[Any]: """ Trigger a search for one or more series. Args: media_ids (Union[int, List[int]]): Series IDs. Returns: Optional[Any]: API response or None if search fails. """ self.logger.debug(f"Media ID: {media_ids}") endpoint = f"{self.url}/api/v3/command" payloads = [] if isinstance(media_ids, int): media_ids = [media_ids] for id in media_ids: payloads.append({"name": "SeriesSearch", "seriesId": id}) self.logger.debug(f"Search payload: {payloads}") result = None for payload in payloads: result = self.make_post_request( endpoint, headers=self.headers, json=payload ) if result: return result else: self.logger.error(f"Search failed for media ID: {media_ids}") return None def search_season(self, media_id: int, season_number: int) -> Any: """ Trigger a search for a specific season of a series. Args: media_id (int): Series ID. season_number (int): Season number. Returns: Any: API response. """ payload = { "name": "SeasonSearch", "seriesId": media_id, "SeasonNumber": season_number, } endpoint = f"{self.url}/api/v3/command" return self.make_post_request(endpoint, json=payload) def get_episode_data(self, media_id: int) -> Any: """ Get episode file data for a specific series. Args: media_id (int): Series ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/episodefile?seriesId={media_id}" return self.make_get_request(endpoint, headers=self.headers) def get_episode_data_by_season(self, media_id: int, season_number: int) -> Any: """ Get episode data for a specific season of a series. Args: media_id (int): Series ID. season_number (int): Season number. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/episode?seriesId={media_id}&seasonNumber={season_number}" return self.make_get_request(endpoint, headers=self.headers) def get_season_data(self, media_id: int) -> Any: """ Get all episode data for a specific series. Args: media_id (int): Series ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/episode?seriesId={media_id}" return self.make_get_request(endpoint, headers=self.headers) def delete_episode_file(self, episode_file_id: int) -> Any: """ Delete an episode file by file ID. Args: episode_file_id (int): Episode file ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/episodefile/{episode_file_id}" return self.make_delete_request(endpoint) def delete_episode_files(self, episode_file_ids: Union[int, List[int]]) -> Any: """ Delete multiple episode files by their IDs. Args: episode_file_ids (Union[int, List[int]]): Episode file IDs. Returns: Any: API response. """ if isinstance(episode_file_ids, int): episode_file_ids = [episode_file_ids] payload = {"episodeFileIds": episode_file_ids} self.logger.debug(f"Delete episode files payload: {payload}") endpoint = f"{self.url}/api/v3/episodefile/bulk" return self.make_delete_request(endpoint, payload) def search_episodes(self, episode_ids: List[int]) -> Any: """ Trigger a search for specific episodes. Args: episode_ids (List[int]): Episode IDs. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/command" payload = {"name": "EpisodeSearch", "episodeIds": episode_ids} self.logger.debug(f"Search payload: {payload}") return self.make_post_request(endpoint, json=payload) def get_grab_history(self, media_id: int) -> Any: """ Get grab history for a series. Args: media_id (int): Series ID. Returns: Any: API response. """ url_addon = f"series?seriesId={media_id}&eventType=grabbed&includeSeries=false&includeEpisode=false" endpoint = f"{self.url}/api/v3/history/{url_addon}" return self.make_get_request(endpoint, headers=self.headers) def get_import_history(self, media_id: int) -> Any: """ Get import history for a series. Args: media_id (int): Series ID. Returns: Any: API response. """ url_addon = f"series?seriesId={media_id}&eventType=downloadFolderImported&includeSeries=false&includeEpisode=false" endpoint = f"{self.url}/api/v3/history/{url_addon}" return self.make_get_request(endpoint, headers=self.headers) def get_season_grab_history(self, media_id: int, season: int) -> Any: """ Get grab history for a specific season of a series. Args: media_id (int): Series ID. season (int): Season number. Returns: Any: API response. """ url_addon = f"series?seriesId={media_id}&seasonNumber={season}&eventType=grabbed&includeSeries=false&includeEpisode=false" endpoint = f"{self.url}/api/v3/history/{url_addon}" return self.make_get_request(endpoint, headers=self.headers) def get_season_import_history(self, media_id: int, season: int) -> Any: """ Get import history for a specific season of a series. Args: media_id (int): Series ID. season (int): Season number. Returns: Any: API response. """ url_addon = f"series?seriesId={media_id}&seasonNumber={season}&eventType=downloadFolderImported&includeSeries=false&includeEpisode=false" endpoint = f"{self.url}/api/v3/history/{url_addon}" return self.make_get_request(endpoint, headers=self.headers) def get_queue(self) -> Any: """ Get the current queue from Sonarr. Returns: Any: API response. """ url_addon = "page=1&pageSize=200&includeSeries=true" endpoint = f"{self.url}/api/v3/queue?{url_addon}" return self.make_get_request(endpoint, headers=self.headers) def delete_media(self, media_id: int) -> Any: """ Delete a series from Sonarr. Args: media_id (int): Series ID. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/series/{media_id}" return self.make_delete_request(endpoint) def get_parsed_media(self, include_episode: bool = False) -> List[Dict[str, Any]]: """ Return a structured list of normalized series items. Args: include_episode (bool): If True, include episode-level metadata. Returns: List[Dict[str, Any]]: List of normalized media entries. """ media_dict = [] media = self.get_media() if not media: return media_dict for item in media: season_data = item.get("seasons", []) season_list = [] for season in season_data: if include_episode: episode_data = self.get_episode_data_by_season( item["id"], season["seasonNumber"] ) episode_list = [ { "episode_number": ep["episodeNumber"], "monitored": ep["monitored"], "episode_file_id": ep["episodeFileId"], "episode_id": ep["id"], "has_file": ep["hasFile"], } for ep in episode_data ] else: episode_list = [] try: status = ( season["statistics"]["episodeCount"] == season["statistics"]["totalEpisodeCount"] ) except Exception: status = False try: season_stats = season["statistics"]["episodeCount"] except Exception: season_stats = 0 season_list.append( { "season_number": season["seasonNumber"], "monitored": season["monitored"], "season_pack": status, "season_has_episodes": season_stats, "episode_data": episode_list, } ) alternate_titles = [t["title"] for t in item["alternateTitles"]] normalized_alternate_titles = [ normalize_titles(t["title"]) for t in item["alternateTitles"] ] if year_regex.search(item["title"]): title = year_regex.sub("", item["title"]) year = extract_year(item["title"]) else: title = item["title"] year = item["year"] reg = windows_path_regex.match(item["path"]) if reg and reg.group(1): folder = item["path"][item["path"].rfind("\\") + 1 :] else: folder = os.path.basename(os.path.normpath(item["path"])) media_dict.append( { "title": unidecode(html.unescape(title)), "year": year, "media_id": item["id"], "tvdb_id": item["tvdbId"], "imdb_id": item.get("imdbId", None), "monitored": item["monitored"], "status": item["status"], "root_folder": item["rootFolderPath"], "quality_profile": item["qualityProfileId"], "normalized_title": normalize_titles(item["title"]), "path_name": os.path.basename(item["path"]), "original_title": item.get("originalTitle", None), "secondary_year": item.get("secondaryYear", None), "alternate_titles": alternate_titles, "normalized_alternate_titles": normalized_alternate_titles, "file_id": None, "folder": folder, "normalized_folder": normalize_titles(folder), "has_file": None, "tags": item["tags"], "seasons": season_list, "season_numbers": [s["season_number"] for s in season_list], } ) return media_dict def refresh_queue(self) -> Any: """ Refresh the queue in Sonarr. Returns: Any: API response. """ endpoint = f"{self.url}/api/v3/command" payload = {"name": "RefreshMonitoredDownloads"} self.logger.debug(f"Refresh queue payload: {payload}") return self.make_post_request(endpoint, json=payload) def remove_item_from_queue(self, queue_ids: Union[int, List[int]]) -> Any: """ Remove an item or items from the queue. Args: queue_ids (Union[int, List[int]]): Queue item IDs. Returns: Any: API response. """ if isinstance(queue_ids, int): queue_ids = [queue_ids] payload = {"ids": queue_ids} endpoint = f"{self.url}/api/v3/queue/bulk?removeFromClient=false&blocklist=false&skipRedownload=false&changeCategory=false" return self.make_delete_request(endpoint, payload) def create_arr_client( url: str, api: str, logger: Any ) -> Optional[Union[RadarrClient, SonarrClient]]: """ Factory to create a Radarr or Sonarr client. Args: url (str): API URL. api (str): API key. logger (Any): Logger instance. Returns: Optional[Union[RadarrClient, SonarrClient]]: The client or None on failure. """ class SilentLogger: def debug(self, *args, **kwargs): pass def info(self, *args, **kwargs): pass def warning(self, *args, **kwargs): pass def error(self, *args, **kwargs): pass temp = BaseARRClient(url, api, SilentLogger()) if not temp.connect_status: return None if temp.app_name == "Radarr": return RadarrClient(url, api, logger) if temp.app_name == "Sonarr": return SonarrClient(url, api, logger) logger.error("Unknown ARR type") return None ================================================ FILE: util/assets.py ================================================ import datetime import os from typing import Any, Dict, List, Optional, Tuple from util.construct import generate_title_variants from util.index import build_search_index, create_new_empty_index, search_matches from util.match import is_match from util.normalization import normalize_file_names from util.scanner import process_files from util.utility import progress def get_assets_files( source_dirs: str | List[str], logger: Optional[Any], merge: bool = True, ) -> Tuple[Optional[List[Dict]], Optional[Dict[str, Any]]]: """Process one or more directories to extract and organize media assets. Args: source_dirs (str or List[str]): One or more paths to media source directories. merge (bool): Whether to merge/deduplicate assets by content and title. logger (Any, optional): Logger instance for debug/info messages. Returns: Tuple[Optional[List[Dict]], Optional[Dict[str, Any]]]: A tuple containing a flat asset list and a search index. """ if isinstance(source_dirs, str): source_dirs = [source_dirs] final_assets: List[Dict] = [] prefix_index: Dict[str, Any] = create_new_empty_index() start_time = datetime.datetime.now() for source_dir in source_dirs: new_assets = process_files(source_dir, logger) if new_assets: if merge: merge_assets(new_assets, final_assets, prefix_index, logger) else: for asset in new_assets: asset["files"].sort() final_assets.append(asset) build_search_index(prefix_index, asset["title"], asset, logger) end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() items_per_second = len(source_dirs) / elapsed_time if elapsed_time > 0 else 0 if logger: logger.debug( f"Processed {len(source_dirs)} source directories in {elapsed_time:.2f} seconds " f"({items_per_second:.2f} items/s)" ) if not final_assets: if logger: logger.warning( f"No valid files were found in any of the source directories: {source_dirs}" ) return None, None return final_assets, prefix_index def merge_assets( new_assets: List[Dict], final_assets: List[Dict], prefix_index: Dict, logger: Any ) -> None: """Merge new asset entries into the final asset list, collapsing duplicates, handling upgrades, and indexing. Args: new_assets (List[Dict]): List of new asset dictionaries. final_assets (List[Dict]): List to append/merge assets into. prefix_index (Dict): Index for fast search/lookup. logger (Any): Logger instance. """ with progress( new_assets, desc="Processing assets", total=len(new_assets), unit="asset", logger=logger, leave=False, ) as pbar: for new in pbar: search_matched_assets = search_matches(prefix_index, new["title"], logger) merged = False for final in search_matched_assets: new_dirs = {os.path.dirname(f) for f in new["files"]} final_dirs = {os.path.dirname(f) for f in final["files"]} if new_dirs & final_dirs: continue is_matched, reason = is_match(final, new) if is_matched and ( final["type"] == new["type"] or final.get("season_numbers") or new.get("season_numbers") ): if new.get("season_numbers") or final.get("season_numbers"): final["type"] = "series" pre_files = list(final["files"]) upgrades = [] for new_file in new["files"]: normalized_new_file = normalize_file_names( os.path.basename(new_file) ) for final_file in final["files"]: normalized_final_file = normalize_file_names( os.path.basename(final_file) ) if final.get("type") == "collections": final_base = os.path.splitext( os.path.basename(final_file) )[0] final_file_variants = generate_title_variants( final_base )["normalized_alternate_titles"] if normalized_final_file == normalized_new_file or ( final.get("type") == "collections" and normalized_new_file in final_file_variants ): final["files"].remove(final_file) final["files"].append(new_file) upgrades.append((final_file, new_file)) break else: final["files"].append(new_file) new_season_numbers = new.get("season_numbers") if new_season_numbers: final_season_numbers = final.get("season_numbers") if final_season_numbers: final["season_numbers"] = list( set(final_season_numbers + new_season_numbers) ) else: final["season_numbers"] = new_season_numbers final["files"].sort() for key in ["tmdb_id", "tvdb_id", "imdb_id"]: if not final.get(key) and new.get(key): final[key] = new[key] post_files = list(final["files"]) src_parent = os.path.basename(os.path.dirname(new["files"][0])) reason_str = f" Reason: {reason}." files_str = f" Files: {len(pre_files)} → {len(post_files)}" pre_basenames = {os.path.basename(f): f for f in pre_files} post_basenames = {os.path.basename(f): f for f in post_files} new_basenames = {os.path.basename(f): f for f in new["files"]} upgrade_lines = [] for pre_base, pre_full in pre_basenames.items(): if pre_base in new_basenames: new_full = new_basenames[pre_base] pre_dir = os.path.basename(os.path.dirname(pre_full)) new_dir = os.path.basename(os.path.dirname(new_full)) if pre_full != new_full: upgrade_lines.append( f" - Replaced: {pre_base} [{pre_dir}]\n" f" → {os.path.basename(new_full)} [{new_dir}]" ) for post_base, post_full in post_basenames.items(): if post_base not in pre_basenames: post_dir = os.path.basename(os.path.dirname(post_full)) upgrade_lines.append( f" - Added: {post_base} [{post_dir}]" ) logger.debug( f"[MERGE] '{final['title']}' ({final['type']}) from [{src_parent}]\n" f"{reason_str}\n" f"{files_str}\n" + ("\n".join(upgrade_lines) if upgrade_lines else "") ) merged = True break if not merged: new["files"].sort() final_assets.append(new) build_search_index(prefix_index, new["title"], new, logger) src_parent = os.path.basename(os.path.dirname(new["files"][0])) logger.debug( f"[ADD] New asset '{new['title']}' ({new['type']}), {len(new['files'])} file(s), from {src_parent}" ) ================================================ FILE: util/config.py ================================================ import json import os import pathlib import sys from copy import deepcopy from types import SimpleNamespace from typing import Any, Dict, List, Tuple import yaml from util.logger import Logger class Config: """Manages loading and accessing configuration for a given module.""" def __init__(self, module_name: str) -> None: """ Initialize Config with module name. Args: module_name (str): Name of the module requesting configuration. """ self.config_path: str = config_file_path self.module_name: str = module_name self.load_config() def load_config(self) -> None: """ Load the YAML configuration and set attributes for scheduler, discord, notifications, instances, and module config. """ try: config = load_user_config(self.config_path) except Exception: return self._config = config if "schedule" not in config: print( "[CONFIG] Warning: 'schedule' key missing in config; defaulting to empty schedule" ) self.scheduler = config.get("schedule", {}) self.discord = config.get("discord", {}) self.notifications = config.get("notifications", []) if "instances" not in config: sys.stderr.write( f"[CONFIG] Missing 'instances' key! Config keys: {list(config.keys())}\n" ) self.instances_config = config.get("instances", {}) if self.module_name: self.module_config = self._config.get(self.module_name, {}) self.module_config = SimpleNamespace(**self.module_config) self.module_config.module_name = self.module_name module_notifications = self._config.get("notifications", {}).get( self.module_name, {} ) setattr(self.module_config, "notifications", module_notifications) return def load_user_config(path: str) -> Dict[str, Any]: """ Load YAML configuration from the specified file path. Args: path (str): Path to the YAML configuration file. Returns: dict: Parsed configuration dictionary, or empty dict if file is missing or invalid. """ try: with open(path, "r") as f: raw = f.read() data = yaml.safe_load(raw) return data or {} except FileNotFoundError: sys.stderr.write("[CONFIG] config file not found\n") return {} except yaml.YAMLError as e: sys.stderr.write(f"[CONFIG] Error parsing config file: {e}\n") print(f"Error parsing config file: {e}") return {} TEMPLATE_PATH = pathlib.Path(__file__).parent / "template" / "config_template.json" if os.environ.get("DOCKER_ENV"): config_dir = os.getenv("CONFIG_DIR", "/config") config_file_path = os.path.join(config_dir, "config.yml") else: config_dir = pathlib.Path(__file__).parents[1] / "config" config_dir.mkdir(parents=True, exist_ok=True) config_file_path = config_dir / "config.yml" if not os.path.exists(config_file_path): from json import load as _json_load with open(TEMPLATE_PATH, "r") as tf: default_cfg = _json_load(tf) with open(config_file_path, "w") as wf: yaml.safe_dump(default_cfg, wf, sort_keys=False) def _reconcile_config_data( template_data: Dict[str, Any], user_data: Dict[str, Any] ) -> Tuple[Dict[str, Any], List[str], List[str]]: """ Recursively reconcile user configuration with a template. Args: template_data (dict): Template configuration dictionary. user_data (dict): User configuration dictionary. Returns: Tuple containing reconciled dictionary, list of added keys, and list of removed keys. """ reconciled_dict: Dict[str, Any] = {} added_keys: List[str] = [] removed_keys: List[str] = [] for key, template_value in template_data.items(): if key in user_data: user_value = user_data[key] if isinstance(template_value, dict): if isinstance(user_value, dict): if not template_value: reconciled_dict[key] = deepcopy(user_value) else: rec, add, rem = _reconcile_config_data( template_value, user_value ) reconciled_dict[key] = rec added_keys.extend([f"{key}.{k}" for k in add]) removed_keys.extend([f"{key}.{k}" for k in rem]) else: reconciled_dict[key] = deepcopy(template_value) else: reconciled_dict[key] = user_value else: reconciled_dict[key] = deepcopy(template_value) added_keys.append(key) for key in user_data.keys(): if key not in template_data: removed_keys.append(key) return reconciled_dict, added_keys, removed_keys def manage_config(logger: Logger) -> None: """ Update user's config.yml based on config_template.json. Logs keys that are added or removed. Args: logger (Logger): Logger instance for logging messages. """ global TEMPLATE_PATH, config_file_path try: with open(TEMPLATE_PATH, "r", encoding="utf-8") as f: template_data = json.load(f) except FileNotFoundError: logger.error( f"[CONFIG] Template configuration file not found at {TEMPLATE_PATH}" ) return except json.JSONDecodeError as e: logger.error( f"[CONFIG] Could not parse template configuration file {TEMPLATE_PATH}: {e}" ) return user_data: Dict[str, Any] = {} if os.path.exists(config_file_path): try: with open(config_file_path, "r", encoding="utf-8") as f: user_data = yaml.safe_load(f) or {} except yaml.YAMLError as e: logger.error( f"[CONFIG] Could not parse user configuration file {config_file_path}: {e}" ) logger.warning( "[CONFIG] Proceeding with an empty user configuration for reconciliation." ) if not isinstance(user_data, dict): logger.warning( f"User configuration at {config_file_path} is not a dictionary. Treating as empty." ) user_data = {} reconciled_data, added_keys, removed_keys = _reconcile_config_data( template_data, user_data ) try: with open(config_file_path, "w", encoding="utf-8") as f: yaml.safe_dump( reconciled_data, f, sort_keys=False, indent=2, default_flow_style=False, allow_unicode=True, ) logger.info( f"[CONFIG] Configuration file {config_file_path} updated successfully based on template." ) if added_keys: logger.info(f"[CONFIG] Keys ADDED to config: {added_keys}") if removed_keys: logger.info(f"[CONFIG] Keys REMOVED from config: {removed_keys}") except IOError as e: logger.error( f"[CONFIG] Could not write to configuration file {config_file_path}: {e}" ) ================================================ FILE: util/constants.py ================================================ import re from typing import List, Pattern, Set # Matches suffixes like " - Season X" or "_SeasonX" (where X is 1–4 digits), as well as "- Specials" or "_Specials" (case-insensitive) season_pattern: Pattern = re.compile( r"(?:\s*-\s*Season\s*\d+|_Season\d{1,4}|\s*-\s*Specials|_Specials)", re.IGNORECASE ) # 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 season_number_regex: Pattern = re.compile( r"(?:[-\s_]+)?Season\s*(\d{1,4})", re.IGNORECASE ) # 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 season_regex: str = r"Season (\d{1,4})" # Matches strings like "E01" or "e5", capturing 1–2 digits as the episode number episode_regex: str = r"(?:E|e)(\d{1,2})" # Matches any text (group 1) followed by a space and a 4-digit year in parentheses (group 2), e.g. "Movie Title (2022)" folder_year_regex: Pattern = re.compile(r"(.*)\s\((\d{4})\)") # 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 year_regex: Pattern = re.compile(r"\s?\((\d{4})\)(?!.*Collection).*") # Matches one or more illegal filename characters—including < > : " / \ | ? * and control characters U+0000–U+001F illegal_chars_regex: Pattern = re.compile(r"[<>:\"/\\|?*\x00-\x1f]+") # Matches one or more characters that are not letters (A–Z, a–z), digits (0–9), or whitespace, i.e., special symbols and punctuation remove_special_chars: Pattern = re.compile(r"[^a-zA-Z0-9\s]+") # 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 title_regex: str = r".*\/([^/]+)\s\((\d{4})\).*" # matches "tmdbid 12345" or "tmdb-12345" or "tmdb_12345" or "tmdb 12345" tmdb_id_regex = re.compile(r"(?i)\btmdb(?:id|[-_\s])(\d+)\b") # matches "tvdbid 67890" or "tvdb-67890" or "tvdb_67890" or "tvdb 67890" tvdb_id_regex = re.compile(r"(?i)\btvdb(?:id|[-_\s])(\d+)\b") # Matches strings like "imdb-tt1234567", "imdb_tt1234567", or "imdb tt1234567", capturing the "tt" plus digits as the IMDb ID imdb_id_regex: Pattern = re.compile(r"imdb[-_\s](tt\d+)") # Matches the start of a Windows drive path like "C:\" or "D:\", capturing the drive letter and colon windows_path_regex: Pattern = re.compile(r"^([A-Z]:\\)") # Remove curly‐brace blocks containing TMDB, TVDB, or IMDb IDs id_content_regex = re.compile( r"\s*\{\s*(?:" r"tmdb(?:[-_\s]\d+)|" r"tvdb(?:[-_\s]\d+)|" r"imdb(?:[-_\s](?:tt)?\d+)" r")\s*\}", flags=re.IGNORECASE, ) words_to_remove: List[str] = [ "(US)", "(UK)", "(AU)", "(CA)", "(NZ)", "(FR)", "(NL)", "DC's", ] common_words: Set[str] = {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to"} prefixes: List[str] = [ "The", "A", "An", ] suffixes: List[str] = [ "Collection", "Saga", ] ================================================ FILE: util/construct.py ================================================ import os import re from typing import Any, Dict, List, Optional from util.constants import prefixes, season_number_regex, suffixes from util.normalization import normalize_titles def generate_title_variants(title: str) -> Dict[str, List[str]]: """Generate alternate and normalized title variants for a given media title. Args: title (str): The original media title. Returns: Dict[str, List[str]]: Dictionary with 'alternate_titles' and 'normalized_alternate_titles' keys. """ stripped_prefix = next( (title[len(p) + 1 :].strip() for p in prefixes if title.startswith(p + " ")), title, ) stripped_suffix = next( (title[: -(len(s) + 1)].strip() for s in suffixes if title.endswith(" " + s)), title, ) stripped_both = next( ( stripped_prefix[: -(len(s) + 1)].strip() for s in suffixes if stripped_prefix.endswith(" " + s) ), stripped_prefix, ) alternate_titles = [stripped_prefix, stripped_suffix, stripped_both] if not title.lower().endswith("collection"): alternate_titles.append(f"{title} Collection") normalized_alternate_titles = [normalize_titles(alt) for alt in alternate_titles] alternate_titles = list(dict.fromkeys(alternate_titles)) normalized_alternate_titles = list(dict.fromkeys(normalized_alternate_titles)) return { "alternate_titles": alternate_titles, "normalized_alternate_titles": normalized_alternate_titles, } def create_collection( title: str, tmdb_id: int, normalized_title: str, files: List[str], parent_folder: Optional[str] = None, media_folder: Optional[str] = None, ) -> Dict[str, Any]: """Construct a standardized dictionary representing a collection entry. Args: title (str): Display title of the collection. tmdb_id (int): TMDB identifier. normalized_title (str): Normalized version of the title. files (List[str]): Associated media file paths. parent_folder (Optional[str]): Folder containing the files. Returns: Dict[str, Any]: Dictionary with metadata fields for a collection. """ variants = generate_title_variants(title) return { "type": "collections", "title": title, "year": None, "normalized_title": normalized_title, "files": [files[-1]], "alternate_titles": variants["alternate_titles"], "normalized_alternate_titles": variants["normalized_alternate_titles"], "tmdb_id": tmdb_id, "folder": parent_folder, "media_folder": media_folder, } def create_series( title: str, year: Optional[int], tvdb_id: Optional[int], imdb_id: Optional[str], normalized_title: str, files: List[str], parent_folder: Optional[str] = None, media_folder: Optional[str] = None, ) -> Dict[str, Any]: """Construct a standardized dictionary representing a series entry. Args: title (str): Series title. year (Optional[int]): Release year of the series. tvdb_id (Optional[int]): TVDB identifier. imdb_id (Optional[str]): IMDB identifier. normalized_title (str): Normalized version of the title. files (List[str]): List of associated media file paths. parent_folder (Optional[str]): Folder containing the files. Returns: Dict[str, Any]: Dictionary with metadata fields for a series. """ season_numbers_dict = {} series_poster = None for file_path in files: base = os.path.basename(file_path) if "Specials" in base: season_numbers_dict[0] = file_path else: match = re.search(season_number_regex, base) if match: season_numbers_dict[int(match.group(1))] = file_path else: series_poster = file_path season_numbers = sorted(season_numbers_dict.keys()) final_files = list(season_numbers_dict.values()) if series_poster: final_files.append(series_poster) return { "type": "series", "title": title, "year": year, "tvdb_id": tvdb_id, "imdb_id": imdb_id, "normalized_title": normalized_title, "files": final_files, "season_numbers": season_numbers, "folder": parent_folder, "media_folder": media_folder, } def create_movie( title: str, year: Optional[int], tmdb_id: Optional[int], imdb_id: Optional[str], normalized_title: str, files: List[str], parent_folder: Optional[str] = None, media_folder: Optional[str] = None, ) -> Dict[str, Any]: """Construct a standardized dictionary representing a movie entry. Args: title (str): Movie title. year (Optional[int]): Release year of the movie. tmdb_id (Optional[int]): TMDB identifier. imdb_id (Optional[str]): IMDB identifier. normalized_title (str): Normalized version of the title. files (List[str]): List of associated media file paths. parent_folder (Optional[str]): Folder containing the files. Returns: Dict[str, Any]: Dictionary with metadata fields for a movie. """ return { "type": "movies", "title": title, "year": year, "tmdb_id": tmdb_id, "imdb_id": imdb_id, "normalized_title": normalized_title, "files": [files[-1]], "folder": parent_folder, "media_folder": media_folder, } ================================================ FILE: util/extract.py ================================================ from typing import Optional, Tuple from util.constants import imdb_id_regex, tmdb_id_regex, tvdb_id_regex, year_regex def extract_year(text: str) -> Optional[int]: """Extract the first 4-digit year from text. Args: text: Input string to search for a year. Returns: The extracted year as an integer, or None if not found. """ try: return int(year_regex.search(text).group(1)) except Exception: return None def extract_ids(text: str) -> Tuple[Optional[int], Optional[int], Optional[str]]: """Extract TMDB, TVDB, and IMDB IDs from text. Args: text: Input string containing IDs. Returns: Tuple of TMDB ID (int or None), TVDB ID (int or None), IMDB ID (str or None). """ tmdb_match = tmdb_id_regex.search(text) tmdb = int(tmdb_match.group(1)) if tmdb_match else None tvdb_match = tvdb_id_regex.search(text) tvdb = int(tvdb_match.group(1)) if tvdb_match else None imdb_match = imdb_id_regex.search(text) imdb = imdb_match.group(1) if imdb_match else None return tmdb, tvdb, imdb ================================================ FILE: util/index.py ================================================ from typing import Any, Dict, List, Optional from util.constants import common_words from util.normalization import normalize_titles Asset = Dict[str, Any] PrefixIndex = Dict[str, List[Asset]] prefix_length: int = 3 def create_new_empty_index() -> PrefixIndex: """Create and return an empty search index structure. Returns: PrefixIndex: An empty dictionary. """ return {} def remove_common_words(text: str) -> str: """Remove any word that matches an entry in common_words (case-insensitive). Only removes complete words, does not touch substrings or special characters. Args: text (str): Input text to filter. Returns: str: Text with common words removed. """ words = text.split() filtered = [ word for word in words if word.lower() not in {w.lower() for w in common_words} ] return " ".join(filtered) def build_search_index( prefix_index: PrefixIndex, title: str, asset: Asset, logger: Optional[Any], debug_items: Optional[List[str]] = None, ) -> None: """Populate the search index with normalized forms of the asset title and TMDB/TVDB IDs. Args: prefix_index (PrefixIndex): The overall index to update. title (str): Original title to normalize and index. asset (Asset): Dictionary containing asset metadata. logger (Optional[Any]): Logger instance for debug output. debug_items (Optional[List[str]]): List of normalized titles to enable debug logging on. """ title = remove_common_words(title) processed = normalize_titles(title) debug_build_index = bool( debug_items and len(debug_items) > 0 and processed in debug_items ) if debug_build_index and logger: logger.info("debug_build_search_index") logger.info(processed) logger.info(asset) if asset.get("tmdb_id"): key = f"tmdb:{asset['tmdb_id']}" prefix_index.setdefault(key, []).append(asset) if debug_build_index and logger: logger.info(f"Indexed by {key}") if asset.get("tvdb_id"): key = f"tvdb:{asset['tvdb_id']}" prefix_index.setdefault(key, []).append(asset) if debug_build_index and logger: logger.info(f"Indexed by {key}") words = processed.split() if debug_build_index and logger: logger.info(words) for word in words: prefix_index.setdefault(word, []).append(asset) if len(word) > prefix_length: prefix = word[:prefix_length] if debug_build_index and logger: logger.info(prefix) prefix_index.setdefault(prefix, []).append(asset) break def search_matches( prefix_index: PrefixIndex, title: str, logger: Optional[Any], tmdb_id: Optional[int] = None, tvdb_id: Optional[int] = None, ) -> List[Asset]: """Search for matching assets in the index. If a TMDB or TVDB ID is provided, search strictly by that ID and return results. Only perform title-based search if neither ID is provided. Args: prefix_index (PrefixIndex): The populated search index. title (str): The title to search for. logger (Optional[Any]): Logger instance for optional logging. tmdb_id (Optional[int]): TMDB ID for direct lookup. tvdb_id (Optional[int]): TVDB ID for direct lookup. Returns: List[Asset]: List of matching assets from the index. """ if tmdb_id is not None: key = f"tmdb:{tmdb_id}" filtered_assets = [ a for a in prefix_index.get(key, []) if a.get("tmdb_id") == tmdb_id ] return filtered_assets if tvdb_id is not None: key = f"tvdb:{tvdb_id}" filtered_assets = [ a for a in prefix_index.get(key, []) if a.get("tvdb_id") == tvdb_id ] return filtered_assets title = remove_common_words(title) processed_title = normalize_titles(title) words = processed_title.split() matches: List[Asset] = [] for word in words: if len(word) > prefix_length: prefix = word[:prefix_length] if prefix in prefix_index: matches.extend(prefix_index[prefix]) return matches if word in prefix_index: matches.extend(prefix_index[word]) break return matches ================================================ FILE: util/logger.py ================================================ import builtins import logging import os import sys from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Optional from util.utility import create_bar from util.version import get_version class Logger: """Logger with file rotation, console output, and versioned header.""" def __init__(self, log_level: str, module_name: str, max_logs: int = 9): """Set up file and console logging handlers and emit versioned header.""" log_base = os.getenv("LOG_DIR") if log_base: log_dir = Path(log_base) / module_name else: log_dir = Path(__file__).resolve().parents[1] / "logs" / module_name os.makedirs(log_dir, exist_ok=True) base = os.path.join(log_dir, module_name) log_file = f"{base}.log" if os.path.isfile(log_file): for i in range(max_logs - 1, 0, -1): old = f"{base}.{i}.log" new = f"{base}.{i + 1}.log" if os.path.exists(old): os.rename(old, new) os.rename(log_file, f"{base}.1.log") self._logger = logging.getLogger(f"{module_name}_{os.getpid()}") self._logger.handlers.clear() self._logger.propagate = False self._logger.setLevel(getattr(logging, log_level.lower().upper(), logging.INFO)) formatter = logging.Formatter( fmt="%(asctime)s %(levelname)s: %(message)s", datefmt="%m/%d/%y %I:%M:%S %p" ) file_handler = RotatingFileHandler(log_file, mode="w", backupCount=max_logs) file_handler.setFormatter(formatter) self._logger.addHandler(file_handler) if module_name == "main" or os.environ.get("LOG_TO_CONSOLE", "").lower() in ( "1", "true", "yes", ): console = logging.StreamHandler() console.setLevel(self._logger.level) console.addFilter(lambda record: record.levelno < logging.ERROR) console.setFormatter(logging.Formatter("%(message)s")) self._logger.addHandler(console) error_console = logging.StreamHandler() error_console.setLevel(logging.ERROR) error_console.setFormatter( logging.Formatter(f"%(levelname)s [{module_name}]: %(message)s") ) self._logger.addHandler(error_console) if not hasattr(logging, log_level.lower().upper()): self._logger.warning(f"Invalid log level '{log_level}', defaulting to INFO") version = get_version() self.start_time = datetime.now() self._logger.start_time = self.start_time self._logger.info( create_bar(f"{module_name.replace('_', ' ').upper()} Version: {version}") ) def log_outro(self) -> None: """Log runtime duration since start_time.""" start = getattr(self, "start_time", None) if start is None: return duration = datetime.now() - start hours, remainder = divmod(duration.total_seconds(), 3600) minutes, seconds = divmod(remainder, 60) formatted_duration = f"{int(hours)}h {int(minutes)}m {int(seconds)}s" module_name = self._logger.name.rsplit("_", 1)[0].replace("_", " ").upper() self._logger.info(create_bar(f"{module_name} | Run Time: {formatted_duration}")) def __getattr__(self, name): return getattr(self._logger, name) _orig_print = builtins.print def _print(*args: object, file: Optional[object] = None, **kwargs: object) -> None: """Custom print respecting LOG_TO_CONSOLE for stdout; always allow stderr.""" target = file if file is not None else sys.stdout log_console = os.environ.get("LOG_TO_CONSOLE", "").lower() in ("1", "true", "yes") if target in (sys.stderr, sys.__stderr__): _orig_print(*args, file=target, **kwargs) return if target in (sys.stdout, sys.__stdout__): if log_console: _orig_print(*args, file=target, **kwargs) return _orig_print(*args, file=target, **kwargs) builtins.print = _print ================================================ FILE: util/match.py ================================================ import os import re import time from types import SimpleNamespace from typing import Any, Dict, List, Optional, Tuple from util.constants import folder_year_regex, season_pattern from util.index import search_matches from util.normalization import normalize_titles from util.utility import progress def compare_strings(string1: str, string2: str) -> bool: """Loosely compare two strings by removing non-alphanumeric characters and comparing lowercase.""" string1 = re.sub(r"\W+", "", string1) string2 = re.sub(r"\W+", "", string2) return string1.lower() == string2.lower() def is_match( asset: Dict[str, Any], media: Dict[str, Any], strict_folder_match: bool = False, ) -> Tuple[bool, str]: """Determine if a media entry and an asset match based on ID, title, and year heuristics. Args: asset: Asset dictionary. media: Media dictionary. strict_folder_match: Only consider match if asset's folder matches media's folder. Returns: Tuple of (True, reason) if matched, else (False, ""). """ if media.get("folder"): folder_base_name = os.path.basename(media["folder"]) match = re.search(folder_year_regex, folder_base_name) if match: media["folder_title"], media["folder_year"] = match.groups() media["folder_year"] = ( int(media["folder_year"]) if media["folder_year"] else None ) media["normalized_folder_title"] = normalize_titles(media["folder_title"]) def year_matches() -> bool: asset_year = asset.get("year") media_years = [ media.get(key) for key in ["year", "secondary_year", "folder_year"] ] if asset_year is None and all(year is None for year in media_years): return True return any(asset_year == year for year in media_years if year is not None) def has_any_valid_id(d: Dict[str, Any]) -> bool: for k in ["tmdb_id", "tvdb_id", "imdb_id"]: v = d.get(k) if k == "imdb_id": if v and isinstance(v, str) and v.startswith("tt"): return True else: if v and str(v).isdigit() and int(v) > 0: return True return False has_asset_ids = has_any_valid_id(asset) has_media_ids = has_any_valid_id(media) if strict_folder_match: match_criteria = [ ( asset.get("media_folder") == media.get("folder"), "Asset folder equals media folder (media_folder)", ), ( asset.get("folder") == media.get("folder"), "Asset folder equals media folder (folder)", ), ] for condition, reason in match_criteria: if condition and year_matches(): return True, reason return False, "" if has_asset_ids and has_media_ids: id_match_criteria = [ ( media.get("tvdb_id") and asset.get("tvdb_id") and media["tvdb_id"] == asset["tvdb_id"], "ID match: tvdb_id", ), ( media.get("tmdb_id") and asset.get("tmdb_id") and media["tmdb_id"] == asset["tmdb_id"], "ID match: tmdb_id", ), ( media.get("imdb_id") and asset.get("imdb_id") and media["imdb_id"] == asset["imdb_id"], "ID match: imdb_id", ), ] for matched, reason in id_match_criteria: if matched: return True, reason return False, "" match_criteria = [ (asset.get("title") == media.get("title"), "Asset title equals media title"), ( asset.get("title") in media.get("alternate_titles", []), "Asset title found in media's alternate titles", ), (asset.get("title") == media.get("folder"), "Asset title equals media folder"), ( asset.get("title") == media.get("original_title"), "Asset title equals media original title", ), ( asset.get("normalized_title") == media.get("normalized_title"), "Asset normalized title equals media normalized title", ), ( asset.get("normalized_title") == media.get("normalized_folder"), "Asset normalized title equals media folder normalized", ), ( asset.get("normalized_title") in media.get("normalized_alternate_titles", []), "Asset normalized title found in media's normalized alternate titles", ), ( any( assets == media.get("title") for assets in asset.get("alternate_titles", []) ), "One of asset's alternate_titles matches media title", ), ( any( assets == media.get("normalized_title") for assets in asset.get("normalized_alternate_titles", []) ), "One of asset's normalized_alternate_titles matches media normalized title", ), ( any( media_alt == asset.get("title") for media_alt in media.get("alternate_titles", []) ), "One of media's alternate_titles matches asset title", ), ( any( media_alt == asset.get("normalized_title") for media_alt in media.get("normalized_alternate_titles", []) ), "One of media's normalized_alternate_titles matches asset normalized title", ), ( compare_strings(media.get("title", ""), asset.get("title", "")), "Titles match under loose string comparison", ), ( compare_strings( media.get("normalized_title", ""), asset.get("normalized_title", "") ), "Normalized titles match under loose string comparison", ), ] for condition, reason in match_criteria: if condition and year_matches(): return True, reason return False, "" def match_media_to_assets( media_dict: Dict[str, List[Dict[str, Any]]], prefix_index: Dict[str, Any], ignore_root_folders: List[str], logger: Any, ) -> Dict[str, List[Dict[str, Any]]]: """Match media entries against known asset entries and return unmatched assets by type. Args: media_dict: Dictionary of media grouped by type. prefix_index: Search index for assets. ignore_root_folders: List of folder names or paths to ignore. logger: Logger instance. Returns: Dictionary of unmatched entries by type as flat lists. """ unmatched: Dict[str, List[Dict[str, Any]]] = { "movies": [], "series": [], "collections": [], } for media_type in ["movies", "series", "collections"]: media_list = media_dict.get(media_type, []) with progress( media_list, desc=f"Matching {media_type}", total=len(media_list), unit="media", logger=logger, ) as pbar: for media_data in pbar: if media_type in ["series", "movies"] and media_data.get( "status" ) not in ["released", "ended", "continuing"]: logger.debug( f"Skipping {media_type} '{media_data.get('title')}' with status '{media_data.get('status')}'" ) continue location = ( media_data.get("location") if media_type == "collections" else media_data.get("root_folder") ) if not location: continue root = os.path.basename(location.rstrip("/")).lower() if ignore_root_folders and ( root in ignore_root_folders or location in ignore_root_folders ): continue media_seasons: List[int] = [] if media_type == "series": media_seasons = [ s["season_number"] for s in media_data.get("seasons", []) if s.get("season_has_episodes") ] found = False tmdb_id = media_data.get("tmdb_id") tvdb_id = media_data.get("tvdb_id") candidates = [] id_assets_found = [] if tmdb_id or tvdb_id: id_assets_found = search_matches( prefix_index, media_data.get("title", ""), logger, tmdb_id=tmdb_id, tvdb_id=tvdb_id, ) if id_assets_found: asset_data = id_assets_found[0] found = True if media_type == "series": missing = [ s for s in media_seasons if s not in asset_data.get("season_numbers", []) ] has_main_poster = any( not season_pattern.search(os.path.basename(f)) for f in asset_data.get("files", []) ) missing_main_poster = not has_main_poster if missing or missing_main_poster: entry = { "title": media_data.get("title"), "year": media_data.get("year"), "missing_seasons": missing, "missing_main_poster": missing_main_poster, } unmatched[media_type].append(entry) else: titles_to_try = [media_data.get("title")] + media_data.get( "alternate_titles", [] ) for title in titles_to_try: assets_found = search_matches(prefix_index, title, logger) candidates.extend(assets_found) for asset_data in candidates: is_matched, reason = is_match(asset_data, media_data) if is_matched: logger.debug( f"✓ Fallback match: {reason}: {media_data.get('title')} ({media_data.get('year')}) <-> {asset_data.get('title')} ({asset_data.get('year')})" ) found = True if media_type == "series" and media_seasons: missing = [ s for s in media_seasons if s not in asset_data.get("season_numbers", []) ] if missing: has_main_poster = any( not season_pattern.search(os.path.basename(f)) for f in asset_data.get("files", []) ) missing_main_poster = not has_main_poster unmatched[media_type].append( { "title": media_data.get("title"), "year": media_data.get("year"), "missing_seasons": missing, "missing_main_poster": missing_main_poster, } ) break if not found: entry = { "title": media_data.get("title"), "year": media_data.get("year"), "missing_main_poster": True, } if media_type == "series": entry["missing_seasons"] = media_seasons unmatched[media_type].append(entry) return unmatched def match_assets_to_media( media_dict: Dict[str, List[Dict[str, Any]]], prefix_index: Dict[str, Any], logger: Optional[Any] = None, return_unmatched_assets: bool = False, config: Optional[SimpleNamespace] = None, strict_folder_match: bool = False, ) -> Dict[str, List[Dict[str, Any]]]: """Match assets to media. Optionally, return unmatched assets instead of matched. Args: media_dict: Dictionary of media grouped by type. prefix_index: Search index for assets. logger: Logger instance. return_unmatched_assets: Whether to return unmatched assets. config: Optional config namespace. strict_folder_match: If True, only match if folder matches. Returns: Dictionary of matched or unmatched assets by type. """ asset_types = ["movies", "series", "collections"] all_assets = {atype: [] for atype in asset_types} asset_key_to_asset: Dict[Any, Any] = {} for asset_list in prefix_index.values(): for asset in asset_list: atype = asset.get("type") if atype in asset_types: key = ( asset.get("title"), asset.get("year"), tuple(asset.get("files") or []), asset.get("path"), ) if key not in asset_key_to_asset: all_assets[atype].append(asset) asset_key_to_asset[key] = asset matched_asset_keys = set() matched: Dict[str, List[Dict[str, Any]]] = {atype: [] for atype in asset_types} use_asset_types = [t for t in media_dict if media_dict[t] is not None] total_comparisons = 0 total_items = 0 matches = 0 non_matches = 0 with progress( use_asset_types, desc="Matching assets...", total=len(use_asset_types), unit="asset types", logger=logger, ) as pbar_outer: for asset_type in pbar_outer: if asset_type in media_dict: matched_dict: List[Dict[str, Any]] = [] media_data = media_dict[asset_type] start_time = time.time() with progress( media_data, desc=f"Matching {asset_type}", total=len(media_data), unit="media", logger=logger, ) as pbar_inner: for media in pbar_inner: total_items += 1 found_match = False search_asset = None seasons = media.get("seasons") or [] media_seasons_numbers = [ season["season_number"] for season in seasons ] tmdb_id = media.get("tmdb_id") tvdb_id = media.get("tvdb_id") candidates = [] id_candidates = [] if tmdb_id or tvdb_id: id_candidates = search_matches( prefix_index, media.get("title", ""), logger, tmdb_id=tmdb_id, tvdb_id=tvdb_id, ) for candidate in id_candidates: total_comparisons += 1 is_matched, reason = is_match( candidate, media, strict_folder_match ) if is_matched: logger.debug( f"✓ Matched: {reason}: {media['title']} ({media['year']}) <-> {candidate['title']} ({candidate.get('year')})" ) search_asset = candidate found_match = True asset_season_numbers = search_asset.get( "season_numbers", None ) if asset_season_numbers and media_seasons_numbers: handle_series_match( search_asset, media_seasons_numbers, asset_season_numbers, ) key = ( search_asset.get("title"), search_asset.get("year"), tuple(search_asset.get("files") or []), search_asset.get("path"), ) matched_asset_keys.add(key) break if not found_match and not id_candidates: titles_to_check = [media["title"]] + media.get( "alternate_titles", [] ) for title in titles_to_check: candidate_list = search_matches( prefix_index, title, logger ) candidates.extend(candidate_list) type_candidates = [ a for a in candidates if a.get("type") == asset_type ] if type_candidates: candidates = type_candidates for search_asset in candidates: total_comparisons += 1 is_matched, reason = is_match( search_asset, media, strict_folder_match ) if is_matched: logger.debug( f"✓ Matched: {reason}: {media['title']} ({media['year']}) <-> {search_asset['title']} ({search_asset.get('year')})" ) asset_season_numbers = search_asset.get( "season_numbers", None ) if ( not asset_season_numbers or not media_seasons_numbers or ( asset_season_numbers and media_seasons_numbers ) ): found_match = True if ( asset_season_numbers and media_seasons_numbers ): handle_series_match( search_asset, media_seasons_numbers, asset_season_numbers, ) key = ( search_asset.get("title"), search_asset.get("year"), tuple(search_asset.get("files") or []), search_asset.get("path"), ) matched_asset_keys.add(key) break if found_match: matches += 1 matched_dict.append( { "title": media["title"], "year": media["year"], "folder": media.get("folder"), "files": search_asset["files"], "seasons_numbers": ( search_asset.get("season_numbers", None) if search_asset else None ), "asset_ref": search_asset, } ) else: non_matches += 1 candidate_titles = [] if id_candidates or candidates: for c in (id_candidates or []) + (candidates or []): ct = c.get("title") cy = c.get("year") if ct: candidate_titles.append( f"{ct} ({cy})" if cy else str(ct) ) if candidate_titles: col_width = ( max(len(s) for s in candidate_titles) + 2 ) rows = [] for i in range(0, len(candidate_titles), 3): chunk = candidate_titles[i : i + 3] row = " | ".join( c.ljust(col_width) for c in chunk ) rows.append(row) candidates_str = "\n ".join(rows) logger.debug( f"✗ No match: {media['title']} ({media['year']})\n" f" Candidates checked:\n" f" {candidates_str}" ) else: logger.debug( f"✗ No match: {media['title']} ({media['year']}) | No candidates found" ) else: logger.debug( f"✗ No match: {media['title']} ({media['year']}) | No candidates found" ) matched[asset_type] = matched_dict elapsed_time = time.time() - start_time items_per_second = ( len(media_data) / elapsed_time if elapsed_time > 0 else 0 ) logger.debug( f"Completed matching for {asset_type}: {len(media_data)} items in {elapsed_time:.2f} seconds ({items_per_second:.2f} items/s)" ) logger.debug(f"{total_items} total_items") logger.debug(f"{total_comparisons} total_comparisons") logger.debug(f"{matches} total_matches") logger.debug(f"{non_matches} non_matches") if return_unmatched_assets: unmatched_assets = {atype: [] for atype in asset_types} for atype in asset_types: for asset in all_assets[atype]: if asset.get("title", "").lower() == "tmp": continue key = ( asset.get("title"), asset.get("year"), tuple(asset.get("files") or []), asset.get("path"), ) if key in matched_asset_keys: continue if config and getattr(config, "ignore_media", None): ignore_title = asset["title"] ignore_title_year = f"{asset['title']} ({asset['year']})" if ( ignore_title in config.ignore_media or ignore_title_year in config.ignore_media ): logger.debug( f"{asset['title']} ({asset['year']}) is in ignore_media, skipping..." ) continue unmatched_assets[atype].append( { "title": asset["title"], "year": asset["year"], "files": asset["files"], "path": asset.get("path", None), } ) return unmatched_assets return matched def handle_series_match( asset: Dict[str, Any], media_seasons_numbers: List[int], asset_season_numbers: List[int], ) -> None: """Prune asset data to remove files/seasons not present in the media entry. Args: asset: Asset dictionary with file and season data. media_seasons_numbers: List of seasons found in the media source. asset_season_numbers: List of seasons declared in the asset. """ files_to_remove = [] seasons_to_remove = [] for file in asset.get("files", []): if re.search(r" - Season| - Specials", file): match = re.search(r"Season (\d+)", file) if match: season_number = int(match.group(1)) elif "Specials" in file: season_number = 0 else: continue if season_number not in media_seasons_numbers: files_to_remove.append(file) for file in files_to_remove: asset["files"].remove(file) for season in asset_season_numbers: if season not in media_seasons_numbers: seasons_to_remove.append(season) for season in seasons_to_remove: asset_season_numbers.remove(season) ================================================ FILE: util/normalization.py ================================================ import html import os import re from unidecode import unidecode from util.constants import ( common_words, id_content_regex, illegal_chars_regex, remove_special_chars, words_to_remove, year_regex, ) def remove_common_words(text: str) -> str: """Remove complete words found in common_words (case-insensitive). Args: text (str): Input text. Returns: str: Text with common words removed. """ words = text.split() filtered = [ word for word in words if word.lower() not in {w.lower() for w in common_words} ] return " ".join(filtered) def remove_tokens(text: str) -> str: """Remove specified unwanted substrings from text. Args: text (str): Input text. Returns: str: Text with tokens removed. """ for token in words_to_remove: text = text.replace(token, "") return text def normalize_file_names(file_name: str) -> str: """Normalize filename for indexing. Steps: 1. Strip extension. 2. Convert HTML entities and unicode to ASCII. 3. Remove ID tokens in curly braces. 4. Remove specified unwanted substrings. 5. Remove illegal filename characters. 6. Remove miscellaneous special symbols. 7. Remove common filler words. 8. Remove whitespace and lowercase. Args: file_name (str): Filename to normalize. Returns: str: Normalized filename. """ base, _ = os.path.splitext(file_name) cleaned = unidecode(html.unescape(base)) cleaned = id_content_regex.sub("", cleaned) cleaned = remove_tokens(cleaned) cleaned = illegal_chars_regex.sub("", cleaned) cleaned = re.sub(remove_special_chars, "", cleaned) cleaned = remove_common_words(cleaned) cleaned = cleaned.replace(" ", "").lower() return cleaned.strip() def normalize_titles(title: str) -> str: """Normalize media title for matching and indexing. Steps: 1. Strip year tag. 2. Convert HTML entities and unicode to ASCII. 3. Remove ID tokens in curly braces. 4. Remove specified unwanted substrings. 5. Remove illegal filename characters. 6. Remove miscellaneous special symbols. 7. Remove whitespace and lowercase. Args: title (str): Media title to normalize. Returns: str: Normalized title. """ normalized_title = year_regex.sub("", title) normalized_title = unidecode(html.unescape(normalized_title)).strip() normalized_title = id_content_regex.sub("", normalized_title) normalized_title = remove_tokens(normalized_title) normalized_title = illegal_chars_regex.sub("", normalized_title) normalized_title = re.sub(remove_special_chars, "", normalized_title) normalized_title = normalized_title.replace(" ", "").lower() return normalized_title.strip() ================================================ FILE: util/notification.py ================================================ import json import logging import os import random import traceback from dataclasses import dataclass from datetime import datetime from typing import Any, Callable, Dict, List, Optional, Tuple, Union from urllib.parse import quote import requests from apprise import Apprise from ratelimit import limits, sleep_and_retry class ErrorNotifyHandler(logging.Handler): """Custom logging handler to send errors to Discord/Notifiarr via notifications.""" def __init__(self, config, module_name="main", logger=None): super().__init__(level=logging.ERROR) self.config = config self.module_name = module_name self.logger = logger # for logging send status, not for the error itself def emit(self, record): try: msg = record.getMessage() tb = None error_type_msg = "" if record.exc_info: tb_lines = traceback.format_exception(*record.exc_info) tb = "".join(tb_lines) if tb_lines: error_type_msg = tb_lines[-1].strip() elif record.stack_info: tb = record.stack_info else: tb = None if error_type_msg: error_msg = f"{msg}\n{error_type_msg}" else: error_msg = msg output = { "error_message": error_msg, "traceback": tb, "color": "FF0000", "source_module": getattr(record, "module", self.module_name), } notify_mod = "error_notify" config = self.config if hasattr(config, "module_name"): old_mod = config.module_name config.module_name = notify_mod send_notification(self.logger or config, notify_mod, config, output) config.module_name = old_mod else: temp_cfg = dict(config) temp_cfg["module_name"] = notify_mod send_notification(self.logger or config, notify_mod, temp_cfg, output) except Exception as e: if self.logger: self.logger.error( f"[ErrorNotifyHandler] Failed to send error notification: {e}" ) @dataclass class NotifiarrConfig: webhook: str channel_id: int def extract_error(resp: requests.Response) -> str: """Extract a user-friendly error message from an HTTP response. Args: resp: HTTP response object. Returns: User-friendly error message. """ try: data = resp.json() return data.get("error", resp.text) except ValueError: return resp.text def build_notifiarr_payload(module_title: str, cid: int) -> Dict[str, Any]: """Build the JSON payload for Notifiarr Passthrough. Args: module_title: Title of the module. cid: Channel ID. Returns: Notifiarr payload dict. """ return { "notification": {"update": False, "name": module_title, "event": "0"}, "discord": { "color": "", "ping": {"pingUser": 0, "pingRole": 0}, "images": {"thumbnail": "", "image": ""}, "text": { "title": "Test Notification", "icon": "", "content": "This is a test notification.", "description": "This is a test notification.", "fields": [], "footer": "", }, "ids": {"channel": cid}, }, } def build_discord_payload( module_title: str, data: Any, timestamp: str, dry_run: bool = False, color: Union[int, str] = 0x00FF00, ) -> List[Dict[str, Any]]: """Build Discord payload(s) for embeds/content. Args: module_title: Title of the module. data: Data for the payload. timestamp: ISO timestamp string. dry_run: If True, marks as dry run. color: Embed color as int (0xRRGGBB) or hex string ("FF0000" or "#FF0000"). Returns: List of Discord payload dicts. """ # Handle string color input if isinstance(color, str): color = int(color.lstrip("#"), 16) payloads: List[Dict[str, Any]] = [] if isinstance(data, dict): data = [ { "embed": True, "fields": fields, "part": f" (Part {idx} of {len(data)})" if len(data) > 1 else "", } for idx, fields in data.items() ] for part in data: payload: Dict[str, Any] = {} if "embed" in part: payload["embeds"] = [ { "title": f"{module_title} Notification{part.get('part', '')}", "description": None, "color": color, "timestamp": timestamp, "fields": part.get("fields", []), "footer": {"text": f"Powered by: Drazzilb | {get_random_joke()}"}, } ] if "content" in part: payload["content"] = ( f"__**Dry Run**__\n{part['content']}" if dry_run else part["content"] ) elif dry_run: payload["content"] = "__**Dry Run**__" payload["username"] = "Notification Bot" payloads.append(payload) return payloads @sleep_and_retry @limits(calls=5, period=5) def safe_post(url: str, payload: Dict[str, Any]) -> requests.Response: """Send a POST request with a JSON payload. Args: url: Target URL. payload: Payload dict. Returns: HTTP response. """ return requests.post(url, json=payload) def get_random_joke() -> str: """Retrieve a random joke from jokes.txt in the parent directory. Returns: A random joke string, or empty string if not found. """ root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) jokes_path = os.path.join(root_dir, "jokes.txt") if os.path.exists(jokes_path): with open(jokes_path, encoding="utf-8") as f: jokes = [line.strip() for line in f if line.strip()] if jokes: return random.choice(jokes) return "" def send_and_log_response( logger: Any, label: str, hook: str, payload: Dict[str, Any] ) -> None: """Send a POST request and log the response status. Args: logger: Logger instance. label: Notification label. hook: Webhook URL. payload: Payload dict. """ try: resp = safe_post(hook, payload) if resp.status_code not in (200, 204): err = format_notification_error(resp, label) logger.error( f"[Notification] ❌ {label} failed ({resp.status_code}): {err}\n" f"Payload:\n{json.dumps(payload, indent=2)}" ) else: logger.info(f"[Notification] ✅ {label} notification sent.") except Exception as e: logger.error(f"[Notification] {label} send exception: {e}") def send_notifiarr_notification( logger: Any, config: Any, auth_data: NotifiarrConfig, module_title: str, output: Any, test: bool = False, ) -> Optional[Tuple[bool, str]]: """Send structured notifications to Notifiarr via Passthrough API. Args: logger: Logger instance. config: Configuration object. auth_data: NotifiarrConfig instance. module_title: Module title. output: Output data. test: Whether to send a test notification. Returns: (success, message) if test, else None. """ hook = auth_data.webhook.rstrip("/") cid = auth_data.channel_id payload = build_notifiarr_payload(module_title, cid) if test: resp = safe_post(hook, payload) success = resp.status_code in (200, 204) msg = ( "Test notification sent via Notifiarr." if success else f"Notifiarr Test failed ({resp.status_code}): {extract_error(resp)}" ) return success, msg from util.notification_formatting import format_for_discord data, _ = format_for_discord(config, output) parts: List[Dict[str, Any]] = [] if isinstance(data, dict): for idx, fields in data.items(): parts.append( { "embed": True, "fields": fields, "part": f" (Part {idx} of {len(data)})" if len(data) > 1 else "", } ) else: parts = data for part in parts: pt_payload = { "notification": {"update": False, "name": module_title, "event": ""}, "discord": { "color": "", "ping": {"pingUser": 0, "pingRole": 0}, "images": {"thumbnail": "", "image": ""}, "ids": {"channel": cid}, }, } if part.get("embed"): fields = [ { "title": f.get("name", ""), "text": f.get("value", ""), "inline": bool(f.get("inline", False)), } for f in part.get("fields", []) ] pt_payload["discord"]["text"] = { "title": f"{module_title} Notification{part.get('part','')}", "fields": fields, "footer": get_random_joke(), } else: content = part.get("content") pt_payload["discord"]["text"] = { "description": " ", "content": content, } color = output.get("color", "00FF00") if isinstance(output, dict) else "00FF00" if isinstance(color, int): color = f"{color:06X}" elif isinstance(color, str): color = color.lstrip("#") pt_payload["discord"]["color"] = color send_and_log_response(logger, "Notifiarr", hook, pt_payload) return None def send_discord_notification( logger: Any, config: Any, hook: str, module_title: str, output: Any, ) -> None: from util.notification_formatting import format_for_discord data, _ = format_for_discord(config, output) timestamp = datetime.utcnow().isoformat() dry_run = getattr(config, "dry_run", False) if isinstance(output, dict): color = output.get("color", 0x00FF00) else: color = 0x00FF00 for payload in build_discord_payload( module_title, data, timestamp, dry_run=dry_run, color=color ): send_and_log_response(logger, "Discord", hook, payload) def extract_apprise_errors(apprise: Apprise) -> str: """Extract concise error messages from Apprise services. Args: apprise: Apprise instance. Returns: Concatenated error message string. """ errors: List[str] = [] for service in apprise: if hasattr(service, "last_response") and service.last_response: errors.append(f"Last response: {service.last_response}") if hasattr(service, "response") and service.response: errors.append(f"Response: {service.response}") if hasattr(service, "details") and callable(service.details): try: details = service.details() if details: errors.append(f"Details: {details}") except Exception: pass errors.append(f"Service config: {service}") return "; ".join(errors) if errors else "Unknown error" def format_notification_error(source: Any, label: str = "") -> str: """Return a user-friendly error message from a response or Apprise. Args: source: requests.Response or Apprise instance. label: Optional label. Returns: Error message string. """ if isinstance(source, requests.Response): return extract_error(source) try: if isinstance(source, Apprise): return extract_apprise_errors(source) except Exception: pass return f"{label} unknown error" def format_module_title(name: str) -> str: """Convert a module name to a human-readable title. Args: name: Module name. Returns: Title string. """ return name.replace("_", " ").title() def send_apprise_notification( logger: Any, label: str, apprise: Apprise, title: str, body: str, body_format: str = "text", ) -> bool: """Send a notification via Apprise and log the result. Args: logger: Logger instance. label: Notification label. apprise: Apprise instance. title: Notification title. body: Notification body. body_format: Body format. Returns: True on success, False on failure. """ try: success = apprise.notify(title=title, body=body, body_format=body_format) if success: logger.info(f"[Notification] ✅ {label} sent via Apprise.") else: err_msg = format_notification_error(apprise, label) logger.error(f"[Notification] ❌ {label} failed via Apprise: {err_msg}") return success except Exception as e: logger.error( f"[Notification] ❌ {label} exception via Apprise: {e}", exc_info=True ) return False def send_email_notification( logger: Any, config: Any, apprise: Apprise, module_title: str, output: Any, ) -> None: """Send an HTML formatted email using Apprise. Args: logger: Logger instance. config: Configuration object. apprise: Apprise instance. module_title: Module title. output: Output data. """ from util.notification_formatting import format_for_email try: body, success = format_for_email(config, output) if not success: logger.warning("[Notification] Email skipped: no formatter found.") return subject = f"{module_title} Notification" send_apprise_notification( logger, f"{module_title} Email", apprise, subject, body, "html" ) except Exception as e: logger.error( f"[Notification] Unhandled exception during email notification: {e}", exc_info=True, ) def collect_valid_targets( config: Any, logger: Any, test: bool = False ) -> Dict[str, Union[str, Tuple[str, Union[str, int]]]]: """Collect and format valid notification targets from the configuration. Args: config: Configuration object. logger: Logger instance. test: If True, format for test mode. Returns: Dictionary of notification targets. """ target_data: Dict[str, Union[str, Tuple[str, Union[str, int]]]] = {} notification_targets = getattr(config, "notifications", None) if notification_targets is None and isinstance(config, dict): notification_targets = config.get("notifications", []) if notification_targets is None: notification_targets = {} try: for ttype, target in notification_targets.items(): if not isinstance(target, dict): logger.warning(f"Invalid config structure for {ttype}: expected dict.") target_data[ttype] = f"Invalid config for {ttype}" continue if ttype == "discord": if test: hook = target.get("webhook", "").rstrip("/") parts = hook.rstrip("/").split("/") if len(parts) >= 7 and parts[4] == "webhooks": webhook_id = parts[5] token = parts[6] apprise_url = f"discord://{webhook_id}/{token}" target_data["discord"] = apprise_url else: msg = "Invalid Discord webhook URL" logger.warning(msg) target_data["discord"] = msg else: hook = target.get("webhook", "").rstrip("/") if hook: target_data[ttype] = hook else: msg = "Invalid Notifiarr configuration" logger.warning(msg) target_data[ttype] = msg elif ttype == "notifiarr": hook = target.get("webhook", "").rstrip("/") cid = target.get("channel_id") if hook and cid is not None: target_data["notifiarr"] = { "webhook": hook, "channel_id": int(cid), } else: logger.warning("Invalid Notifiarr configuration") target_data["notifiarr"] = "Invalid Notifiarr configuration" elif ttype == "email": smtp_server = target.get("smtp_server") smtp_port = target.get("smtp_port", 587) username = target.get("username", "") password = target.get("password", "") from_addr = target.get("from", "") to_addrs = target.get("to", []) use_tls = target.get("use_tls", False) if smtp_server and from_addr and to_addrs: proto = "mailtos" if use_tls else "mailto" if username and password: user = quote(username, safe="") pwd = quote(password, safe="") auth = f"{user}:{pwd}@" else: auth = "" host_part = f"{smtp_server}:{smtp_port}" params = [] to_addrs_list = ( [to_addrs] if isinstance(to_addrs, str) else to_addrs ) params.append("to=" + quote(",".join(to_addrs_list))) params.append("from=" + quote(from_addr)) query = "&".join(params) mail_url = f"{proto}://{auth}{host_part}?{query}" target_data[ttype] = mail_url else: msg = "Invalid email configuration" logger.warning(msg) target_data[ttype] = msg else: target_data[ttype] = f"Invalid - Unknown notification type: {ttype}" except Exception as e: logger.error(f"[Notification] Error collecting targets: {e}", exc_info=True) target_data = {} return target_data def send_test_notification( payload: Dict[str, Any], logger: Any ) -> Dict[str, Union[str, bool, None]]: """Send a simple test notification using Apprise. Args: payload: Payload dict. logger: Logger instance. Returns: Result dict with type, message, and result. """ module_name = payload.get("module", "Unknown") module_title = format_module_title(module_name) target_data = collect_valid_targets(payload, logger=logger, test=True) for target, data in target_data.items(): entry: Dict[str, Union[str, bool, None]] = { "type": target, "message": None, "result": False, } if not data or (isinstance(data, str) and data.startswith("Invalid")): entry["message"] = ( data if isinstance(data, str) else f"No valid URL for '{target.upper()}'" ) entry["result"] = False entry["type"] = target return entry if target == "notifiarr": cfg = NotifiarrConfig(**data) success, msg = send_notifiarr_notification( logger, None, cfg, module_title, None, test=True ) return {"type": target, "message": msg, "result": success} apprise = Apprise() apprise.add(data) subject = f"{target} Notification Test" body = "This is a test notification." success = send_apprise_notification( logger, f"{target} Notification Test", apprise, subject, body, "text" ) entry["message"] = ( "Notification sent successfully." if success else extract_apprise_errors(apprise) ) entry["result"] = success entry["type"] = target return entry return { "type": None, "message": "No valid notification targets found.", "result": False, } SEND_HANDLERS: Dict[str, Callable[..., Any]] = { "notifiarr": send_notifiarr_notification, "discord": send_discord_notification, "email": send_email_notification, } def send_notification(logger: Any, module_name: str, config: Any, output: Any) -> None: """Dispatch notifications to Discord, Notifiarr, and other Apprise targets. Args: logger: Logger instance. module_name: Module name. config: Configuration object. output: Output data. """ target_data = collect_valid_targets(config, logger) module_title = format_module_title(module_name) for target, data in target_data.items(): logger.debug(f"[Notification] Queued {target} via Apprisse") handler = SEND_HANDLERS.get(target) if handler: if target == "notifiarr": cfg = NotifiarrConfig(**data) handler(logger, config, cfg, module_title, output) elif target == "discord": handler(logger, config, data, module_title, output) elif target == "email": apprise = Apprise() apprise.add(data) handler(logger, config, apprise, module_title, output) else: logger.warning(f"[Notification] Unknown target: {target}") ================================================ FILE: util/notification_formatting.py ================================================ import os from typing import Any, Dict, List, Tuple from util.notification import get_random_joke def format_for_discord( config: Any, output: Any ) -> Tuple[Dict[int, List[Dict[str, Any]]], bool]: """Format notification output for Discord embeds and chunking. Args: config: Module config object (must have 'module_name'). output: Output from the module to be formatted. Returns: Tuple of (embed field dict, success bool). """ DISCORD_FIELD_CHAR_LIMIT = 1000 DISCORD_EMBED_CHAR_LIMIT = 5000 DISCORD_FIELD_COUNT_LIMIT = 25 def chunk_code_fields( name: str, text: str, inline: bool = False ) -> List[Dict[str, Any]]: """Chunk a string into Discord embed fields by char limit. Args: name: Name of the field (used for the first chunk). text: The code/text to chunk. inline: Whether this field should be inline. Returns: List of Discord embed field dicts. """ fields: List[Dict[str, Any]] = [] lines = text.split("\n") buffer = "" first = True for line in lines: candidate = buffer + line + "\n" if len(candidate) > DISCORD_FIELD_CHAR_LIMIT: # Discord embed field value chunk limit reached. field = { "name": name if first else "", "value": f"```{buffer.rstrip()}```", } if inline: field["inline"] = True fields.append(field) buffer = line + "\n" first = False else: buffer = candidate if buffer: field = {"name": name if first else "", "value": f"```{buffer.rstrip()}```"} if inline: field["inline"] = True fields.append(field) return fields def split_fields(fields: List[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]: """Split embed fields into multiple Discord embeds by char and field limits. Args: fields: List of Discord embed field dicts. Returns: Dict mapping embed index to list of fields for that embed. """ expanded: List[Dict[str, Any]] = [] for f in fields: name = f.get("name", "") inline = f.get("inline", False) val = f.get("value", "") if val == "": chunk = {"name": name, "value": ""} if inline: chunk["inline"] = True expanded.append(chunk) continue # Unwrap code block if present content = ( val[3:-3] if val.startswith("```") and val.endswith("```") else val ) lines = content.split("\n") buffer = "" first = True for line in lines: candidate = buffer + line + "\n" if len(candidate) > DISCORD_FIELD_CHAR_LIMIT: chunk = { "name": name if first else "", "value": f"```{buffer.strip()}```", } if inline: chunk["inline"] = True expanded.append(chunk) buffer = line + "\n" first = False else: buffer = candidate if buffer: chunk = { "name": name if first else "", "value": f"```{buffer.strip()}```", } if inline: chunk["inline"] = True expanded.append(chunk) # Batch fields into embeds, respecting Discord's limits. result: Dict[int, List[Dict[str, Any]]] = {} batch: List[Dict[str, Any]] = [] size_acc = 0 idx = 1 limit = ( DISCORD_EMBED_CHAR_LIMIT + 500 ) # Discord embed character limit (buffered) for f in expanded: est = len(f.get("name", "")) + len(f.get("value", "")) + 30 if len(batch) >= DISCORD_FIELD_COUNT_LIMIT or size_acc + est > limit: result[idx] = batch idx += 1 batch = [] size_acc = 0 batch.append(f) size_acc += est if batch: result[idx] = batch return result def chunk_flat_content( header: str, content: str, footer: str = "" ) -> List[Dict[str, Any]]: """Chunk plain content into Discord message blocks under 1900 chars. Args: header: Header to prepend to the first chunk. content: The content to chunk. footer: Footer to append to the last chunk. Returns: List of Discord message dicts (with 'content' key). """ CHUNK_LIMIT = 1900 lines = content.split("\n") results = [] buffer_lines: List[str] = [] first_chunk = True def flush_chunk(buf_lines: List[str], is_last: bool) -> None: chunk_text = "\n".join(buf_lines) parts: List[str] = [] if first_chunk and header: parts.append(header) parts.append(f"```{chunk_text}```") if is_last and footer: parts.append(footer) results.append({"content": "\n".join(parts)}) for line in lines: buffer_lines.append(line) total_len = sum(len(line) for line in buffer_lines) + len(buffer_lines) - 1 if total_len > CHUNK_LIMIT: overflow_line = buffer_lines.pop() flush_chunk(buffer_lines, is_last=False) first_chunk = False buffer_lines = [overflow_line] if buffer_lines: flush_chunk(buffer_lines, is_last=True) return results def fmt_poster_renamerr(o: Any) -> List[Dict[str, Any]]: """Format poster_renamerr output for Discord embeds. Args: o: Output data for poster_renamerr. Returns: List of Discord embed field dicts. """ fields: List[Dict[str, Any]] = [] for assets in o.values(): for a in assets: title = a.get("title", "") year = f" ({a.get('year')})" if a.get("year") else "" msgs = sorted(a.get("discord_messages", [])) text = "\n".join([f"{title}{year}"] + [f"\t{m}" for m in msgs]) fields.extend(chunk_code_fields(f"{title}{year}", text)) return fields def fmt_renameinatorr(o: Any) -> List[Dict[str, Any]]: """Format renameinatorr output for Discord embeds. Args: o: Output data for renameinatorr. Returns: List of Discord embed field dicts. """ grouped: Dict[str, List[str]] = {} for inst in o.values(): for item in inst.get("data", []): title = item.get("title", "Unknown") year = item.get("year") name = f"{title}{f' ({year})' if year else ''}" lst = grouped.setdefault(name, []) if np := item.get("new_path_name"): lst.append( f"Folder:\n{item.get('path_name','').lstrip('/')} -> {np.lstrip('/')}" ) for old, new in item.get("file_info", {}).items(): lst.append(old.lstrip("/")) lst.append(new.lstrip("/")) fields: List[Dict[str, Any]] = [] for name, lines in grouped.items(): if not lines: continue text = "\n".join(lines) fields.append({"name": name, "value": f"```{text}```"}) return fields def fmt_health_checkarr(o: Any) -> List[Dict[str, Any]]: """Format health_checkarr output for Discord embeds. Args: o: Output data for health_checkarr. Returns: List of Discord embed field dicts. """ fields: List[Dict[str, Any]] = [] grouped: Dict[str, List[str]] = {} for item in output: title = item.get("title", "Untitled") year = f" ({item.get('year')})" if item.get("year") else "" db_id = ( item["tvdb_id"] if item["instance_type"] == "sonarr" else item.get("tmdb_id") ) grouped.setdefault(item["instance_name"], []).append( f"{title}{year}\t{db_id}" ) for instance, lines in grouped.items(): text = "\n".join(lines) fields.extend(chunk_code_fields(instance, text)) if fields: summary = ( "🔍 The following items were flagged as removed from TMDB/TVDB and would be deleted." if output and output[0].get("dry_run") else "🧹 The following items were deleted as they were removed from TMDB/TVDB." ) fields.insert(0, {"name": "Summary", "value": f"```{summary}```"}) return fields def fmt_nohl(o: Any) -> List[Dict[str, Any]]: """Format nohl output for Discord embeds. Args: o: Output data for nohl. Returns: List of Discord embed field dicts. """ fields: List[Dict[str, Any]] = [] scanned = o.get("scanned", {}) for path, results in scanned.items(): title = f"Scanned: {os.path.basename(path).capitalize()}" lines: List[str] = [] for item in results.get("movies", []): lines.append(f"{item['title']} ({item['year']})") for item in results.get("series", []): lines.append(f"{item['title']} ({item['year']})") for season in item.get("season_info", []): lines.append(f"\tSeason: {season['season_number']}") for episode in season.get("episodes", []): lines.append(f"\t\tEpisode: {episode}") if lines: fields.extend(chunk_code_fields(title, "\n".join(lines))) else: fields.append( { "name": "✅ All Scanned files are hardlinked!", "value": "", } ) resolved = o.get("resolved", {}) for instance, data in resolved.items(): srv = data.get("server_name", instance) inst_type = data.get("instance_type", "") title = f"Resolved: {srv}" sm = data.get("data", {}).get("search_media", []) if not sm: fields.append( { "name": f"✅ {srv} all resolve files are hardlinked!", "value": "", } ) continue lines: List[str] = [] for item in sm: if inst_type == "radarr": lines.append(f"{item['title']} ({item['year']})") else: lines.append(f"{item['title']} ({item['year']})") for season in item.get("seasons", []): lines.append(f"\tSeason {season['season_number']}") if not season.get("season_pack", False): for ep in season.get("episode_data", []): lines.append(f"\t\tEpisode {ep['episode_number']}") lines.append("") if lines: fields.extend(chunk_code_fields(title, "\n".join(lines))) summary = o.get("summary", {}) if not all(value == 0 for value in summary.values()): title = "Summary" lines = [ f"Total Non-Hardlinked Scanned Movies: {summary.get('total_scanned_movies', 0)}", f"Total Non-Hardlinked Scanned Series: {summary.get('total_scanned_series', 0)}", f"Total Non-Hardlinked Resolved Movies: {summary.get('total_resolved_movies', 0)}", f"Total Non-Hardlinked Resolved Series: {summary.get('total_resolved_series', 0)}", ] fields.extend(chunk_code_fields(title, "\n".join(lines))) return fields def fmt_upgradinatorr(o: Any) -> List[Dict[str, Any]]: """Format upgradinatorr output for Discord embeds. Args: o: Output data for upgradinatorr. Returns: List of Discord embed field dicts. """ fields: List[Dict[str, Any]] = [] for inst, data in o.items(): srv = data.get("server_name", inst) lines: List[str] = [] for item in data.get("data", []): dl = item.get("download") or {} if dl: title = item.get("title", "Unknown") year = f" ({item.get('year')})" if item.get("year") else "" lines.append(f"{title}{year}") for t, score in dl.items(): lines.append(f"\t{t}") lines.append(f"\tCF Score: {score}") lines.append("") if lines: fields.extend(chunk_code_fields(srv, "\n".join(lines).strip())) return fields def fmt_labelarr(o: Any) -> List[Dict[str, Any]]: """Format labelarr output for Discord embeds. Args: o: Output data for labelarr. Returns: List of Discord embed field dicts. """ fields: List[Dict[str, Any]] = [] summary = f"Synced {len(o)} items across configured Plex libraries." fields.append({"name": "Summary", "value": f"```{summary}```"}) label_changes: Dict[Tuple[str, str], List[str]] = {} for item in o: for label, action in item["add_remove"].items(): key = (label, action) label_changes.setdefault(key, []).append( f"{item['title']} ({item['year']})" ) for (label, action), items in label_changes.items(): verb = "added to" if action == "add" else "removed from" fields.append( { "name": f"Label: `{label}` has been {verb}:", "value": f"```{chr(10).join(items)}```", "inline": False, } ) return fields def fmt_jduparr(o: Any) -> List[Dict[str, Any]]: """Format jduparr output for Discord flat messages. Args: o: Output data for jduparr. Returns: List of Discord message dicts (with 'content' key). """ results: List[Dict[str, Any]] = [] for item in o: source_dir = item.get("source_dir", "Unknown") field_message = item.get("field_message", "") parsed_files = item.get("output", []) sub_count = item.get("sub_count", 0) dir = os.path.basename(source_dir).capitalize() header = f"_\nSource Directory: '__**{dir}**__'\n{field_message}" footer = f"\nPowered by: Drazzilb | {get_random_joke()}" lines = [f"\t{line}" for line in parsed_files] lines.append(f"\tTotal items re-linked in '{dir}': {sub_count}") content = "\n".join(lines) results.extend(chunk_flat_content(header, content, footer)) return results def fmt_version_check(o: dict) -> list: # o = {"local_version": "...", "remote_version": "..."} fields = [ {"name": "Update Available", "value": "🚨 A new update is available!"}, {"name": "Your Version", "value": o.get("local_version", "")}, {"name": "Latest Version", "value": o.get("remote_version", "")}, ] return fields def fmt_error_notify(o: dict) -> list: fields = [ {"name": "Error", "value": o.get("error_message", "")}, {"name": "Module", "value": o.get("source_module", "")}, ] tb = o.get("traceback") if tb: # Truncate traceback if too long for Discord embed field if len(tb) > 1800: tb = tb[:1800] + "\n...truncated..." fields.append({"name": "Traceback", "value": f"```{tb}```"}) return fields registry: Dict[str, Dict[str, Any]] = { "poster_renamerr": {"formatter": fmt_poster_renamerr, "type": "embedded"}, "renameinatorr": {"formatter": fmt_renameinatorr, "type": "embedded"}, "health_checkarr": {"formatter": fmt_health_checkarr, "type": "embedded"}, "nohl": {"formatter": fmt_nohl, "type": "embedded"}, "upgradinatorr": {"formatter": fmt_upgradinatorr, "type": "embedded"}, "labelarr": {"formatter": fmt_labelarr, "type": "embedded"}, "jduparr": {"formatter": fmt_jduparr, "type": "flat"}, "version_check": {"formatter": fmt_version_check, "type": "embedded"}, "error_notify": {"formatter": fmt_error_notify, "type": "embedded"}, } formatter_entry = registry.get(config.module_name) if not formatter_entry: return {}, True formatter = formatter_entry["formatter"] output_type = formatter_entry["type"] formatted_output = formatter(output) if output_type == "flat": return formatted_output, True return split_fields(formatted_output), True def format_for_email(config: Any, output: Any) -> Tuple[str, bool]: """Format notification output for email (HTML). Args: config: Module config object (must have 'module_name'). output: Output from the module to be formatted. Returns: Tuple of (HTML email body, success bool). """ def fmt_labelarr(o: Any) -> str: """Format labelarr output for email. Args: o: Output data for labelarr. Returns: HTML string. """ from collections import defaultdict summary_html = f"
Synced {len(o)} items across configured Plex libraries.
" label_changes = defaultdict(list) for item in o: for label, action in item["add_remove"].items(): key = (label, action) label_changes[key].append(f"{item['title']} ({item['year']})") blocks = [] for (label, action), items in label_changes.items(): verb = "added to" if action == "add" else "removed from" blocks.append( f"

Label: {label} has been {verb}

") return summary_html + "\n" + "\n".join(blocks) def wrap_email(title: str, body: str) -> str: """Wrap formatted output in an HTML email template. Args: title: Email subject/title. body: HTML content. Returns: HTML string. """ return f"""

{title} Notification

{body} """.strip() def fmt_poster_renamerr(o: Any) -> str: """Format poster_renamerr output for email (HTML). Args: o: Output data for poster_renamerr. Returns: HTML string. """ def render_group(title: str, assets: List[Dict[str, Any]]) -> str: if not assets: return "" block: List[str] = [f"

{title}

"] for asset in assets: name = asset.get("title", "") year = f" ({asset.get('year')})" if asset.get("year") else "" renamed = sorted(asset.get("messages", [])) if not renamed: continue block.append( f"
{name}{year}
" ) block.append("
    ") for msg in renamed: block.append(f"
  • {msg}
  • ") block.append("
") block.append("
") return "\n".join(block) return "\n".join( [ render_group("Collections", o.get("collections", [])), render_group("Movies", o.get("movies", [])), render_group("Series", o.get("series", [])), ] ) def fmt_renameinatorr(o: Any) -> str: """Format renameinatorr output for email (HTML). Args: o: Output data for renameinatorr. Returns: HTML string. """ sections: List[str] = [] for inst, inst_data in o.items(): server_name = inst_data.get("server_name", inst).capitalize() title_header = f"{server_name} Rename List" section: List[str] = [ "
", f"

{title_header}

", ] renamed_count = 0 folder_renamed_count = 0 for item in inst_data["data"]: title = item.get("title", "Unknown") year = f" ({item.get('year')})" if item.get("year") else "" file_info = item.get("file_info", {}) folder_renamed = item.get("new_path_name") if not file_info and not folder_renamed: continue section.append("
") section.append( f"
{title}{year}
" ) if folder_renamed: section.append( f"
📁 Folder Renamed:
{item['path_name']}{folder_renamed}
" ) folder_renamed_count += 1 if file_info: section.append("
🎬 Files:
    ") for old, new in file_info.items(): section.append( f"
  • Original: {old}
    New: {new}
  • " ) renamed_count += 1 section.append("
") section.append("
") if renamed_count or folder_renamed_count: summary = [ "
", f"

{server_name} Rename Summary

", f"

Total Items: {len(inst_data['data'])}

", ] if renamed_count: summary.append(f"

Total Renamed Items: {renamed_count}

") if folder_renamed_count: summary.append( f"

Total Folder Renames: {folder_renamed_count}

" ) summary.append("
") section.extend(summary) else: section.append( f"
No items renamed in {server_name}.
" ) section.append("
") sections.append("\n".join(section)) return "".join(sections) def fmt_health_checkarr(o: Any) -> str: """Format health_checkarr output for email (HTML). Args: o: Output data for health_checkarr. Returns: HTML string. """ grouped: Dict[str, List[str]] = {} for item in o: name = item.get("title", "Untitled") year = f" ({item.get('year')})" if item.get("year") else "" db_id = item.get("tvdb_id") or item.get("tmdb_id") key = item["instance_name"] grouped.setdefault(key, []).append(f"{name}{year} - {db_id}") sections: List[str] = [] for instance, entries in grouped.items(): block: List[str] = [ "
", f"

{instance}

", "
") sections.append("\n".join(block)) return "".join(sections) def fmt_upgradinatorr(o: Any) -> str: """Format upgradinatorr output for email (HTML). Args: o: Output data for upgradinatorr. Returns: HTML string. """ sections: List[str] = [] for inst, data in o.items(): server_name = data.get("server_name", inst).capitalize() section: List[str] = [ "
", f"

{server_name}

", ] for item in data.get("data", []): title = item.get("title", "Unknown") year = f" ({item.get('year')})" if item.get("year") else "" downloads = item.get("download", {}) if not downloads: continue section.append("
") section.append( f"
{title}{year}
" ) section.append("
    ") for quality, score in downloads.items(): section.append( f"
  • {quality}: CF Score: {score}
  • " ) section.append("
") section.append("
") section.append("
") sections.append("\n".join(section)) return "".join(sections) def fmt_nohl(o: Any) -> str: """Format nohl output for email (HTML). Args: o: Output data for nohl. Returns: HTML string. """ sections: List[str] = [] for inst_name, inst_data in o.items(): server_name = inst_data.get("server_name", inst_name).capitalize() section: List[str] = [f"

{server_name}

"] search_media = inst_data.get("data", {}).get("search_media", []) filtered_media = inst_data.get("data", {}).get("filtered_media", []) if not search_media: section.append( "
✅ All files are already hardlinked.
" ) else: section.append( "
❌ Non-hardlinked Files:
") if filtered_media: section.append( "
🎛️ Filtered Media:
") section.append("
") sections.append("\n".join(section)) return "".join(sections) def fmt_unmatched_assets(output: dict) -> str: """ Format unmatched_assets output for email (HTML). Args: output: Output data for unmatched_assets. Returns: HTML string. """ sections = [] o = output.get("unmatched_dict", {}) asset_types = ["movies", "series", "collections"] for asset_type in asset_types: data_set = o.get(asset_type, []) if data_set: block = [ "
", f"

Unmatched {asset_type.title()}

", "
") sections.append("".join(block)) # Summary block for unmatched_assets summary_data = output.get("summary") if summary_data: summary_block = [ "
", "

Statistics

", "", ] for row in summary_data: if len(row) == 1: summary_block.append(f"") elif len(row) == 4: summary_block.append( f"" ) else: summary_block.append( "" + "".join(f"" for cell in row) + "" ) summary_block.append("
{row[0]}
{row[0]}{row[1]}{row[2]}{row[3]}
{cell}
") sections.append("".join(summary_block)) return "".join(sections) def fmt_jduparr(output: Any) -> str: """Format jduparr output for email (HTML). Args: output: Output data for jduparr. Returns: HTML string. """ total_count = 0 blocks: List[str] = [] for item in output: path = item.get("source_dir", "Unknown Path") field_message = item.get("field_message", "") parsed_files = item.get("output", []) sub_count = item.get("sub_count", 0) total_count += sub_count block: List[str] = [f"

{os.path.basename(path)}

"] block.append(f"
{field_message}
") if parsed_files: block.append("
") block.append( f"
Total items for '{os.path.basename(path)}': {sub_count}
" ) block.append("
") blocks.append("".join(block)) blocks.append( f"
Total items relinked: {total_count}
" ) return "\n".join(blocks) registry: Dict[str, Any] = { "poster_renamerr": fmt_poster_renamerr, "renameinatorr": fmt_renameinatorr, "health_checkarr": fmt_health_checkarr, "nohl": fmt_nohl, "upgradinatorr": fmt_upgradinatorr, "unmatched_assets": fmt_unmatched_assets, "labelarr": fmt_labelarr, "jduparr": fmt_jduparr, } formatter = registry.get(config.module_name) if not formatter: return "", False inner = formatter(output) return wrap_email(config.module_name.replace("_", " ").title(), inner), True ================================================ FILE: util/scanner.py ================================================ import datetime import html import os import re from collections import defaultdict from typing import Any, Dict, List, Optional from unidecode import unidecode from util.constants import ( illegal_chars_regex, remove_special_chars, season_pattern, year_regex, ) from util.construct import create_collection, create_movie, create_series from util.extract import extract_ids, extract_year from util.normalization import normalize_titles from util.utility import progress def scan_files_in_flat_folder(folder_path: str, logger: Any) -> List[Dict]: """Scan a flat directory structure (no subfolders) for media assets. Args: folder_path (str): Path to the folder containing files. logger (Any): Logger instance for progress and debugging. Returns: List[Dict]: List of parsed media asset dictionaries. """ try: files = os.listdir(folder_path) except FileNotFoundError: return [] except Exception as exc: logger.error(f"Unexpected error listing files in folder {folder_path}: {exc}") return [] groups = defaultdict(list) normalized_map = {} assets_dict = [] for file in files: try: if re.match(r"^\.[^.]", file): continue title = file.rsplit(".", 1)[0] title = unidecode(html.unescape(title)) title = re.sub(illegal_chars_regex, "", title) raw_title = season_pattern.split(title)[0].strip() normalized_title = remove_special_chars.sub("", raw_title.lower()) if normalized_title in normalized_map: match_key = normalized_map[normalized_title] groups[match_key].append(file) else: groups[raw_title].append(file) normalized_map[normalized_title] = raw_title except Exception as exc: logger.error( f"Error processing file '{file}' in folder {folder_path}: {exc}" ) continue groups = dict(sorted(groups.items(), key=lambda x: x[0].lower())) with progress( groups.items(), desc=f"Processing files {os.path.basename(folder_path)}", total=len(groups), unit="file", logger=logger, ) as pbar: for base_name, files in groups.items(): try: assets_dict.append(parse_file_group(folder_path, base_name, files)) except Exception as exc: logger.error( f"Error parsing file group '{base_name}' in folder {folder_path}: {exc}" ) continue pbar.update(1) return assets_dict def scan_files_in_nested_folders(folder_path: str, logger: Any) -> Optional[List[Dict]]: """Scan a directory with subfolders representing grouped assets (e.g., per movie/series). Args: folder_path (str): Path to the base folder. logger (Any): Logger instance. Returns: Optional[List[Dict]]: List of parsed asset dictionaries from subfolders, or None on error. """ assets_dict = [] try: entries = list(os.scandir(folder_path)) progress_bar = progress( entries, desc="Processing posters", total=len(entries), unit="folder", logger=logger, ) for dir_entry in progress_bar: if ( not dir_entry.is_dir() or dir_entry.name.startswith(".") or dir_entry.name == "tmp" ): continue base_name = os.path.basename(dir_entry.path) try: files = [f.name for f in os.scandir(dir_entry.path) if f.is_file()] except Exception as exc: logger.error( f"Failed to scan nested folder: {dir_entry.path} | Exception: {exc}" ) continue if not files: logger.debug(f"Skipping empty folder: {dir_entry.path}") continue try: assets_dict.append(parse_folder_group(dir_entry.path, base_name, files)) except Exception as exc: logger.error( f"Failed to parse folder group: {dir_entry.path} | Exception: {exc}" ) continue except Exception as exc: logger.error(f"Error scanning folder {folder_path}: {exc}") return None return assets_dict def parse_folder_group(folder_path: str, base_name: str, files: List[str]) -> Dict: """Parse metadata and build a structured dictionary for assets within a folder. Args: folder_path (str): Path to the folder. base_name (str): Base name of the folder. files (List[str]): List of file names. Returns: Dict: Structured asset dictionary. """ try: title = re.sub(year_regex, "", base_name) title = unidecode(html.unescape(title)) year = extract_year(base_name) tmdb_id, tvdb_id, imdb_id = extract_ids(base_name) normalized_title = normalize_titles(base_name) full_paths = sorted( [ os.path.join(folder_path, file) for file in files if not file.startswith(".") ] ) parent_folder = os.path.basename(folder_path) if not full_paths: raise ValueError("No valid files found in folder") is_series = len(files) > 1 and any( "Season" in os.path.basename(file) for file in files ) is_collection = not year if is_collection: return create_collection( title, tmdb_id, normalized_title, full_paths, parent_folder ) if is_series or tvdb_id: return create_series( title, year, tvdb_id, imdb_id, normalized_title, full_paths, parent_folder, ) return create_movie( title, year, tmdb_id, imdb_id, normalized_title, full_paths, parent_folder ) except Exception as exc: raise ValueError( f"Error parsing folder group. Folder: {folder_path}, Base name: {base_name}, Exception: {exc}" ) def parse_file_group(folder_path: str, base_name: str, files: List[str]) -> Dict: """Parse a group of files in a flat folder into structured metadata. Args: folder_path (str): Path to the containing folder. base_name (str): Group title. files (List[str]): List of file names. Returns: Dict: Structured media dictionary. """ try: id_cleaned_name = re.sub(r"\{(?:tmdb|tvdb|imdb)-\w+\}", "", base_name).strip() title = re.sub(year_regex, "", id_cleaned_name).strip() title = unidecode(html.unescape(title)) year = extract_year(base_name) tmdb_id, tvdb_id, imdb_id = extract_ids(base_name) normalized_title = normalize_titles(base_name) files = sorted( [ os.path.join(folder_path, file) for file in files if not re.match(r"^\.[^.]", file) ] ) is_series = any(season_pattern.search(file) for file in files) is_collection = not year non_season_file = next((f for f in files if not season_pattern.search(f)), None) if non_season_file: media_folder = os.path.splitext(os.path.basename(non_season_file))[0] else: media_folder = ( os.path.splitext(os.path.basename(files[0]))[0] if files else "" ) if is_collection: return create_collection( title, tmdb_id, normalized_title, files, parent_folder=None, media_folder=media_folder, ) if is_series or tvdb_id: return create_series( title, year, tvdb_id, imdb_id, normalized_title, files, parent_folder=None, media_folder=media_folder, ) return create_movie( title, year, tmdb_id, imdb_id, normalized_title, files, parent_folder=None, media_folder=media_folder, ) except Exception as exc: raise ValueError( f"Error parsing file group. Folder: {folder_path}, Base name: {base_name}, Exception: {exc}" ) def process_files(folder_path: str, logger: Any) -> Optional[List[Dict]]: """Determine folder structure and route to the appropriate scanning logic. Args: folder_path (str): Path to the folder to scan. logger (Any): Logger instance. Returns: Optional[List[Dict]]: List of structured asset dictionaries, or None on failure. """ asset_folders = _is_asset_folders(folder_path, logger) logger.debug(f"Folder Path: {folder_path} | Asset Folder: {asset_folders}") start_time = datetime.datetime.now() if not asset_folders: assets_dict = scan_files_in_flat_folder(folder_path, logger) else: assets_dict = scan_files_in_nested_folders(folder_path, logger) end_time = datetime.datetime.now() if assets_dict: elapsed_time = (end_time - start_time).total_seconds() item_count = ( sum(len(asset.get("files", [])) for asset in assets_dict) if assets_dict else 0 ) items_per_second = item_count / elapsed_time if elapsed_time > 0 else 0 if logger: logger.info( f"Processed {item_count} files in {elapsed_time:.2f} seconds ({items_per_second:.2f} items/s) " f"in folder '{os.path.basename(folder_path.rstrip('/'))}'" ) return assets_dict return None def _is_asset_folders(folder_path: str, logger: Any) -> bool: """Check if the folder contains asset folders. Args: folder_path (str): The path to the folder to check. logger (Any): Logger instance for debug output. Returns: bool: True if the folder contains asset folders, False otherwise. """ try: if not os.path.exists(folder_path): return False for item in os.listdir(folder_path): if ( (len(item) > 1 and item[0] == "." and item[1] != ".") or item.startswith("@") or item == "tmp" ): logger.debug(f"Skipping hidden item: {item}") continue if os.path.isdir(os.path.join(folder_path, item)): return True return False except Exception as exc: logger.error(f"Error checking asset folders in {folder_path}: {exc}") return False def process_selected_files( file_paths: List[str], logger: Any, asset_folders: bool = False ) -> List[Dict]: """Group and parse selected file paths into assets_dict. Args: file_paths (List[str]): List of file paths. logger (Any): Logger instance. asset_folders (bool): Whether files are grouped in asset folders. Returns: List[Dict]: List of structured asset dictionaries. """ assets_dict = [] if asset_folders: folder_groups = defaultdict(list) for file_path in file_paths: if file_path.startswith("."): continue folder_name = os.path.basename(os.path.dirname(file_path)) folder_groups[folder_name].append(file_path) for folder_name, files in folder_groups.items(): folder_path = os.path.dirname(files[0]) base_files = [os.path.basename(f) for f in files] try: assets_dict.append( parse_folder_group(folder_path, folder_name, base_files) ) except Exception as exc: logger.error( f"Error parsing folder group '{folder_name}' in folder '{folder_path}': {exc}" ) continue else: groups = defaultdict(list) normalized_map = {} for file_path in file_paths: filename = os.path.basename(file_path) if filename.startswith("."): continue title = filename.rsplit(".", 1)[0] title = unidecode(html.unescape(title)) title = re.sub(illegal_chars_regex, "", title) raw_title = season_pattern.split(title)[0].strip() normalized_title = remove_special_chars.sub("", raw_title.lower()) if normalized_title in normalized_map: match_key = normalized_map[normalized_title] groups[match_key].append(file_path) else: groups[raw_title].append(file_path) normalized_map[normalized_title] = raw_title for base_name, files in groups.items(): folder = os.path.dirname(files[0]) if files else "" base_files = [os.path.basename(f) for f in files] try: assets_dict.append(parse_file_group(folder, base_name, base_files)) except Exception as exc: logger.error( f"Error parsing file group '{base_name}' in folder '{folder}': {exc}" ) continue return assets_dict ================================================ FILE: util/scheduler.py ================================================ from datetime import datetime from logging import Logger from typing import Dict from croniter import croniter from dateutil import tz """Module to determine if the current time matches specified scheduling criteria.""" next_run_times: Dict[str, datetime] = {} def check_schedule(script_name: str, schedule: str, logger: Logger) -> bool: """Check if the current time matches the given schedule for a script. Args: script_name: The name of the script being checked. schedule: The scheduling string defining when the script should run. logger: Logger instance for logging debug and error messages. Returns: True if the current time matches the schedule, False otherwise. """ try: now: datetime = datetime.now() try: frequency, data = schedule.split("(") except ValueError: logger.error( f"Invalid schedule format: {schedule} for script: {script_name}" ) return False data = data[:-1] if frequency == "hourly": return int(data) == now.minute if frequency == "daily": times = data.split("|") for time in times: hour, minute = map(int, time.split(":")) if now.hour == hour and now.minute == minute: return True if frequency == "weekly": days = [day.split("@")[0] for day in data.split("|")] times = [day.split("@")[1] for day in data.split("|")] current_day = now.strftime("%A").lower() for day, time in zip(days, times): hour, minute = map(int, time.split(":")) if current_day == day or ( current_day == "sunday" and day == "saturday" ): if now.hour == hour and now.minute == minute: return True if frequency == "monthly": day_str, time_str = data.split("@") day = int(day_str) hour, minute = map(int, time_str.split(":")) if now.day == day and now.hour == hour and now.minute == minute: return True if frequency == "range": ranges = data.split("|") for start_end in ranges: start, end = start_end.split("-") start_month, start_day = map(int, start.split("/")) end_month, end_day = map(int, end.split("/")) start_date = datetime(now.year, start_month, start_day) end_date = datetime(now.year, end_month, end_day) if start_date <= now <= end_date: return True if frequency == "cron": local_tz = tz.tzlocal() local_date = datetime.now(local_tz) current_time = local_date.replace(second=0, microsecond=0) logger.debug(f"Local time: {current_time}") next_run = next_run_times.get(script_name) if next_run is None: next_run = croniter(data, local_date).get_next(datetime) next_run_times[script_name] = next_run logger.debug(f"Next run for {script_name}: {next_run}") if next_run <= current_time: next_run = croniter(data, local_date).get_next(datetime) next_run_times[script_name] = next_run logger.debug(f"Next run for {script_name}: {next_run}\n") return True logger.debug( f"Next run time for script {script_name}: {next_run} is in the future\n" ) return False return False except ValueError as e: logger.error(f"Invalid schedule: {schedule} for script: {script_name}") logger.error(f"Error: {e}", exc_info=True) return False ================================================ FILE: util/template/config_template.json ================================================ { "schedule": { "border_replacerr": "", "health_checkarr": "", "labelarr": "", "nohl": "", "sync_gdrive": "", "poster_cleanarr": "", "poster_renamerr": "", "renameinatorr": "", "unmatched_assets": "", "upgradinatorr": "", "jduparr": "" }, "instances": { "radarr": {}, "sonarr": {}, "plex": {} }, "notifications": { "poster_renamerr": {}, "poster_cleanarr": {}, "unmatched_assets": {}, "health_checkarr": {}, "labelarr": {}, "upgradinatorr": {}, "renameinatorr": {}, "nohl": {}, "jduparr": {}, "main": {} }, "sync_gdrive": { "log_level": "info", "client_id": "", "client_secret": "", "token": "", "gdrive_sa_location": "", "gdrive_list": [{}] }, "poster_renamerr": { "log_level": "info", "dry_run": false, "sync_posters": false, "action_type": "copy", "asset_folders": false, "print_only_renames": false, "run_border_replacerr": false, "incremental_border_replacerr": false, "source_dirs": [], "destination_dir": "", "instances": [] }, "border_replacerr": { "log_level": "info", "dry_run": false, "source_dirs": [], "destination_dir": "", "border_width": 26, "skip": false, "exclusion_list": [], "border_colors": [], "holidays": {} }, "unmatched_assets": { "log_level": "info", "source_dirs": [], "instances": [], "ignore_root_folders": [], "ignore_collections": [] }, "poster_cleanarr": { "log_level": "info", "dry_run": true, "source_dirs": [], "instances": [], "ignore_media": [] }, "upgradinatorr": { "log_level": "info", "dry_run": false, "instances_list": [] }, "renameinatorr": { "log_level": "info", "dry_run": false, "rename_folders": true, "count": 100, "radarr_count": 0, "sonarr_count": 0, "tag_name": "", "ignore_tag": "", "enable_batching": false, "instances": [] }, "nohl": { "log_level": "info", "dry_run": false, "searches": 10, "print_files": false, "source_dirs": [], "exclude_profiles": [], "exclude_movies": [], "exclude_series": [], "instances": [] }, "labelarr": { "log_level": "info", "dry_run": false, "mappings": [] }, "health_checkarr": { "log_level": "info", "dry_run": false, "instances": [] }, "jduparr": { "log_level": "info", "dry_run": false, "source_dirs": [] }, "main": { "log_level": "info", "theme": "dark", "update_notifications": false } } ================================================ FILE: util/utility.py ================================================ import copy import datetime import html import json import math import os import re from pathlib import Path from types import SimpleNamespace from typing import Any, Dict, List, Optional import yaml from plexapi.exceptions import NotFound from tqdm import tqdm from unidecode import unidecode from util.constants import illegal_chars_regex from util.construct import generate_title_variants from util.normalization import normalize_titles def print_json(data: Any, logger: Any, module_name: str, type_: str) -> None: """Write data as JSON to a debug file and log the action. Args: data (Any): Data to write as JSON. logger (Any): Logger instance. module_name (str): Module name for directory path. type_ (str): Type used for filename. """ log_base = os.getenv("LOG_DIR") if log_base: debug_dir = Path(log_base) / module_name / "debug" else: debug_dir = Path(__file__).resolve().parents[1] / "logs" / module_name / "debug" debug_dir.mkdir(parents=True, exist_ok=True) assets_file = debug_dir / f"{type_}.json" with open(assets_file, "w") as f: json.dump(data, f, indent=2) logger.debug(f"Wrote {type_} to {assets_file}") def print_settings(logger: Any, module_config: SimpleNamespace) -> None: """Print sanitized settings from module_config in YAML format. Args: logger (Any): Logger instance. module_config (SimpleNamespace): Configuration object. """ logger.debug(create_table([["Script Settings"]])) def ns_to_dict(obj: Any) -> Any: if isinstance(obj, SimpleNamespace): return {k: ns_to_dict(v) for k, v in vars(obj).items()} if isinstance(obj, dict): return {k: ns_to_dict(v) for k, v in obj.items()} if isinstance(obj, list): return [ns_to_dict(i) for i in obj] return obj raw = { k: v for k, v in vars(module_config).items() if k not in ("module_name", "instances_config") } sanitized = copy.deepcopy(ns_to_dict(raw)) def _redact(obj: Any) -> None: if isinstance(obj, dict): for key, val in obj.items(): kl = key.lower() if val is None: continue if "password" in kl: obj[key] = redact_sensitive_info(str(val), password=True) elif "webhook" in kl: obj[key] = redact_sensitive_info(str(val), password=False) else: _redact(val) elif isinstance(obj, list): for item in obj: if isinstance(item, (dict, list)): _redact(item) _redact(sanitized) try: yaml_output = yaml.dump( {getattr(module_config, "module_name", "settings"): sanitized}, sort_keys=False, allow_unicode=True, default_flow_style=False, ) logger.debug("\n" + yaml_output) except Exception: logger.warning( "Failed to render config as YAML; falling back to key:value lines." ) for key, value in sanitized.items(): display = value if isinstance(value, str) else str(value) logger.debug(f"{key}: {display}") logger.debug(create_bar("-")) def create_table(data: List[List[Any]]) -> str: """Create a formatted table string from 2D data list. Args: data (List[List[Any]]): Data to create the table from. Returns: str: Formatted table string. """ if not data: return "No data provided." num_rows = len(data) num_cols = len(data[0]) col_widths = [ max(len(str(data[row][col])) for row in range(num_rows)) for col in range(num_cols) ] col_widths = [max(width + 2, 5) for width in col_widths] total_width = sum(col_widths) + num_cols - 1 min_width = 76 if total_width < min_width: additional_width = min_width - total_width extra_width_per_col = additional_width // num_cols remainder = additional_width % num_cols for i in range(num_cols): col_widths[i] += extra_width_per_col if remainder > 0: col_widths[i] += 1 remainder -= 1 total_width = sum(col_widths) + num_cols - 1 table = "\n" table += "_" * (total_width + 2) + "\n" for row in range(num_rows): table += "|" for col in range(num_cols): cell_content = str(data[row][col]) padding = col_widths[col] - len(cell_content) left_padding = padding // 2 right_padding = padding - left_padding separator = "|" if col < num_cols - 1 else "|" table += ( f"{' ' * left_padding}{cell_content}{' ' * right_padding}{separator}" ) table += "\n" if row < num_rows - 1: table += "|" + "-" * total_width + "|\n" table += "‾" * (total_width + 2) return table def get_plex_data( plex: Any, library_names: List[str], logger: Any, include_smart: bool, collections_only: bool, ) -> List[Dict[str, Any]]: """Retrieve data from Plex libraries or collections. Args: plex (Any): Plex instance. library_names (List[str]): Names of libraries to get data from. logger (Any): Logger instance. include_smart (bool): Whether to include smart collections. collections_only (bool): If True, only retrieve collection data. Returns: List[Dict[str, Any]]: List of dictionaries containing Plex data. """ plex_list: List[Dict[str, Any]] = [] collection_names: Dict[str, List[str]] = {} library_data: Dict[str, Any] = {} for library_name in library_names: try: library = plex.library.section(library_name) except NotFound: logger.error( f"Error: Library '{library_name}' not found, check your settings and try again." ) continue if collections_only: if include_smart: collection_names[library_name] = [ c.title for c in library.search(libtype="collection") ] else: collection_names[library_name] = [ c.title for c in library.search(libtype="collection") if not c.smart ] else: library_data[library_name] = library.all() if collections_only: libraries = list(collection_names.items()) with progress( libraries, desc="Libraries", total=len(libraries), unit="library", logger=logger, ) as outer: for library_name, titles in outer: start_time = datetime.datetime.now() with progress( titles, desc=f"Processing Plex collections in '{library_name}'", total=len(titles), unit="collection", logger=logger, leave=False, ) as inner: for title in inner: title_unescaped = unidecode(html.unescape(title)) normalized_title = normalize_titles(title_unescaped) alternate_titles = generate_title_variants(title_unescaped) folder = illegal_chars_regex.sub("", title_unescaped) plex_list.append( { "title": title_unescaped, "normalized_title": normalized_title, "location": library_name, "year": None, "folder": folder, "alternate_titles": alternate_titles[ "alternate_titles" ], "normalized_alternate_titles": alternate_titles[ "normalized_alternate_titles" ], } ) end_time = datetime.datetime.now() elapsed = (end_time - start_time).total_seconds() rate = len(titles) / elapsed if elapsed > 0 else 0 logger.debug( f"Processed {len(titles)} collections in '{library_name}' in {elapsed:.2f}s ({rate:.2f} items/s)" ) return plex_list def create_bar(middle_text: str) -> str: """Create a separation bar with text centered. Args: middle_text (str): Text to place in center of bar. Returns: str: Formatted separation bar. """ total_length = 80 if len(middle_text) == 1: remaining_length = total_length - len(middle_text) - 2 left_side_length = 0 right_side_length = remaining_length return f"\n{middle_text * left_side_length}{middle_text}{middle_text * right_side_length}\n" remaining_length = total_length - len(middle_text) - 4 left_side_length = math.floor(remaining_length / 2) right_side_length = remaining_length - left_side_length return f"\n{'*' * left_side_length} {middle_text} {'*' * right_side_length}\n" def redact_sensitive_info(text: str, password: bool = False) -> str: """Redact sensitive info from text. Args: text (str): Text to redact. password (bool): If True, redact entire text. Returns: str: Redacted text. """ if password: return "[redacted]" text = re.sub( r"https://discord\.com/api/webhooks/[^/]+/\S+", r"https://discord.com/api/webhooks/[redacted]", text, ) text = re.sub( r"\b(\w{24})-[a-zA-Z0-9_-]{24}\.apps\.googleusercontent\.com\b", r"[redacted].apps.googleusercontent.com", text, ) text = re.sub(r'(?<=refresh_token": ")([^"]+)(?=")', r"[redacted]", text) text = re.sub(r"(\b[A-Za-z0-9_-]{33}\b)", r"[redacted]", text) text = re.sub(r'(?<=access_token": ")([^"]+)(?=")', r"[redacted]", text) text = re.sub(r"GOCSPX-\S+", r"GOCSPX-[redacted]", text) pattern_client_id = r"(-i).*?(\.apps\.googleusercontent\.com)" text = re.sub( pattern_client_id, r"\1 [redacted]\2", text, flags=re.DOTALL | re.IGNORECASE ) pattern_file_arg = r"(-f)\s\S+" text = re.sub( pattern_file_arg, r"\1 [redacted]", text, flags=re.DOTALL | re.IGNORECASE ) return text def progress( iterable: Any, desc: Optional[str] = None, total: Optional[int] = None, unit: Optional[str] = None, logger: Optional[Any] = None, leave: bool = True, **kwargs: Any, ) -> Any: """Wrap tqdm to toggle progress bars based on LOG_TO_CONSOLE env var. Args: iterable (Any): Iterable to wrap. desc (Optional[str]): Description for progress bar. total (Optional[int]): Total iterations. unit (Optional[str]): Unit of progress. logger (Optional[Any]): Logger instance. leave (bool): Keep progress bar after completion. **kwargs: Additional tqdm args. Returns: tqdm or DummyProgress: Progress bar or dummy context manager. """ log_console = os.environ.get("LOG_TO_CONSOLE", "").lower() in ("1", "true", "yes") class DummyProgress: def __init__(self, iterable: Any) -> None: self.iterable = iterable def __enter__(self) -> "DummyProgress": return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: pass def __iter__(self): return iter(self.iterable) def update(self, n: int = 1) -> None: pass if not log_console: return DummyProgress(iterable) return tqdm(iterable, desc=desc, total=total, unit=unit, leave=leave, **kwargs) def redact_apis(obj: Any) -> None: """Recursively redact any 'api' keys in dicts or nested lists. Args: obj (Any): Object to redact API keys in-place. """ if isinstance(obj, dict): for key, value in obj.items(): if key.lower() == "api": obj[key] = "REDACTED" else: redact_apis(value) elif isinstance(obj, list): for item in obj: redact_apis(item) def get_log_dir(module_name: str) -> str: """Return the log directory for a given module.""" log_base = os.getenv("LOG_DIR") if log_base: log_dir = Path(log_base) / module_name else: log_dir = Path(__file__).resolve().parents[1] / "logs" / module_name os.makedirs(log_dir, exist_ok=True) return str(log_dir) ================================================ FILE: util/version.py ================================================ import os import re import subprocess import threading import time from pathlib import Path import requests from util.notification import send_notification BASE = Path(__file__).parents[1] / "VERSION" def get_version() -> str: """Get the version string based on environment variables or git information.""" base_version = BASE.read_text().strip() ci_build = os.getenv("BUILD_NUMBER") ci_branch = os.getenv("BRANCH") if ci_build and ci_branch: return f"{base_version}.{ci_branch}{ci_build}" try: branch = ( subprocess.check_output( ["git", "rev-parse", "--abbrev-ref", "HEAD"], stderr=subprocess.DEVNULL ) .decode() .strip() ) commit_count = ( subprocess.check_output( ["git", "rev-list", "--count", "HEAD"], stderr=subprocess.DEVNULL ) .decode() .strip() ) return f"{base_version}.{branch}{commit_count}" except Exception: return base_version def _check_remote_version(local_version, branch, logger): # Fetch remote VERSION file from GitHub raw_url = f"https://raw.githubusercontent.com/Drazzilb08/daps/{branch}/VERSION" try: remote_version = requests.get(raw_url, timeout=5) if not remote_version.ok: logger.debug( f"Could not fetch remote VERSION: {remote_version.status_code}" ) return None, None, False remote_version_str = remote_version.text.strip() except Exception as e: logger.debug(f"Exception fetching VERSION: {e}") return None, None, False # Get remote build number (commit count) api_url = ( f"https://api.github.com/repos/Drazzilb08/daps/commits?sha={branch}&per_page=1" ) try: resp = requests.get(api_url, timeout=5) if not resp.ok: logger.debug(f"Could not fetch commit count: {resp.status_code}") return remote_version_str, None, False link = resp.headers.get("Link") if not link: build_count = 1 else: match = re.search(r"&page=(\d+)>; rel=\"last\"", link) build_count = int(match.group(1)) if match else 1 except Exception as e: logger.debug(f"Exception fetching build count: {e}") return remote_version_str, None, False # Construct remote full version remote_full = f"{remote_version_str}.{branch}{build_count}" # Compare (mimic your JS logic) update_available = False local_parts = local_version.strip().split(".") if len(local_parts) >= 4: local_base = ".".join(local_parts[:3]) local_branch_build = local_parts[3] m = re.match(r"([a-zA-Z]+)(\d+)", local_branch_build) if m: local_branch = m.group(1) local_build = int(m.group(2)) else: local_branch = local_branch_build.rstrip("0123456789") local_build = int(local_branch_build[len(local_branch) :] or 0) if remote_version_str == local_base and build_count > local_build: update_available = True elif remote_version_str != local_base: update_available = True return remote_full, build_count, update_available def start_version_check(config, logger, interval=3600): """Starts a background thread to check for version updates.""" def poll(): local_version = get_version() local_parts = local_version.strip().split(".") if len(local_parts) < 4: return branch_and_build = local_parts[3] m = re.match(r"([a-zA-Z]+)", branch_and_build) branch = m.group(1) if m else "main" logger.info(f"[VERSION CHECK] Local version: {local_version}, branch: {branch}") while True: remote_full, build_count, update_available = _check_remote_version( local_version, branch, logger ) if update_available: logger.debug( f"[VERSION CHECK] Update available. Local: {local_version}, Remote: {remote_full}, Build Count: {build_count}" ) output = { "local_version": local_version, "remote_version": remote_full, "color": "FF0000", # Red hex string (or 0xFF0000 as int, but string is flexible) } # Make sure config.module_name = "version_check" or similar for formatting to work config.module_name = "version_check" send_notification(logger, "version_check", config, output) else: logger.debug( f"[VERSION CHECK] No update. Local: {local_version}, Remote: {remote_full}" ) time.sleep(interval) thread = threading.Thread(target=poll, daemon=True) thread.start() ================================================ FILE: web/server.py ================================================ import copy import multiprocessing import os import time from pathlib import Path from threading import Thread from typing import Any, Dict, List, Optional import requests import uvicorn import yaml from dotenv import load_dotenv from fastapi import ( APIRouter, BackgroundTasks, Depends, FastAPI, HTTPException, Request, ) from fastapi.requests import Request as FastAPIRequest from fastapi.responses import ( FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, ) from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from util.config import Config from util.utility import redact_apis from util.version import get_version load_dotenv(override=True) if os.environ.get("DOCKER_ENV"): LOG_BASE_DIR = "/config/logs" else: LOG_BASE_DIR = str((Path(__file__).parent.parent / "logs").resolve()) def load_config_dict() -> Dict[str, Any]: """Loads the configuration dictionary from file.""" config_path = Config("main").config_path with open(config_path, "r") as f: return yaml.safe_load(f) def save_config_dict(cfg: Dict[str, Any]) -> None: """Saves the configuration dictionary to file.""" config_path = Config("main").config_path with open(config_path, "w") as f: yaml.safe_dump(cfg, f, sort_keys=False) class RunRequest(BaseModel): """Request schema for running a module.""" module: str class CancelRequest(BaseModel): """Request schema for canceling a module.""" module: str class TestInstanceRequest(BaseModel): """Request schema for testing a service instance.""" service: str name: str url: str api: Optional[str] = None class NotificationPayload(BaseModel): """Request schema for test notification.""" module: str notifications: Dict[str, Any] def get_config() -> Dict[str, Any]: """Dependency: returns the current configuration.""" return load_config_dict() def get_logger(request: Request) -> Any: """Dependency: returns the logger from app state.""" return request.app.state.logger # ==== App and State ==== run_processes: Dict[str, multiprocessing.Process] = {} run_time: Dict[str, float] = {} app = FastAPI() router = APIRouter() app.state.logger = None # ==== Centralized Error Handler ==== @app.exception_handler(Exception) async def handle_exception(request: FastAPIRequest, exc: Exception): """Handles uncaught exceptions and logs them.""" logger = getattr(request.app.state, "logger", None) if logger: logger.error(f"[WEB] Unhandled Exception: {exc}", exc_info=True) return JSONResponse(status_code=500, content={"error": str(exc)}) # ==== Helper: Log Route ==== def log_route(logger: Any, path: str, method: str = "GET") -> None: """Logs web route access.""" logger.debug(f"[WEB] Serving {method} {path}") # ==== Routes ==== @app.get("/api/version", response_model=None) async def get_version_route( request: Request, logger: Any = Depends(get_logger) ) -> PlainTextResponse: """Returns the current version string.""" try: version = get_version() logger.debug(f"[WEB] Serving GET /api/version: {version}") except Exception: version = "unknown" return PlainTextResponse(version) @app.post("/api/test-notification", response_model=None) async def test_notification( payload: NotificationPayload, logger: Any = Depends(get_logger) ) -> Any: """Sends a test notification and returns the result.""" logger.debug( "[WEB] Serving POST /api/test-notification for module: %s", payload.module ) from util.notification import send_test_notification try: results = send_test_notification(payload.dict(), logger) logger.debug("[WEB] Test notification results: %s", results) return results except Exception as e: logger.error("[WEB] Test notification failed: %s", e) return JSONResponse(status_code=500, content={"error": str(e)}) app.mount( "/web/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static", ) @app.get("/", response_class=HTMLResponse, response_model=None) async def root() -> HTMLResponse: """Serves the main index.html page.""" html_path = Path(__file__).parent / "templates" / "index.html" try: return HTMLResponse(content=html_path.read_text(), status_code=200) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) @app.get("/api/config", response_model=None) async def get_config_route( config: Dict[str, Any] = Depends(get_config), logger: Any = Depends(get_logger) ) -> Dict[str, Any]: """Returns the current configuration as a dictionary.""" log_route(logger, "/api/config") return config @app.post("/api/config", response_model=None) async def update_config_route( request: Request, logger: Any = Depends(get_logger), config: Dict[str, Any] = Depends(get_config), ) -> Any: """Updates the configuration file with provided values.""" try: incoming = await request.json() incoming_copy = copy.deepcopy(incoming) if "instances" in incoming_copy: redact_apis(incoming_copy["instances"]) logger.debug("[WEB] Serving POST /api/config with payload: %s", incoming_copy) current_config = load_config_dict() new_schedule = incoming.get("schedule") new_instances = incoming.get("instances") new_notifications = incoming.get("notifications") if new_schedule is not None: if "schedule" not in current_config: current_config["schedule"] = {} for key, value in new_schedule.items(): current_config["schedule"][key] = value if new_instances is not None: current_config["instances"] = new_instances if new_notifications is not None: current_config["notifications"] = new_notifications for mod_name, mod_payload in incoming.items(): if mod_name in ("schedule", "instances", "notifications"): continue if ( "bash_scripts" in current_config and mod_name in current_config["bash_scripts"] ): target = current_config["bash_scripts"][mod_name] else: target = current_config.setdefault(mod_name, {}) for field, val in mod_payload.items(): target[field] = val save_config_dict(current_config) logger.info("[WEB] Config entries updated") return {"status": "success"} except Exception as e: logger.error("[WEB] Config update failed: %s", e) return JSONResponse(status_code=500, content={"error": str(e)}) @app.get("/api/list", response_model=None) async def list_dir(path: str = "/") -> Any: """Returns subdirectories for a given path.""" resolved = Path(path).expanduser().resolve() if not resolved.exists() or not resolved.is_dir(): try: return JSONResponse(status_code=400, content={"error": "Invalid path"}) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) dirs = [ p.name for p in resolved.iterdir() if p.is_dir() and not p.name.startswith(".") ] dirs.sort() return {"directories": dirs} @app.get("/api/plex/libraries", response_model=None) async def get_plex_libraries( instance: str, config: Dict[str, Any] = Depends(get_config), logger: Any = Depends(get_logger), ) -> Any: """Returns library names for a specific Plex instance.""" try: plex_data = config.get("instances", {}).get("plex", {}).get(instance) if not plex_data: return JSONResponse( status_code=404, content={"error": "Plex instance not found"} ) base_url = plex_data.get("url") token = plex_data.get("api") if not base_url or not token: return JSONResponse( status_code=400, content={"error": "Missing Plex API credentials"} ) headers = {"X-Plex-Token": token} url = f"{base_url}/library/sections" try: logger.debug( "[WEB] Serving GET /api/plex/libraries for instance: %s", instance ) res = requests.get(url, headers=headers, timeout=5) except requests.exceptions.RequestException as req_exc: logger.error(f"[WEB] Plex request failed: {req_exc}") return JSONResponse( status_code=502, content={"error": f"Failed to connect to Plex server: {req_exc}"}, ) if not res.ok: return JSONResponse( status_code=res.status_code, content={"error": res.text} ) xml = res.text import xml.etree.ElementTree as ET root = ET.fromstring(xml) libraries = [ el.attrib["title"] for el in root.findall(".//Directory") if "title" in el.attrib ] return libraries except Exception as e: logger.error(f"[WEB] Unexpected error in /api/plex/libraries: {e}") return JSONResponse(status_code=500, content={"error": str(e)}) @app.post("/api/run", response_model=None) async def run_module( data: RunRequest, background: BackgroundTasks, logger: Any = Depends(get_logger) ) -> Any: """Starts a module process in the background if not already running.""" from main import list_of_python_modules, run_module module = data.module logger.debug("[WEB] Serving POST /api/run for module: %s", module) if module not in list_of_python_modules: logger.error(f"[WEB] Unknown module: {module}") return JSONResponse( status_code=400, content={"error": f"Unknown module: {module}"} ) if module in run_processes and run_processes[module].is_alive(): logger.error(f"[WEB] Module {module} is already running") return JSONResponse( status_code=400, content={"error": f"Module {module} is already running"} ) def background_run(): start = time.time() logger.info(f"[WEB] Background starting module: {module}") run_time[module] = start proc = run_module(module) if proc: run_processes[module] = proc else: logger.error(f"[WEB] Failed to start module: {module}") background.add_task(background_run) return {"status": "starting", "module": module} @app.get("/api/status", response_model=None) async def module_status(module: str, logger: Any = Depends(get_logger)) -> Any: """Queries the running status of a given module.""" proc = run_processes.get(module) if not proc and getattr(app.state, "manager", None): proc = app.state.manager.running_modules.get(module) alive = False if proc: alive = proc.is_alive() if not alive: start_time = run_time.pop(module, None) if start_time is not None: duration = time.time() - start_time hours, rem = divmod(duration, 3600) minutes, seconds = divmod(rem, 60) human_duration = f"{int(hours)}:{int(minutes):02d}:{int(seconds):02d}" logger.info( f"[WEB] Module: {module} finished in {human_duration} (raw: {duration:.2f} seconds)" ) if proc in run_processes.values(): del run_processes[module] elif ( getattr(app.state, "manager", None) and module in app.state.manager.running_modules ): del app.state.manager.running_modules[module] try: return {"module": module, "running": alive} except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) @app.post("/api/cancel", response_model=None) async def cancel_module(data: CancelRequest, logger: Any = Depends(get_logger)) -> Any: """Cancels a running module.""" module = data.module proc = run_processes.get(module) scheduled = False if not proc and getattr(app.state, "manager", None): proc = app.state.manager.running_modules.get(module) scheduled = True if not proc: try: return JSONResponse( status_code=400, content={"error": "Module not running"} ) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) proc.terminate() logger.info(f"[WEB] Manually cancelled module: {module}") if scheduled: del app.state.manager.running_modules[module] else: del run_processes[module] return {"status": "cancelled", "module": module} @app.post("/api/test-instance", response_model=None) async def test_instance( data: TestInstanceRequest, logger: Any = Depends(get_logger) ) -> Any: """Tests the connection to a service instance and returns the result.""" service = data.service name = data.name url = data.url api = data.api if not url: return JSONResponse(status_code=400, content={"error": "Missing URL"}) try: url = url.rstrip("/") if service == "plex": headers = {"X-Plex-Token": api} if api else {} test_url = f"{url}/library/sections" else: headers = {"X-Api-Key": api} if api else {} test_url = f"{url}/api/v3/system/status" logger.info(f"[WEB] Testing: {name.upper()} - URL: {test_url}") resp = requests.get(test_url, headers=headers, timeout=5) if resp.ok: logger.info("[WEB] Connection test: OK") return {"ok": True, "status": resp.status_code} if resp.status_code == 401: logger.error( "[WEB] Connection test code 401: Unauthorized - Invalid credentials" ) return JSONResponse(status_code=401, content={"error": "Unauthorized"}) if resp.status_code == 404: logger.error("[WEB] Connection test code 404: Not Found - Invalid URL") return JSONResponse(status_code=404, content={"error": "Not Found"}) logger.error(f"[WEB] Connection test code {resp.status_code}: {resp.text}") return JSONResponse(status_code=resp.status_code, content={"error": resp.text}) except Exception as e: logger.error(f"[WEB] Connection test failed for {name} ({url}): {e}") return JSONResponse(status_code=500, content={"error": str(e)}) @app.post("/api/create-folder", response_model=None) async def create_folder(path: str, logger: Any = Depends(get_logger)) -> Any: """Creates a folder at the given path.""" resolved = Path(path).expanduser().resolve() try: logger.info(f"[WEB] Creating folder: {resolved}") resolved.mkdir(parents=True, exist_ok=False) return {"status": "created"} except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) @app.get("/pages/{fragment_name}", response_class=HTMLResponse, response_model=None) async def serve_fragment(fragment_name: str, logger: Any = Depends(get_logger)) -> Any: """Serves a named HTML fragment from the fragments directory.""" html_path = Path(__file__).parent / "templates" / "pages" / f"{fragment_name}.html" if not html_path.exists(): raise HTTPException(status_code=404, detail="Fragment not found") try: return HTMLResponse(content=html_path.read_text(), status_code=200) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) # ========== Logs API ========== @router.get("/api/logs") async def list_logs(logger: Any = Depends(get_logger)) -> Dict[str, List[str]]: """Lists available log files for each module.""" logger.info("[WEB] Listing logs in %s", LOG_BASE_DIR) logs_data: Dict[str, List[str]] = {} if not os.path.exists(LOG_BASE_DIR): logger.error("[WEB] Log directory not found: %s", LOG_BASE_DIR) raise HTTPException(status_code=404, detail="Log directory not found.") for module in os.listdir(LOG_BASE_DIR): if module == "debug": logger.debug(f"[WEB] Skipping {module} folder") continue module_path = os.path.join(LOG_BASE_DIR, module) if os.path.isdir(module_path): files = sorted( f for f in os.listdir(module_path) if os.path.isfile(os.path.join(module_path, f)) ) logs_data[module] = files logger.info("[WEB] Logs listed: %s", list(logs_data.keys())) return logs_data @router.get("/api/logs/{module}/{filename}", response_class=PlainTextResponse) async def read_log( module: str, filename: str, logger: Any = Depends(get_logger) ) -> str: """Reads a specific log file and returns its content as plain text.""" safe_module = os.path.basename(module) safe_filename = os.path.basename(filename) if safe_module == "debug": raise HTTPException(status_code=404, detail="Log file not found.") log_path = os.path.join(LOG_BASE_DIR, safe_module, safe_filename) if "debug" in os.path.relpath(log_path, LOG_BASE_DIR).split(os.sep): raise HTTPException(status_code=404, detail="Log file not found.") if not os.path.exists(log_path): raise HTTPException(status_code=404, detail="Log file not found.") with open(log_path, "r", encoding="utf-8", errors="ignore") as f: content = f.read() return content @app.post("/api/poster-search-stats", response_model=None) async def poster_search_stats(request: Request, logger: Any = Depends(get_logger)): """Returns stats and file list for a given poster location directory.""" try: data = await request.json() location = data.get("location") logger.debug( f"[WEB] Serving POST /api/poster-search-stats for location: {location}" ) if not location or not os.path.isdir(location): return JSONResponse(status_code=400, content={"error": "Invalid location"}) total_size = 0 poster_files = [] for root, dirs, files in os.walk(location): for f in files: fp = os.path.join(root, f) try: stat = os.stat(fp) total_size += stat.st_size rel_path = os.path.relpath(fp, location) if rel_path.startswith("tmp" + os.sep) or rel_path.startswith( "tmp/" ): continue poster_files.append(rel_path) except Exception as e: logger.error(f"SKIPPED FILE: {fp} | ERROR: {e}") continue return { "file_count": len(poster_files), "size_bytes": total_size, "files": sorted(poster_files), } except Exception as e: logger.error(f"poster-search-stats error: {e}") return JSONResponse(status_code=500, content={"error": str(e)}) @app.get("/api/preview-poster") async def preview_poster(location: str, path: str, logger: Any = Depends(get_logger)): """ Returns the requested poster image file as a response if it exists within location. """ try: base_dir = Path(location).resolve() file_path = (base_dir / path).resolve() # Security: prevent path traversal if not str(file_path).startswith(str(base_dir)): return JSONResponse(status_code=403, content={"error": "Invalid path"}) if not file_path.exists() or not file_path.is_file(): return JSONResponse(status_code=404, content={"error": "File not found"}) # Basic file type check (optional: just for images) if file_path.suffix.lower() not in [".jpg", ".jpeg", ".png", ".webp", ".bmp"]: return JSONResponse( status_code=415, content={"error": "Unsupported file type"} ) logger.debug(f"[WEB] Serving image preview: {file_path}") return FileResponse(str(file_path)) except Exception as e: logger.error(f"[WEB] Preview poster error: {e}") return JSONResponse(status_code=500, content={"error": str(e)}) # ========== Web Server Startup ========== def start_web_server(logger: Any) -> None: """Starts the web server in a background thread and stores logger in app state. Args: logger: Logger instance to use for the app. """ app.state.logger = logger try: app.state.config_data = load_config_dict() except Exception as e: logger.error(f"[WEB] Failed to load config: {e}") app.state.config_data = {} PORT = int(os.environ.get("PORT", 8000)) HOST = os.environ.get("HOST", "127.0.0.1") app.state.logger.info(f"[WEB] Starting web server on {HOST}:{PORT}") web_thread = Thread( target=lambda: uvicorn.run(app, host=HOST, port=PORT, log_level="warning"), daemon=True, ) web_thread.start() app.include_router(router) ================================================ FILE: web/static/css/base.css ================================================ /* ===== Font Imports ===== */ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'); /* ====================================================== ROOT VARIABLES (DARK THEME DEFAULT) ====================================================== */ :root { /* ====== NON-COLOR VARIABLES ====== */ /* ---------- Layout & Container ---------- */ --container-max-width: 70%; --container-padding: 2rem; --container-padding-bottom: 4rem; --container-radius: 8px; --view-frame-padding: 2rem; --card-margin-btm: 1.5rem; --card-padding: 1.5rem; --card-radius: 8px; /* ---------- Typography ---------- */ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; --font-size-base: 17px; --font-size-heading: 32px; --font-size-subheading: 20px; --font-size-base-plus: 1.2rem; --line-height: 1.5; --font-weight-base: 400; --font-weight-heading: 600; --heading-font-size-lg: 1.75rem; /* ---------- Form & Fields ---------- */ --form-padding: 0.6rem 1rem; --form-radius: 6px; --form-font-size: 0.99rem; --form-height: 2.5rem; --field-label-width: 200px; --field-gap: 0.5rem; /* ---------- Button ---------- */ --btn-padding: 0.5rem 1rem; --btn-radius: 4px; --btn-font-size: 0.9rem; /* ---------- Modal & Toasts ---------- */ --overlay-blur: 8px; --toast-position-bottom: 3.3rem; --toast-position-right: 1.5rem; --toast-radius: 6px; --toast-font-size: 0.95rem; --toast-padding: 1rem 1.5rem; --modal-max-width: 600px; --modal-content-width: 50%; --modal-header-font-size: 1.5rem; --modal-header-font-weight: 700; --modal-header-margin: 1rem; --modal-footer-gap: 0.75rem; --modal-footer-margin-top: 1rem; --modal-radius: 8px; --modal-padding: 2rem 2.5rem; --modal-btn-radius: 6px; --modal-btn-padding: 0.6rem 1.25rem; --modal-btn-font-size: 1rem; --modal-btn-font-weight: 600; --modal-btn-margin: 0.5rem 0; /* ---------- Navigation/Dropdown ---------- */ --nav-menu-gap: 2rem; --nav-border-radius: 8px; --nav-dropdown-radius: 4px; --nav-dropdown-bg: transparent; --nav-dropdown-item-padding: 0.75rem 1.25rem; --nav-dropdown-item-radius: 4px; --nav-glass-blur: 26px; --dropdown-shadow: 0 4px 12px var(--shadow); /* ---------- Dashboard/Stats ---------- */ --dashboard-label-background-clip: text; --dashboard-label-text-fill-color: transparent; --dashboard-label-filter: blur(0.15px) brightness(1.07); --stat-bar-gradient: linear-gradient(90deg, var(--info, #4c7ad1), #b6d0ff); /* ---------- Splash/Welcome ---------- */ --splash-card-padding: 2rem 3rem; --splash-card-radius: 12px; --splash-icon-size: 4rem; --splash-header-font-size: 2rem; --splash-header-margin-btm: 0.5rem; --splash-p-opacity: 0.8; /* ---------- Status & Labels ---------- */ --status-font-size: 1rem; --status-margin-top: 1rem; /* ---------- Utility/Transform ---------- */ --translate-neutral: translateY(0); --padding-standard: 0.75rem 1rem; /* ---------- Border/Radius ---------- */ --border-radius: 8px; --border-radius-default: 6px; /* ---------- Toggle Switch ---------- */ --toggle-radius: 24px; --toggle-slider-radius: 50%; --toggle-width: 36px; --toggle-height: 20px; --toggle-slider-size: 14px; --toggle-slider-offset: 3px; --toggle-api-font-size: 1.2rem; --toggle-api-right: 0.75rem; --toggle-api-opacity: 0.7; /* ---------- Notification Card ---------- */ --notification-fieldset-padding: 0.75rem; /* ---------- Log/Log Viewer ---------- */ --log-badge-radius: 5px; --log-badge-padding: 5px 10px; --log-badge-font-size: 0.8rem; --log-badge-z: 9999; --log-badge-opacity: 0; --log-badge-transition: opacity 0.5s; --log-spinner-size: 60px; --log-spinner-radius: 50%; --log-spinner-z: 1000; --log-jump-btn-radius: 20px; --log-jump-btn-font-size: 0.85rem; --log-jump-btn-font-weight: bold; --log-jump-btn-padding: 0.4rem 0.9rem; --log-jump-btn-z: 100; --log-toolbar-gap: 0.75rem; --log-controls-radius: 12px; --log-scroll-btn-radius: 20px; } :root[data-theme='dark'] { /* ===== BASE COLORS ===== */ --bg: #1e262b; --fg: #e8e8e8; --text-color: #e5e5e5; --heading-color: #ffffff; --fg-secondary: #aaa; /* ===== BRAND & ACTION COLORS ===== */ --primary: #ff7300; --focus: #ff9500; --accent: #3e7bfa; --accent-dark: #204288; --success: #32d74b; --success-highlight: #30d16e; --error: #ff375f; --error-highlight: #ff3b30; --caution: #ff9f0a; --info: #5ac8fa; /* ===== LINKS & HIGHLIGHTS ===== */ --link-color: #5ac8fa; --link-hover-color: #007aff; --highlight: #0069f2; --highlight-bg: #ffeaa7; --highlight-color: #222; /* ===== BACKGROUNDS ===== */ --primary-bg: #141414; --secondary-bg: #1e1e1e; --container-bg: #141414; --view-frame-bg: #141414; --overlay-bg: rgba(0, 0, 0, 0.4); /* ===== CARD & CONTAINER ===== */ --card-bg: #202228; --card-hover-bg: #272b33; --card-hover-shadow: 0 6px 20px rgba(0, 0, 0, 0.24); --card-shadow: 0 1px 8px rgba(0, 0, 0, 0.12), 0 4px 24px rgba(0, 0, 0, 0.14); --card-shadow: 0 2px 6px var(--shadow); --card-border: 1px solid #26292d; --container-bg: #141414; /* ===== INPUTS & FORMS ===== */ --form-bg: #2b2e33; --form-shadow: 0 1.5px 8px #0003; --form-focus: #5ac8fa; --form-border: 1px solid var(--shadow); --form-color: var(--fg); --form-invalid-outline: 2px solid #ff9900; --form-invalid-bg: rgba(255, 223, 164, 0.7); --form-invalid-color: #222; /* ===== BUTTONS ===== */ --btn-bg: #007aff; --btn-hover-bg: #0069f2; --btn-color: var(--text-color); /* ===== MODALS & TOASTS ===== */ --modal-bg: #24292e; --modal-color: #e8e8e8; --modal-shadow: 0 8px 32px rgba(0, 0, 0, 0.22); --modal-preset-type-color: #ffe6b8; --modal-preset-type-background: rgba(190, 160, 80, 0.11); --modal-preset-content-color: #cfdbee; --modal-preset-content-background: rgba(100, 110, 130, 0.07); --toast-shadow: 0 0 10px rgba(0, 0, 0, 0.4); /* ===== Shadows & Misc ===== */ --shadow: rgba(0, 0, 0, 0.22); --notification-card-shadow: 0 2px 6px var(--shadow); --splash-card-shadow: 0 8px 24px var(--shadow); --hover-preview-border: #b0b8c6; --img-modal-shadow: #c1c5cb; --loader-bg: #dde2ec; /* ===== Navigation ===== */ --nav-glass-bg: rgba(32, 34, 40, 0.83); --nav-glass-border: 1px solid rgba(255, 255, 255, 0.07); --nav-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); --nav-dropdown-shadow: 0 4px 12px var(--shadow); /* ===== Special Effects & Misc ===== */ --poster-list-hover-bg: #e7e9ed; --poster-list-hover-color: #2b6cb0; --gdrive-tooltip-bg: #f4f6fb; --gdrive-tooltip-color: #242628; --gdrive-tooltip-shadow: #b0b8c6; --gdrive-tooltip-red: #d13434; --gdrive-tooltip-highlight: #fdbe44; --copy-btn-active-background: #e6b800; --copy-btn-hover-color: #e6b800; /* ===== Labelarr & Custom ===== */ --labelarr-label-bg: rgba(210, 180, 90, 0.14); --labelarr-label-color: #a78748; --labelarr-label-empty-color: #85898f; --labelarr-library-bg: rgba(60, 120, 160, 0.09); --labelarr-library-color: #7dc4fa; --labelarr-plex-instance-color: #69d46e; --labelarr-arrow-color: #b0b4bb; --mapping-instance-color: #b0b4bb; /* ===== Log Level Colors ===== */ --log-error: #ff375f; --log-warning: #ffb020; --log-critical: #b1001d; --log-info: #30d16e; --log-debug: #999fa8; /* ===== Tables, Stats, Dashboard ===== */ --stats-footer-color: #c9d0d9; --stats-table-hover-bg: #f3f4f7; --stats-table-hover-color: #23272e; --stats-row-error-bg: #f7e9e9; --preset-card-border: rgba(80, 90, 110, 0.13); /* ===== Logs & Log Viewer ===== */ --log-badge-bg: rgba(0, 0, 0, 0.7); --log-spinner-border: 6px solid var(--card-bg); --log-spinner-border-top: 6px solid var(--primary); --log-jump-btn-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); --log-controls-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); --log-line-hover-bg: rgba(255, 255, 255, 0.03); --log-scroll-btn-border: 1px solid var(--bg); /* ===== Dashboard & Header ===== */ --daps-header-gradient: linear-gradient(90deg, #a6b5c9 0%, #dce3ec 60%, #f4f8fb 100%); --daps-header-shadow: 0 1px 1px rgba(255, 255, 255, 0.6); --daps-header-hover: blur(0.8px) brightness(1.12) drop-shadow(0 2px 10px #a6b5c980); --daps-header-underline: linear-gradient(90deg, #a6b5c9 0%, #dce3ec 60%, #f4f8fb 100%); --dashboard-label-color: transparent; --dashboard-label-shadow: var(--daps-header-shadow); /* ===== Footer & Misc ===== */ --viewframe-border-top: rgba(255, 255, 255, 0.1); --footer-update-badge-color: #fff; --update-tooltip-title-color: #fff; --daps-footer-box-shadow: rgba(0, 0, 0, 0.1); --update-tooltip-versions-color: #bbb; --dir-list-li-hover-background: rgba(0, 0, 0, 0.1); --select2-results--option--highlighted-color: #fff; /* ===== Toggles ===== */ --toggle-off: rgba(255, 255, 255, 0.13); --toggle-on: #3e7bfa; --toggle-thumb-bg: #222; --toggle-thumb-shadow: 0 1.5px 6px rgba(0, 0, 0, 0.34); /* ===== Pills ===== */ --pill-border: 1px solid var(--shadow); --pill-hover-bg: #282d33; --pill-active-bg: #25282d; --pill-hover-shadow: 0 2px 8px rgba(40, 64, 96, 0.08); --terminal-bg: #1a1a1a; --terminal-border: 0.1em solid #333; --terminal-color: #0f0; --terminal-header-bg: #333; --terminal-title-color: #eee; --terminal-control-close: #e33; --terminal-control-min: #ee0; --terminal-control-max: #0b0; --terminal-cursor-color: #0f0; } /* ====================================================== LIGHT THEME OVERRIDES ====================================================== */ :root[data-theme='light'] { /* ====== BASE COLORS ====== */ --bg: #e3e4e8; --fg: #222325; --text-color: #242628; --heading-color: #23272e; --primary: #3e7bfa; --focus: #2a4b80; --accent: #0073e6; /* --accent-dark: #204288; */ /* ====== BRAND & ACTION COLORS ====== */ --success: #32b982; --success-highlight: #49e3a2; --error: #e35c67; --error-highlight: #f37c83; --caution: #ffc857; --info: #4c7ad1; /* ====== LINKS & HIGHLIGHTS ====== */ --link-color: #2b6cb0; --link-hover-color: #417dbe; --highlight: #ff7300; --highlight-bg: #ffeaa7; --highlight-color: #222; /* ====== BACKGROUNDS ====== */ --primary-bg: #edeef0; --secondary-bg: #dee1e7; --container-bg: #edeef0; --view-frame-bg: #edeef0; /* ====== CARD & CONTAINER ====== */ --card-bg: #f5f6f7; --card-hover-bg: #e7e9ed; --card-hover-shadow: 0 6px 20px rgba(60, 72, 90, 0.05); --card-shadow: 0 1px 8px rgba(60, 72, 90, 0.06), 0 4px 24px rgba(60, 72, 90, 0.09); /* ====== INPUTS & FORMS ====== */ --form-bg: #f2f2f4; --form-shadow: 0 1.5px 8px #bbb3; --form-focus: #4c7ad1; --form-padding: 0.6rem 1rem; --form-border: 1px solid var(--shadow); --form-radius: 6px; --form-font-size: 0.99rem; --form-height: 2.5rem; --form-color: var(--fg); --form-invalid-outline: 2px solid #ff9900; --form-invalid-bg: rgba(255, 223, 164, 0.7); --form-invalid-color: #222; /* ====== BUTTONS ====== */ --btn-bg: #4c7ad1; --btn-hover-bg: #4166a0; --btn-color: var(--text-color); /* ====== MODALS & TOASTS ====== */ --overlay-blur: 8px; --overlay-bg: rgba(240, 240, 245, 0.1); --modal-bg: #f1f2f4; --modal-color: #23272e; --modal-shadow: 0 8px 32px rgba(60, 72, 90, 0.08); --toast-shadow: 0 0 10px rgba(60, 72, 90, 0.11); /* ====== DASHBOARD & DAPS HEADER ====== */ --daps-header-gradient: linear-gradient(90deg, #343741 0%, #51545c 70%, #b3bac7 100%); --daps-header-shadow: 0 2px 5px rgba(60, 62, 70, 0.13), 0 1.5px 6px rgba(90, 90, 90, 0.09); --daps-header-hover: blur(0.8px) brightness(1.11) drop-shadow(0 2px 10px #34374160); --daps-header-underline: linear-gradient(90deg, #282a32 0%, #51545c 70%, #b3bac7 100%); --dashboard-label-color: #454852; --dashboard-label-background-clip: initial; --dashboard-label-text-fill-color: #454852; --dashboard-label-shadow: none; --dashboard-label-filter: none; /* ====== TABLES & STATS ====== */ --stats-footer-color: #362f26; --stats-table-hover-bg: #acacac; --stats-table-hover-color: #f5faff; --stats-row-error-bg: #37242a; --stat-bar-gradient: linear-gradient(90deg, var(--info, #339af0), #1565c0); /* ====== SPECIAL EFFECTS & MISC ====== */ --splash-particle-color: rgb(0, 0, 0, 0.5); --poster-list-hover-bg: #222c; --poster-list-hover-color: #ffe06f; --gdrive-tooltip-bg: #232c3b; --gdrive-tooltip-color: #e6ecfa; --gdrive-tooltip-shadow: #0006; --gdrive-tooltip-red: #e75b5b; --gdrive-tooltip-highlight: #ffd166; --copy-btn-hover-color: #ffe06f; --loader-bg: #e0e6ed; --img-modal-shadow: #1117; --hover-preview-border: #444; /* ====== NOTIFICATION CARD ====== */ --notification-card-shadow: 0 2px 6px #c1c5cb; /* ===== Toggles ===== */ --toggle-off: #d3dae6; --toggle-on: #3e7bfa; --toggle-thumb-bg: #fff; --toggle-thumb-shadow: 0 1.5px 4px rgba(60, 72, 90, 0.18); /* ===== Navigation ===== */ --nav-glass-bg: rgba(245, 246, 247, 0.83); --nav-glass-border: 1px solid rgba(30, 40, 50, 0.07); --nav-shadow: 0 2px 6px rgba(60, 72, 90, 0.13); --nav-dropdown-shadow: 0 4px 12px rgba(60, 72, 90, 0.09); /* ====== LOGS & LOG VIEWER ====== */ --log-badge-bg: rgba(30, 40, 50, 0.06); /* --log-badge-shadow: 0 2px 6px #c1c5cb; */ --log-jump-btn-shadow: 0 2px 6px #c1c5cb; --log-line-hover-bg: rgba(0, 0, 0, 0.018); /* ====== SPLASH / WELCOME ====== */ --splash-card-shadow: 0 8px 24px #c1c5cb; /* ====== SHADOWS & BORDERS ====== */ --shadow: rgba(60, 72, 90, 0.09); /* --box-shadow: 0 2px 8px var(--shadow); */ --card-border: 1px solid #c1c5cb; --pill-border: 1.5px solid #dde2ec; --pill-hover-bg: #e9eaed; --pill-active-bg: #d8dade; --pill-hover-shadow: 0 2px 8px #c1c5cb; /* ====== LABELARR & LIBRARY CUSTOM ====== */ --labelarr-label-bg: rgba(200, 200, 200, 0.13); --labelarr-label-color: #967540; --labelarr-label-empty-color: #bcbec4; --labelarr-library-bg: rgba(60, 110, 180, 0.07); --labelarr-library-color: #4c7ad1; --labelarr-arrow-color: #9fa3aa; --labelarr-plex-instance-color: #5ea455; --viewframe-border-top: rgba(0, 0, 0, 0.07); --footer-update-badge-color: #23272e; --update-tooltip-title-color: #23272e; --daps-footer-box-shadow: rgba(0, 0, 0, 0.06); --update-tooltip-versions-color: #868e96; --preset-card-border: rgba(30, 40, 60, 0.1); --dir-list-li-hover-background: rgba(50, 60, 80, 0.07); --select2-results--option--highlighted-color: #23272e; --modal-preset-type-color: #a37614; --modal-preset-type-background: rgba(225, 200, 120, 0.19); --modal-preset-content-color: #3a4c67; --modal-preset-content-background: rgba(160, 175, 200, 0.13); --terminal-bg: #1a1a1a; --terminal-border: 1px solid #c1c5cb; --terminal-color: #0f0; --terminal-header-bg: #dee1e7; --terminal-title-color: #23272e; --terminal-control-close: #e33; --terminal-control-min: #ffc400; --terminal-control-max: #43a047; --terminal-cursor-color: #0f0; } ================================================ FILE: web/static/css/common.css ================================================ /* ====================================================== TOAST NOTIFICATIONS ====================================================== */ #toast-container { position: fixed; bottom: var(--toast-position-bottom); right: var(--toast-position-right); display: flex; flex-direction: column; align-items: flex-end; gap: 0.75rem; z-index: 9999; } .toast { background: var(--primary-bg); color: var(--text-color); padding: var(--toast-padding); border-radius: var(--toast-radius); box-shadow: var(--toast-shadow); opacity: 0; transform: translateY(20px); transition: opacity 0.4s ease, transform 0.4s ease; font-size: var(--toast-font-size); font-weight: 600; } .toast.show { opacity: 1; transform: translateY(0); } .toast.success { background: var(--success); color: var(--primary-bg); } .toast.error { background: var(--error); color: var(--primary-bg); } .toast.info { background: var(--info); color: var(--primary-bg); } /* ====================================================== PASSWORD WRAPPER (API Key Input) ====================================================== */ .password-wrapper { position: relative; display: flex; align-items: center; width: 100%; } .password-wrapper input { flex: 1 1 auto; width: 100%; padding-right: 2rem; background: var(--form-bg); color: var(--fg); border: var(--form-border); border-radius: var(--form-radius); } .password-wrapper input.masked-input { -webkit-text-security: disc; } .password-wrapper .toggle-password { position: absolute; top: 50%; right: 0.5rem; transform: translateY(-50%); cursor: pointer; font-size: 1rem; opacity: 0.7; transition: opacity 0.2s, color 0.2s; } .password-wrapper .toggle-password:hover { color: var(--focus); opacity: 1; } /* ====================================================== BASE LAYOUT (BODY, CONTAINER, VIEW FRAME) ====================================================== */ body { font: var(--font-size-base) / var(--line-height) var(--font); color: var(--text-color); background: var(--primary-bg); margin: 0; padding: 0; display: flex; flex-direction: column; min-height: 100vh; font-size: 1.1rem; position: relative; overflow: hidden; } .container { max-width: var(--container-max-width); margin: 0 auto; padding: var(--container-padding); width: 100%; } .view-frame { padding: var(--view-frame-padding, 2rem); } .view-frame.fade-in { animation: pageFadeIn 0.3s ease-out forwards; } /* ====================================================== CARD COMPONENT ====================================================== */ .card { background: var(--card-bg); border: var(--card-border, 1px solid rgba(255, 255, 255, 0.1)); border-radius: var(--card-radius); box-shadow: var(--card-shadow); padding: var(--card-padding, 1.5rem); margin-bottom: var(--card-margin-btm); transition: transform 0.2s ease, box-shadow 0.2s ease; } .card:hover { background: var(--card-hover-bg); box-shadow: var(--card-shadow); filter: brightness(1.01); } /* ====================================================== TYPOGRAPHY (HEADINGS) ====================================================== */ h1 { font-size: var(--font-size-heading); font-weight: var(--font-weight-heading); margin-bottom: var(--card-margin-btm); color: var(--heading-color); } h2 { font-size: var(--font-size-subheading); font-weight: var(--font-weight-heading); margin-bottom: 0.75rem; } /* ====================================================== INPUT ERROR STATE ====================================================== */ .input-invalid { outline: var(--form-invalid-outline) !important; background: var(--form-invalid-bg) !important; color: var(--form-invalid-color) !important; transition: background 0.5s, outline 0.5s; } /* ====================================================== TOGGLE SWITCH ====================================================== */ .toggle-row { display: flex; align-items: flex-start; gap: 0.75rem; } .toggle-switch { position: relative; display: inline-block; width: var(--toggle-width); height: var(--toggle-height); color: var(--primary); } .toggle-switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--toggle-off); transition: 0.2s; border-radius: var(--toggle-radius); } .slider:before { position: absolute; content: ''; height: var(--toggle-slider-size); width: var(--toggle-slider-size); left: var(--toggle-slider-offset); bottom: var(--toggle-slider-offset); background-color: var(--toggle-thumb-bg); transition: 0.2s; border-radius: var(--toggle-slider-radius); } .toggle-switch input:checked + .slider { background-color: var(--toggle-on); } .toggle-switch input:checked + .slider:before { transform: translateX(16px); } /* ====================================================== BOX SIZING RESET ====================================================== */ *, *::before, *::after { box-sizing: border-box; } /* ====================================================== SCROLLBAR GLOBAL (HIDE) ====================================================== */ html, body { scrollbar-width: none; -ms-overflow-style: none; overflow: hidden; } html::-webkit-scrollbar, body::-webkit-scrollbar { width: 0; height: 0; } /* ====================================================== FORM CONTROLS (Input, Select, Textarea) ====================================================== */ .input, .select { display: block; width: 100%; padding: var(--form-padding); height: var(--form-height); background: var(--form-bg); color: var(--form-color); border: var(--form-border); box-shadow: var(--form-shadow); border-radius: var(--form-radius); font-size: var(--form-font-size); box-sizing: border-box; transition: border-color 0.2s, box-shadow 0.2s; appearance: none; -webkit-appearance: none; -moz-appearance: none; } /* Arrow icon for selects */ .select { 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"); background-repeat: no-repeat; background-position: right 0.75rem center; background-size: 0.65rem auto; } .input:focus, .select:focus { outline: none; border-color: var(--form-focus); box-shadow: 0 0 0 3px var(--form-focus); } .textarea { display: block; width: 100%; padding: var(--form-padding); background: var(--form-bg); color: var(--form-color); border: var(--form-border); border-radius: var(--form-radius); font-size: 1rem; line-height: 1.2; box-sizing: border-box; resize: vertical; min-height: 4rem; } .textarea:focus { outline: none; border-color: var(--form-focus); box-shadow: 0 0 0 3px var(--form-focus); } /* ====================================================== ANIMATIONS ====================================================== */ @keyframes pageFadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: var(--translate-neutral); } } /* ====================================================== LINK STYLING ====================================================== */ a { color: var(--link-color); text-decoration: none; transition: color 0.2s ease; } a:hover, a:focus { color: var(--link-hover-color); text-decoration: underline; } /* ====================================================== BUTTONS ====================================================== */ .btn { display: inline-flex; align-items: center; justify-content: center; min-width: 100px; padding: var(--btn-padding); background: var(--btn-bg); color: var(--btn-color); text-align: center; font-size: var(--btn-font-size, 0.95rem); font-weight: 600; border: none; border-radius: var(--btn-radius, 4px); cursor: pointer; transition: background 0.2s, transform 0.1s ease; } .btn:hover { background: var(--btn-hover-bg); transform: translateY(-1px); } .btn:active { transform: translateY(1px); } .btn--success { background: var(--success); color: var(--primary-bg); } .btn--success:hover { background: var(--success-highlight); transform: translateY(-1px); } .btn--success:active { transform: translateY(1px); } .btn--cancel { background: var(--error); color: var(--primary-bg); } .btn--cancel:hover { background: var(--error-highlight); transform: translateY(-1px); } .btn--cancel:active { transform: translateY(1px); } .btn-container { display: flex; gap: 0.75rem; margin-left: auto; align-items: center; } .btn--remove-item { background: var(--error); min-width: 40px; } /* ====================================================== GLOBAL HELP COMPONENTS ====================================================== */ /* Help wrapper: spacing reset, no border or shadow */ .help { margin: 1rem 0; padding: 0; background: none; border-left: none; box-shadow: none; } /* Help button: subtle, inline-flex, icon and text */ .help-toggle { background: none; color: var(--link-color); font-weight: 500; font-size: 0.98rem; border: none; padding: 0; display: flex; align-items: center; cursor: pointer; opacity: 0.85; transition: color 0.18s, opacity 0.18s; } /* SVG icon next to label */ .help-icon { margin-right: 0.18em; vertical-align: -0.1em; flex-shrink: 0; } /* Remove unwanted pseudo content */ .help-toggle::before { content: none; } /* Help button hover/focus: just increase opacity */ .help-toggle:hover, .help-toggle:focus { opacity: 1; } /* Expandable help content: transitions for smooth expand/collapse */ .help-content { font: var(--font); word-wrap: break-word; white-space: pre-line; max-height: 0; overflow: hidden; transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s, border-width 0.3s, margin-top 0.3s; opacity: 0; background: var(--card-bg); border-left: 0 solid var(--accent); padding: 0 1rem; border-radius: var(--border-radius); margin-top: 0; } /* Show expanded help content */ .help-content.show { max-height: 500px; opacity: 1; border-left: 4px solid var(--accent); padding: 1rem; margin-top: 0.5rem; } /* Help label: spacing and color */ .help-label { margin-left: 0.2em; font-weight: 500; font-size: 0.98em; color: var(--link-color); opacity: 0.88; } /* ====================================================== SEARCH INPUTS (Schedule/Notifications) ====================================================== */ #poster-search-input, #schedule-search, #notifications-search { width: 100%; font-size: 1rem; margin-bottom: 1.5rem; margin-top: 1.5rem; box-sizing: border-box; } ================================================ FILE: web/static/css/index.css ================================================ /* ====================================================== SPLASH / NOTIFICATIONS PAGE LAYOUT ====================================================== */ .splash-container { z-index: 1; position: relative; overflow: hidden; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } /* ====================================================== SPLASH PARTICLE ANIMATION ====================================================== */ #splash-particles { position: absolute; width: 100%; height: 100%; top: 0; left: 0; z-index: 0; pointer-events: none; } /* ====================================================== SPLASH CARD ====================================================== */ .splash-card { background: var(--card-bg); padding: var(--splash-card-padding); border-radius: var(--splash-card-radius); box-shadow: var(--splash-card-shadow); animation: fadeIn 0.6s ease-out forwards; } /* ====================================================== SPLASH CARD HEADER ====================================================== */ .splash-icon { font-size: var(--splash-icon-size); margin-bottom: 1rem; } .splash-card h1 { font-size: var(--splash-header-font-size); margin-bottom: var(--splash-header-margin-btm); color: var(--primary); } /* ====================================================== SPLASH CARD PARAGRAPH / SETTINGS FIELDS ====================================================== */ .splash-card p { font-size: var(--font-size-base-plus); color: var(--fg); opacity: var(--splash-p-opacity); } /* ====================================================== SPLASH TYPING INDICATOR ====================================================== */ .splash-typing::after { content: '|'; animation: blink 1s infinite; } /* ====================================================== SPLASH KEYFRAMES ====================================================== */ @keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } /* ====================================================== UPDATE TOOLTIP & DASHBOARD HEADER/FOOTER ====================================================== */ /* Tooltip container for update badge */ .has-update-tooltip { position: relative; } /* Tooltip itself */ .update-tooltip { min-width: 225px; background: var(--card-bg); color: var(--fg); border-radius: var(--card-radius); padding: var(--card-padding); font-size: 1.06em; font-weight: 500; box-shadow: var(--card-hover-shadow, 0 6px 24px 0 rgba(0, 0, 0, 0.3)); border: var(--card-border); position: absolute; left: 50%; transform: translateX(-50%); bottom: 2.4em; z-index: 99999; pointer-events: none; opacity: 0; transition: opacity 0.18s, box-shadow 0.2s; text-align: left; white-space: nowrap; display: block; filter: drop-shadow(0 4px 15px var(--error, #f443362c)); } .has-update-tooltip:hover .update-tooltip, .has-update-tooltip:focus .update-tooltip { opacity: 1; pointer-events: auto; display: block; box-shadow: var(--card-hover-shadow, 0 10px 30px 0 rgba(244, 67, 54, 0.13)); } .update-tooltip-title { font-size: 1.1em; font-weight: 600; color: var(--update-tooltip-title-color); letter-spacing: 0.02em; } .update-tooltip-versions { color: var(--update-tooltip-versions-color); font-size: 0.94em; } /* Footer (fixed, with card background, rounded) */ .daps-footer { position: fixed; bottom: 0; right: 0; padding: var(--padding-standard); background: var(--card-bg); color: var(--fg); font-size: 0.85rem; border-top-left-radius: var(--card-radius); box-shadow: -2px -2px 5px var(--daps-footer-box-shadow); display: flex; gap: 1rem; align-items: center; z-index: 1000; } /* Footer version label */ .footer-version { font-weight: 500; } /* Update badge in footer (hidden by default) */ .footer-update-badge { background: var(--error); color: var(--footer-update-badge-color); border-radius: 12px; font-size: 0.82em; padding: 2px 8px 2px 8px; margin-left: 1em; font-weight: 500; vertical-align: middle; cursor: pointer; position: relative; } /* Footer external links (GitHub, Discord) */ .footer-link { display: flex; align-items: center; gap: 0.4rem; color: var(--link-color); text-decoration: none; } .daps-header-gradient { text-align: center; margin: 1.5rem auto 1rem; font-size: 2.5rem; font-weight: 600; background: var(--daps-header-gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; text-shadow: var(--daps-header-shadow); letter-spacing: 0.045em; transition: filter 0.2s, letter-spacing 0.18s; filter: blur(0.15px) brightness(1.07); } .daps-header-gradient:hover { filter: var(--daps-header-hover); letter-spacing: 0.09em; } .daps-header-gradient .daps { font-variant: small-caps; font-size: 1.11em; letter-spacing: 0.17em; } .daps-header-gradient .dashboard-label { font-weight: 600; font-size: 0.92em; margin-left: 0.14em; position: relative; display: inline-block; padding-bottom: 2px; color: var(--dashboard-label-color); background: none !important; -webkit-background-clip: var(--dashboard-label-background-clip); background-clip: var(--dashboard-label-background-clip); -webkit-text-fill-color: var(--dashboard-label-text-fill-color); text-shadow: var(--dashboard-label-shadow); filter: var(--dashboard-label-filter); opacity: 0.98; } .daps-header-gradient .dashboard-label::after { content: ''; position: absolute; left: 20%; right: 20%; bottom: 0; height: 2px; background: var(--daps-header-underline); opacity: 0.22; border-radius: 2px; transform: scaleX(0); transition: transform 0.23s cubic-bezier(0.68, -0.55, 0.27, 1.55); pointer-events: none; } .daps-header-gradient:hover .dashboard-label::after { transform: scaleX(1); } .daps-header-link { display: block; text-decoration: none !important; color: inherit !important; cursor: pointer; } ================================================ FILE: web/static/css/instances.css ================================================ /* ====================================================== API KEY WRAPPER ====================================================== */ .api-wrapper { position: relative; width: 100%; } .api-wrapper input.masked-input, .api-wrapper input { padding-right: 2.5rem; box-sizing: border-box; } .api-wrapper .toggle-api { position: absolute; top: 50%; right: var(--toggle-api-right); transform: translateY(-50%); cursor: pointer; user-select: none; font-size: var(--toggle-api-font-size); opacity: var(--toggle-api-opacity); line-height: 1; } /* Masked Input */ .masked-input { font-family: 'text-security-disc', sans-serif; -webkit-text-security: disc; } /* ====================================================== INSTANCE BUTTONS (Test, Remove, Add) ====================================================== */ .instance-btn { min-width: 130px; } /* ====================================================== INSTANCE CARD ANIMATIONS ====================================================== */ #instancesForm .card { opacity: 0; transform: translateY(24px) scale(0.98); transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98), transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98); } #instancesForm .card.show-card { opacity: 1; transform: translateY(0) scale(1); } #instancesForm .card.removing { opacity: 0 !important; transform: translateY(-32px) scale(0.94) !important; pointer-events: none; transition: opacity 0.25s cubic-bezier(0.71, 0, 0.32, 1), transform 0.25s cubic-bezier(0.71, 0, 0.32, 1); } ================================================ FILE: web/static/css/layout.css ================================================ /* ====================================================== GENERAL CONTAINER ====================================================== */ .container { position: relative; z-index: 1; background: var(--container-bg); padding: var(--container-padding); border-radius: var(--container-radius); padding-top: 0; width: 100%; max-width: var(--container-max-width); margin: 0 auto; } /* ====================================================== MAIN VIEW FRAME ====================================================== */ #viewFrame { width: 100%; height: 80vh; background: var(--view-frame-bg); border-radius: var(--border-radius); padding: var(--view-frame-padding, 2rem); overflow-y: auto; border-top: 1px solid var(--viewframe-border-top); border-top-left-radius: var(--border-radius); border-top-right-radius: var(--border-radius); transition: opacity 0.3s ease, transform 0.3s ease; scroll-behavior: smooth; overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; scrollbar-color: transparent transparent; } #viewFrame::-webkit-scrollbar { width: 0; height: 0; } /* ====================================================== CONTAINER FOR IFRAMES ====================================================== */ .container-iframe { background: var(--container-bg); padding: var(--container-padding); width: 100%; max-width: 100%; scrollbar-width: none; -ms-overflow-style: none; padding-bottom: var(--container-padding-bottom); padding-top: 0; } /* ====================================================== CONTENT SECTION ====================================================== */ .content { border-top: none; margin-top: 0; flex: 1; margin: 0; padding: 0; } /* ====================================================== PAGE HEADINGS ====================================================== */ h1 { text-align: center; margin-bottom: var(--card-margin-btm); font-size: var(--heading-font-size-lg); color: var(--heading-color); } /* ====================================================== PAGE LAYOUTS ====================================================== */ /* Schedule Form Fields */ #scheduleForm .field { position: relative; display: grid; grid-template-columns: var(--field-label-width) 1fr auto; align-items: start; gap: 1.5rem; margin-bottom: 0.5rem; padding-bottom: 0.75rem; } /* Instances Form Fields */ #instancesForm .field { position: relative; display: grid; grid-template-columns: 1fr 1fr 1fr auto; grid-template-rows: auto auto auto; align-items: start; gap: var(--field-gap); } /* Notifications Form Fields */ #notificationsForm .field { position: relative; display: flex; gap: var(--field-gap); margin-bottom: 0.13em; padding-bottom: 0; padding-top: 0; transition: margin-bottom 0.24s, padding-bottom 0.24s; } /* Settings Form Fields */ #settingsForm .field { position: relative; display: grid; grid-template-columns: var(--field-label-width) 1fr auto; align-items: start; gap: var(--field-gap); margin-bottom: 0.5rem; padding-bottom: 0.75rem; } ================================================ FILE: web/static/css/logs.css ================================================ /* ====================================================== LOG VIEWER: AUTO-SCROLL BADGE & SPINNER ====================================================== */ /* ===== Auto-Scroll Badge ===== */ .log-scroll-badge { position: absolute; bottom: 3rem; right: 20px; background: var(--log-badge-bg); color: var(--heading-color); padding: var(--log-badge-padding); border-radius: var(--log-badge-radius); font-size: var(--log-badge-font-size); display: none; z-index: var(--log-badge-z); transition: var(--log-badge-transition); opacity: var(--log-badge-opacity); } /* ===== Spinner ===== */ .log-spinner { position: absolute; top: 50%; left: 50%; width: var(--log-spinner-size); height: var(--log-spinner-size); margin: calc(var(--log-spinner-size) / -2) 0 0 calc(var(--log-spinner-size) / -2); border: var(--log-spinner-border); border-top: var(--log-spinner-border-top); border-radius: var(--log-spinner-radius); animation: spin 1s linear infinite; z-index: var(--log-spinner-z); } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* ====================================================== LOG OUTPUT CONTAINER & LINES ====================================================== */ /* ===== Main Log Output Container ===== */ .log-output { width: 100%; height: 600px; overflow: hidden; } /* ===== Log Lines & Animation ===== */ .log-line { opacity: 1; transition: opacity 0.3s, transform 0.3s; } .log-line.new-line { animation: fadeInLine 0.4s ease-out; } .log-line:hover { background: var(--log-line-hover-bg); } @keyframes fadeInLine { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: var(--translate-neutral); } } /* ====================================================== SCROLL/JUMP BUTTONS ====================================================== */ /* ===== Jump to Bottom Button ===== */ .jump-to-bottom { position: absolute; bottom: 1rem; right: 1rem; background: var(--primary); color: var(--bg); padding: var(--log-jump-btn-padding); border-radius: var(--log-jump-btn-radius); cursor: pointer; font-weight: var(--log-jump-btn-font-weight); font-size: var(--log-jump-btn-font-size); box-shadow: var(--log-jump-btn-shadow); display: none; z-index: var(--log-jump-btn-z); transition: all 0.2s ease; border: var(--log-scroll-btn-border); } .jump-to-bottom:hover { background: var(--focus); transform: translateY(-1px); } /* ===== Scroll-to-Top & Scroll-to-Bottom Buttons ===== */ .scroll-output-container { position: relative; flex-grow: 1; overflow: hidden; min-height: 0; } .scroll-to-top, .scroll-to-bottom { position: absolute; right: 1rem; background: var(--card-bg); color: var(--link-color); padding: 0.4rem 0.9rem; border-radius: var(--log-scroll-btn-radius); font-weight: bold; font-size: 0.85rem; border: var(--log-scroll-btn-border); z-index: 1000; display: none; cursor: pointer; } .scroll-to-top { top: 1rem; } .scroll-to-bottom { bottom: 1rem; } /* ====================================================== LOG CONTROLS & TOOLBAR ====================================================== */ body.logs-open { overflow: hidden; } .log-controls.log-toolbar { display: flex; flex-direction: row; align-items: flex-end; justify-content: flex-start; gap: 1.1rem; padding: 1rem 1.5rem; background: var(--card-bg); border-radius: var(--log-controls-radius); box-shadow: var(--log-controls-shadow); max-width: 95%; margin: 1.5rem auto 1rem; flex-wrap: nowrap; overflow-x: auto; } .log-toolbar { gap: var(--log-toolbar-gap); } /* ====================================================== LOG LEVELS (COLORS) ====================================================== */ .log-error { color: var(--log-error); } .log-warning { color: var(--log-warning); } .log-critical { color: var(--log-critical); font-weight: bold; } .log-info { color: var(--log-info); } .log-debug { color: var(--log-debug); } ================================================ FILE: web/static/css/modals.css ================================================ /* ====================================================== PAGE & MODAL OVERLAYS ====================================================== */ #pageOverlay, .overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--overlay-bg); backdrop-filter: blur(var(--overlay-blur)); opacity: 0; pointer-events: none; transition: opacity 0.3s ease; z-index: 400; } body.modal-open #pageOverlay, body.modal-open .overlay { opacity: 1; pointer-events: auto; } /* ====================================================== BASE MODAL ====================================================== */ .modal { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: var(--overlay-bg); z-index: 1000; backdrop-filter: blur(var(--overlay-blur)); padding-top: 0; overflow-y: auto; opacity: 0; pointer-events: none; display: flex; justify-content: center; align-items: center; transform: scale(0.97); transition: opacity 0.44s cubic-bezier(0.44, 1.13, 0.73, 0.98), transform 0.48s cubic-bezier(0.44, 1.13, 0.73, 0.98); } .modal.show { opacity: 1; pointer-events: auto; transform: scale(1); } /* ====================================================== MODAL CONTENT ====================================================== */ .modal-content { background: var(--card-bg); color: var(--fg); border-radius: var(--modal-radius); padding: var(--modal-padding); width: var(--modal-content-width); display: flex; flex-direction: column; gap: 1rem; box-shadow: var(--dropdown-shadow); } .modal-content h2 { margin: 0; text-align: center; font-size: var(--modal-header-font-size); font-weight: var(--modal-header-font-weight); margin-bottom: var(--modal-header-margin); } /* ====================================================== MODAL FOOTER ====================================================== */ .modal-footer { display: flex; justify-content: flex-end; gap: var(--modal-footer-gap); margin-top: var(--modal-footer-margin-top); } /* ====================================================== DIRECTORY MODAL ====================================================== */ #dir-list { list-style: none; padding: 0; max-height: 300px; overflow-y: auto; background: var(--form-bg); border: var(--form-border); border-radius: var(--form-radius); margin: 1rem 0; color: white; } #dir-list li { padding: var(--form-padding); cursor: pointer; display: flex; align-items: center; color: var(--fg); } #dir-list li:hover { background: var(--dir-list-li-hover-background); } #dir-modal .modal-content .h2 { font-size: 1.5rem; margin-bottom: 1rem; color: var(--fg); } /* ====================================================== DIRECTORY BREADCRUMB ====================================================== */ #dir-breadcrumb { font-size: 0.9rem; margin-bottom: 0.5rem; } /* ====================================================== UNSAVED CHANGES MODAL ====================================================== */ #unsavedModal { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: var(--overlay-bg); z-index: 10000; backdrop-filter: blur(var(--overlay-blur)); display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transform: scale(0.97); transition: opacity 0.44s cubic-bezier(0.44, 1.13, 0.73, 0.98), transform 0.48s cubic-bezier(0.44, 1.13, 0.73, 0.98); font-family: var(--font); } #unsavedModal.show { opacity: 1; pointer-events: auto; transform: scale(1); } #unsavedModal .modal-content { background: var(--modal-bg); color: var(--modal-color); padding: var(--modal-padding); border-radius: var(--modal-radius); box-shadow: var(--modal-shadow); max-width: var(--modal-max-width); width: 25%; text-align: center; display: flex; flex-direction: column; align-items: center; } #unsavedModal .modal-content p { font-size: 1.25rem; margin-bottom: 1.5rem; } #unsavedModal button { padding: var(--modal-btn-padding); font-size: var(--modal-btn-font-size); border: none; border-radius: var(--modal-btn-radius); cursor: pointer; margin: var(--modal-btn-margin); font-weight: var(--modal-btn-font-weight); transition: background 0.2s ease, transform 0.1s ease; display: block; } #unsavedModal .modal-content button { width: 100%; max-width: 240px; } #unsavedModal .save-btn { background: var(--success); color: var(--primary-bg); } #unsavedModal .discard-btn { background: var(--caution); color: var(--primary-bg); } #unsavedModal .cancel-btn { background: var(--error); color: var(--primary-bg); } #unsavedModal button:hover { transform: translateY(-1px); } /* ====================================================== HOLIDAY MODAL FIELD LAYOUT ====================================================== */ #holiday-modal .modal-content .field { display: flex; flex-direction: column; grid-template-columns: none !important; } #holiday-modal .modal-content .field > label, #holiday-modal .modal-content .field > input, #holiday-modal .modal-content .field > select, #holiday-modal .modal-content .field > button { width: 100%; } /* ====================================================== LABELARR MODAL PILL OVERRIDES ====================================================== */ #labelarr-modal { --pill-padding: 0.5rem 1rem; --pill-radius: 4px; --pill-font-size: 1rem; --pill-font-weight: 400; } /* ====================================================== SCHEDULE RANGE FIELD ====================================================== */ .schedule-range { display: flex; gap: 0.5rem; align-items: center; } /* ====================================================== SELECT2 CUSTOM STYLING ====================================================== */ .select2-container .select2-selection--single { background: var(--form-bg); color: var(--form-color, var(--fg)); border: var(--form-border); border-radius: var(--form-radius); font-size: var(--form-font-size); height: var(--form-height); min-height: var(--form-height); box-shadow: var(--input-shadow, none); display: flex; align-items: center; padding-left: 1rem; transition: border-color 0.2s, box-shadow 0.2s; } .select2-container--default .select2-selection--single:focus, .select2-container--default .select2-selection--single.select2-selection--focus { border-color: var(--form-focus); box-shadow: 0 0 0 3px var(--form-focus); outline: none; } .select2-container--default .select2-selection--single .select2-selection__rendered { color: var(--form-color, var(--fg)); font-size: var(--form-font-size); line-height: var(--form-height); padding-left: 0; font-weight: 500; } .select2-dropdown { background: var(--form-bg); color: var(--form-color, var(--fg)); border: var(--form-border); border-radius: var(--form-radius); font-size: var(--form-font-size); box-shadow: var(--dropdown-shadow); } .select2-results__option { background: none; color: var(--form-color, var(--fg)); padding: 0.55em 1em; } .select2-results__option--highlighted { background: var(--primary); color: var(--select2-results--option--highlighted-color); } .select2-container--default .select2-selection--single .select2-selection__arrow { 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"); background-repeat: no-repeat; background-position: right 0.85em center; background-size: 0.65em auto; width: 2em; height: 100%; border: none; } .select2-container--default .select2-selection__arrow b { display: none; } /* ====================================================== PRESET CARD (MODAL CARD-LIKE ELEMENT) ====================================================== */ .preset-card { background: var(--card-bg, #23272f); color: var(--fg, #dbe4ee); border-radius: 10px; padding: 1.2rem 1.8rem 1.2rem 1.5rem; margin: 0.5rem 0 1.1rem 0; box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.03) 0px 1px 3px; border: 1px solid var(--preset-card-border); font-size: 1.06rem; transition: box-shadow 0.15s; line-height: 1.6; } .preset-label { color: var(--accent, #82a6d7); font-weight: 500; margin-right: 0.25em; } .preset-field { margin-bottom: 0.45em; display: flex; align-items: flex-start; gap: 0.4em; font-size: 1em; } .preset-type { color: var(--modal-preset-type-color); background: var(--modal-preset-type-background); border-radius: 3.5px; font-weight: 500; padding: 0.05em 0.65em 0.08em; margin-left: 0.2em; font-size: 0.98em; } .preset-content { margin: 0.3em 0 0.5em 1em; color: var(--modal-preset-content-color); font-size: 1em; line-height: 1.5; padding-left: 0.7em; background: var(--modal-preset-content-background); border-radius: 2px; } ================================================ FILE: web/static/css/navigation.css ================================================ /* ====================================================== NAVIGATION BAR ====================================================== */ nav { border-bottom: none; margin-bottom: 0; box-shadow: var(--nav-shadow); position: sticky; top: 0; z-index: 100; background: var(--primary-bg); } /* ====================================================== TOP MENU ====================================================== */ .menu { display: flex; justify-content: center; gap: var(--nav-menu-gap); background: var(--nav-glass-bg); align-items: center; list-style: none; padding: 0; margin: 0; backdrop-filter: blur(var(--nav-glass-blur, 10px)); -webkit-backdrop-filter: blur(var(--nav-glass-blur, 10px)); border-bottom: var(--nav-glass-border, 1px solid rgba(255, 255, 255, 0.07)); box-shadow: var(--nav-shadow); border-radius: var(--nav-border-radius); } .menu a, .dropdown-toggle { color: var(--text-color); font-size: var(--font-size-base); font-weight: var(--font-weight-base); text-decoration: none; position: relative; padding: 0.5rem 0; background: none; border: none; border-radius: var(--nav-border-radius); transition: color 0.2s; vertical-align: middle; } /* Only show underline for top-level horizontal menu items, not dropdowns */ .menu > li > a.active::after, .menu > li > a:hover::after { content: ''; position: absolute; bottom: -4px; left: 0; right: 0; height: 2px; background: var(--highlight); border-radius: 1px; transition: background 0.18s; } .menu > li > .dropdown-toggle.active::after { content: ''; position: absolute; bottom: -4px; left: 0; right: 0; height: 2px; background: var(--highlight); border-radius: 1px; transition: background 0.18s; } /* Prevent underline on dropdown menu links */ .dropdown-menu li a::after { display: none !important; content: none !important; } .dropdown-toggle.active::after { content: ''; position: absolute; bottom: -4px; left: 0; right: 0; height: 2px; } .dropdown-menu li a.active { background: var(--primary); color: var(--bg); font-weight: 600; opacity: 1; border-left: 3px solid var(--highlight); } /* ====================================================== DROPDOWN WRAPPER ====================================================== */ .dropdown { position: relative; display: inline-block; } /* ====================================================== DROPDOWN TOGGLE BUTTON ====================================================== */ .dropdown-toggle { background: none; border: none; color: var(--text-color); font-size: var(--font-size-base); font-weight: var(--font-weight-base); padding: 0.5rem 0 0.5rem 0; margin-bottom: 0; position: relative; cursor: pointer; border-radius: var(--nav-border-radius); transition: color 0.2s; display: inline-flex; align-items: center; } /* ====================================================== DROPDOWN MENU ====================================================== */ .dropdown-menu { position: absolute; top: 100%; left: 50%; transform: translate(-50%, -8px) scale(0.95); opacity: 0; pointer-events: none; list-style: none; margin: 0; padding: 0; background: var(--nav-glass-bg); backdrop-filter: blur(var(--nav-glass-blur, 22px)) saturate(180%); -webkit-backdrop-filter: blur(var(--nav-glass-blur, 22px)) saturate(180%); border: var(--nav-glass-border, 1.5px solid rgba(255, 255, 255, 0.13)); box-shadow: var(--nav-dropdown-shadow), 0 2px 20px 0 rgba(30, 38, 43, 0.09) inset; border-radius: var(--nav-dropdown-radius); min-width: 10rem; overflow: hidden; z-index: 100; width: 12rem; transition: opacity 150ms ease-in, transform 200ms ease-out; } .dropdown.open .dropdown-menu { opacity: 1; pointer-events: auto; transform: translate(-50%, 0) scale(1); width: 14rem; } .dropdown-menu li { margin: 0; } /* ====================================================== DROPDOWN MENU ITEMS ====================================================== */ .dropdown-menu li a { white-space: nowrap; display: block; padding: var(--nav-dropdown-item-padding); background: var(--nav-glass-bg); color: var(--fg); text-decoration: none; transform: translateX(0); opacity: 0.95; border-radius: var(--nav-dropdown-item-radius); transition: background 150ms ease, color 150ms ease, transform 150ms ease, opacity 150ms ease; } .dropdown-menu li a:hover { background: var(--primary); color: var(--bg); transform: translateX(2px); opacity: 1; border-left: 3px solid var(--highlight); } .dropdown-menu li:first-child a, .dropdown-menu li:last-child a { border-radius: var(--nav-dropdown-item-radius); } /* ====================================================== SETTINGS PANEL ====================================================== */ .settings-panel { background: var(--card-bg); padding: 1rem; border-radius: var(--border-radius-default); box-shadow: 0 6px 12px var(--shadow); display: flex; flex-direction: column; gap: 0.5rem; min-width: 14rem; } /* ====================================================== SETTINGS PANEL TITLE ====================================================== */ .settings-panel .panel-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.5rem; padding-bottom: 0.5rem; /* border-bottom: 1px solid var(--shadow); */ text-align: center; } /* ====================================================== SETTINGS PANEL ITEMS ====================================================== */ .settings-panel li { list-style: none; margin: 0; } .settings-panel li a { display: block; padding: 0.75rem 1.25rem; background: var(--card-bg); color: var(--fg); border-radius: var(--border-radius-default); text-decoration: none; font-weight: 500; text-align: center; transition: background 200ms, border 200ms, color 200ms; } .settings-panel li a:hover, .settings-panel li a.active { color: var(--bg); } ================================================ FILE: web/static/css/notifications.css ================================================ /* ====================================================== NOTIFICATIONS CARD ====================================================== */ #notificationsForm .card { margin-bottom: var(--card-margin-btm); opacity: 0; transform: translateY(24px) scale(0.98); transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98), transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98); } #notificationsForm .card.show-card { opacity: 1; transform: translateY(0) scale(1); } /* ====================================================== TOGGLE ROWS (FIELD GROUPS) ====================================================== */ #notificationsForm .field.toggle-row--expanded { margin-bottom: 0.44em; padding-bottom: 0.38em; z-index: 2; } #notificationsForm .field.toggle-row { margin-bottom: 0.08em; margin-top: 0.04em; padding-left: 0.07em; gap: var(--field-gap, 0.6em); } /* ====================================================== NOTIFICATION TOGGLE GROUP ====================================================== */ .notification-toggle-group { display: flex; flex-direction: column; gap: 0.18em; margin-top: 0.24em; margin-bottom: 0.37em; } /* ====================================================== TEST BUTTON (VISIBLE ONLY IF ENABLED) ====================================================== */ #notificationsForm .field.toggle-row .btn--test.enabled { display: inline-flex; } /* ====================================================== CARD HEADER ====================================================== */ .card-header { padding-bottom: 0.3em; margin-bottom: 0.5em; font-size: 1.2em; font-weight: bold; color: var(--primary); } /* ====================================================== NOTIFICATION FIELDSET (COLLAPSIBLE EXPAND/COLLAPSE) ====================================================== */ .notification-fieldset { display: flex; flex-direction: column; border-radius: var(--card-radius, 8px); margin: 0.24em 0 0.52em 0; padding: var(--notification-fieldset-padding, 1em 1.3em 1.1em 1.2em); opacity: 0; max-height: 0; pointer-events: none; transform: translateY(18px) scale(0.98); transition: opacity 0.33s cubic-bezier(0.44, 1.13, 0.73, 0.98), max-height 0.33s cubic-bezier(0.44, 1.13, 0.73, 0.98), padding 0.17s cubic-bezier(0.44, 1.13, 0.73, 0.98), transform 0.33s cubic-bezier(0.44, 1.13, 0.73, 0.98); overflow: hidden; box-shadow: var(--notification-card-shadow, 0 2px 10px rgba(0, 0, 0, 0.13)); background-color: var(--card-bg); } .notification-fieldset.expanded { opacity: 1; max-height: 2000px; pointer-events: auto; transform: translateY(0) scale(1); animation: notificationFieldsetIn 0.33s cubic-bezier(0.44, 1.13, 0.73, 0.98); margin-bottom: 1.3em !important; } .notification-fieldset.expanded .notification-field-container:last-child { margin-bottom: 1.3em; } .notification-fieldset:not(.expanded) { animation: notificationFieldsetOut 0.27s cubic-bezier(0.71, 0, 0.32, 1); } /* ====================================================== FIELDSET LEGEND ====================================================== */ .fieldset-legend { margin-top: 0.6em; margin-bottom: 0.95em; text-align: left; font-weight: 600; /* color: var(--primary); */ font-size: 1.11em; } /* ====================================================== FIELD CONTAINER ====================================================== */ .notification-field-container { display: flex; flex-direction: column; width: 100%; margin-bottom: 0.39em; } .notification-field-container label { font-size: 1em; font-weight: 500; margin-bottom: 0.27em; margin-right: 0; } .notification-field-container:last-child { margin-bottom: 0; } /* ====================================================== TEST BUTTON (GLOBAL) ====================================================== */ .btn--test { display: none; margin-left: auto; } /* ====================================================== FIELDSET ANIMATIONS ====================================================== */ @keyframes notificationFieldsetIn { from { opacity: 0; transform: translateY(18px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes notificationFieldsetOut { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(-11px) scale(0.96); } } ================================================ FILE: web/static/css/poster_search.css ================================================ /* ====================================================== STATS HEADER & TITLE ====================================================== */ .stats-title { font-weight: var(--font-weight-heading); font-size: 1.15em; margin-bottom: 0.3em; margin-left: 0.2em; } /* ====================================================== STATS TABLE ====================================================== */ .stats-table { width: 100%; border-collapse: separate; border-spacing: 0; margin-bottom: 0.7em; background: var(--card-bg); border-radius: var(--card-radius); box-shadow: var(--card-shadow); font-size: 1.03em; table-layout: fixed; } .stats-table th, .stats-table td { padding: 0.41em 0.55em; text-align: left; font-family: var(--font); vertical-align: middle; white-space: nowrap; text-overflow: ellipsis; } .stats-table th { color: var(--link-color); font-weight: var(--font-weight-heading); background: var(--secondary-bg); border-bottom: 1.5px solid var(--shadow); letter-spacing: 0.02em; font-size: 1.07em; } .stats-table td { color: var(--text-color); background: var(--card-bg); font-size: 0.99em; transition: background 0.12s; } .stats-table tr { border-bottom: 1px solid var(--shadow); } .stats-table tr:last-child { border-bottom: none; } .stats-table tr:nth-child(even) td { background: var(--secondary-bg); } .stats-table tr:hover td { background: var(--stats-table-hover-bg, #222c); color: var(--stats-table-hover-color, #f5faff); } .stats-table td:first-child { min-width: 145px; max-width: 210px; width: 30%; font-weight: var(--font-weight-heading); } .stats-table td:nth-child(2), .stats-table th:nth-child(2) { width: 15%; text-align: right; } .stats-table td:nth-child(3), .stats-table th:nth-child(3) { width: 18%; text-align: right; } .stats-table td:nth-child(4), .stats-table th:nth-child(4) { width: 17%; text-align: right; min-width: 110px; } /* Error rows */ .gdrive-row-error td, .gdrive-row-error { background: var(--stats-row-error-bg, #37242a) !important; color: var(--error, #ff4545) !important; } /* ====================================================== STAT BAR ====================================================== */ .stat-bar-bg { background: var(--secondary-bg); border-radius: var(--border-radius-default); width: 80px; height: 11px; display: inline-block; vertical-align: middle; margin-right: 0.5em; position: relative; overflow: hidden; } .stat-bar-inner { background: var(--stat-bar-gradient, linear-gradient(90deg, var(--info, #339af0), #1565c0)); height: 100%; border-radius: var(--border-radius-default); } .stat-bar-percent { color: var(--text-color); font-size: 0.97em; margin-left: 0.2em; } /* ====================================================== FOLDER/NAME HIGHLIGHT ====================================================== */ .gdrive-name, .result-folder { color: var(--link-color); font-weight: var(--font-weight-heading); font-size: 1.08em; font-family: var(--font); } /* ====================================================== TABLE FOOTER ====================================================== */ .stats-footer { margin-top: 0.7em; font-size: 1.03em; font-weight: 500; color: var(--stats-footer-color, #c9d0d9); } /* ====================================================== SEARCH TOGGLE ROW & LABELS ====================================================== */ .poster-search-toggle-row { display: flex; align-items: center; gap: 0.5em; margin-bottom: 1.1em; padding-left: 0.1em; color: var(--text-color); } .poster-search-label, .poster-search-scope-label { font-size: var(--font-size-base); font-weight: 500; color: var(--text-color); line-height: 1.25; } .poster-search-label { margin-right: 0.2em; min-width: 75px; text-align: right; } .poster-search-scope-label { margin-left: 0.2em; min-width: 115px; text-align: left; } .poster-search-toggle-row .toggle-switch { margin: 0 2px; } /* ====================================================== POSTER SEARCH RESULTS LIST ====================================================== */ .poster-list { list-style: none; margin: 0 0 0.25em 0; padding: 0; background: none; border-radius: 0; border: none; } .poster-list li { display: flex; align-items: center; justify-content: space-between; padding: 0.07em 0.2em 0.07em 1em; min-height: 1.7em; border-bottom: 1px solid var(--shadow); font-size: var(--font-size-base); line-height: var(--line-height); background: none; margin: 0; border-radius: 0; transition: background 0.15s; font-family: var(--font); font-weight: var(--font-weight-base); } .poster-list li:last-child { border-bottom: none; } .highlight { background: var(--highlight-bg, #ffeaa7); color: var(--highlight-color, #222); font-weight: 500; border-radius: 2px; padding: 0 2px; } .poster-list li.img-preview-link:hover { background: var(--poster-list-hover-bg, #222c); color: var(--poster-list-hover-color, #ffe06f); } .poster-file-label { flex: 1 1 auto; cursor: pointer; min-width: 0; word-break: break-all; font-size: var(--font-size-base); font-weight: 500; } /* ====================================================== COPY BUTTON ====================================================== */ .copy-btn { flex: 0 0 auto; background: none; border: none; cursor: pointer; font-size: var(--font-size-base); margin-left: 0.35em; color: var(--link-color); opacity: 0.73; vertical-align: middle; transition: color 0.2s, opacity 0.2s; display: flex; align-items: center; padding: 0.1em 0.25em; border-radius: var(--border-radius-default); font-family: var(--font); font-weight: var(--font-weight-base); } .copy-btn:active { background: var(--copy-btn-active-background); } .copy-btn span { vertical-align: middle; line-height: 1.2; font-family: var(--font); } .copy-btn:hover { color: var(--copy-btn-hover-color, #ffe06f); opacity: 1; } .copy-btn-copied { color: var(--success, #5af176); display: none; align-items: center; } .copy-btn .material-icons { font-family: 'Material Icons', var(--font) !important; font-size: 1.18em !important; font-style: normal; font-weight: normal; line-height: 1; margin-right: 2px; opacity: 0.85; position: relative; top: 2px; } .copy-btn-default, .copy-btn-copied { display: inline-flex; align-items: center; } /* ====================================================== TOOLTIP (GDRIVE ETC) ====================================================== */ .gdrive-tooltip-wrapper { position: relative; display: inline-block; } .gdrive-tooltip-content { display: none; position: absolute; border-radius: var(--border-radius); left: 50%; top: 120%; transform: translateX(-50%); min-width: 240px; background: var(--gdrive-tooltip-bg, #232c3b); color: var(--gdrive-tooltip-color, #e6ecfa); padding: 0.82em 0.82em; border-radius: 7px; box-shadow: 0 6px 24px var(--gdrive-tooltip-shadow, #0006); font: var(--font); font-size: 0.99em; font-weight: 500; line-height: 1.4; z-index: 9000; opacity: 0; pointer-events: none; transition: opacity 0.18s cubic-bezier(0.7, 1.5, 0.7, 1); white-space: normal; } .gdrive-tooltip-wrapper:hover .gdrive-tooltip-content, .gdrive-tooltip-wrapper:focus-within .gdrive-tooltip-content { display: block; opacity: 1; pointer-events: auto; } .gdrive-name.gdrive-tooltip-red { color: var(--gdrive-tooltip-red, #e75b5b); cursor: help; } .gdrive-tooltip-content b { color: var(--gdrive-tooltip-highlight, #ffd166); font-weight: 700; } .gdrive-custom-badge { font-size: 0.89em; color: var(--link-color); margin-left: 0.3em; font-weight: 500; opacity: 0.95; } .gdrive-sort-row { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.4em; } .gdrive-sort-label { color: var(--link-color); font-size: 1em; font-weight: 500; } .gdrive-sort-select { max-width: 240px; } /* ====================================================== LOADER SPINNER ====================================================== */ /* === Terminal Loader Spinner === */ @keyframes blinkCursor { 50% { border-right-color: transparent; } } @keyframes typeAndDelete { 0%, 10% { width: 0; } 45%, 85% { width: 11.5em; } 90%, 100% { width: 0; } } .poster-search-loader-modal { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; align-items: center; background: var(--overlay-bg); backdrop-filter: blur(1px); z-index: 12000; border-radius: var(--container-radius); min-height: 340px; } .terminal-loader { background: var(--terminal-bg); border: var(--terminal-border); color: var(--terminal-color); /* font-family: "Courier New", Courier, monospace; */ font-size: 1em; padding: 1.5em 1.5em; width: 14em; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); border-radius: 4px; position: relative; overflow: hidden; box-sizing: border-box; } .terminal-header { position: absolute; top: 0; left: 0; right: 0; height: 1.5em; background: var(--terminal-header-bg); border-top-left-radius: 4px; border-top-right-radius: 4px; padding: 0 0.4em; box-sizing: border-box; } .terminal-controls { float: right; } .control { display: inline-block; width: 0.6em; height: 0.6em; margin-left: 0.4em; border-radius: 50%; background-color: #777; } .control.close { background-color: var(--terminal-control-close); } .control.minimize { background-color: var(--terminal-control-min); } .control.maximize { background-color: var(--terminal-control-max); } .terminal-title { float: left; line-height: 1.5em; color: var(--terminal-title-color); } .text { display: inline-block; white-space: nowrap; overflow: hidden; color: var(--terminal-color); border-right: 0.2em solid var(--terminal-cursor-color); animation: typeAndDelete 4s steps(19) infinite, blinkCursor 0.5s step-end infinite alternate; margin-top: 1.5em; } /* ====================================================== IMAGE PREVIEW MODAL ====================================================== */ #img-preview-modal { display: none; position: fixed; z-index: 12000; inset: 0; justify-content: center; align-items: center; } #img-preview-modal.show { display: flex; } .img-modal-bg { position: absolute; left: 0; top: 0; right: 0; bottom: 0; background: var(--overlay-bg); } .img-modal-content { position: relative; z-index: 1; background: var(--card-bg); border-radius: var(--border-radius); padding: var(--card-padding); box-shadow: 0 8px 32px var(--shadow); max-width: 80vw; max-height: 80vh; display: flex; flex-direction: column; align-items: center; justify-content: center; } .img-modal-close { position: absolute; right: 0.9em; top: 0.6em; font-size: 2em; background: none; color: var(--text-color); border: none; cursor: pointer; z-index: 2; } .img-modal-img { max-width: 68vw; max-height: 62vh; margin-bottom: 1em; border-radius: var(--border-radius-default); box-shadow: 0 2px 12px var(--img-modal-shadow, #1117); background: var(--card-bg); } .img-modal-caption { color: var(--text-color); font-size: 1.02em; word-break: break-all; text-align: center; margin-top: 0.5em; opacity: 0.8; } /* ====================================================== HOVER PREVIEW IMAGE ====================================================== */ .hover-preview { pointer-events: none; position: fixed; border: 1px solid var(--hover-preview-border, #444); background: var(--card-bg); max-width: 200px; max-height: 200px; border-radius: var(--border-radius-default); z-index: 13000; display: none; box-shadow: 0 2px 14px var(--shadow); 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); } .poster-search-btn-row { margin: 1.5rem 0; } .poster-search-toggle-btn { margin-bottom: 0.8em; } .poster-search-spinner { display: none; text-align: center; margin-bottom: 1.3em; } .poster-search-results { margin-top: 1.5em; } ================================================ FILE: web/static/css/schedule.css ================================================ /* ====================================================== RUN BUTTON ====================================================== */ .run-btn.running { display: inline-flex; align-items: center; justify-content: center; text-align: center; background: var(--success); color: var(--primary-bg); cursor: default; } .run-btn.running.cancel-hover { background: var(--error) !important; color: var(--primary-bg) !important; cursor: pointer; } /* ====================================================== STATUS DISPLAY ====================================================== */ #status { margin-top: var(--status-margin-top, 1rem); font-size: var(--status-font-size, 1rem); font-weight: 600; text-align: center; } #status.error { color: var(--error); } /* ====================================================== RUN BUTTON SPINNER ====================================================== */ .run-btn.running::after { content: ''; display: inline-block; width: 1em; height: 1em; margin-left: 0.5rem; border: 2px solid var(--primary-bg); border-top: 2px solid var(--btn-hover-bg); border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle; } @keyframes spin { to { transform: rotate(360deg); } } /* ====================================================== SCHEDULE FORM CARD ANIMATION ====================================================== */ #scheduleForm .card { opacity: 0; transform: translateY(24px) scale(0.98); transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98), transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98); } #scheduleForm .card.show-card { opacity: 1; transform: translateY(0) scale(1); } ================================================ FILE: web/static/css/settings.css ================================================ /* ====================================================== SETTINGS PANEL LAYOUT & CORE FIELDS ====================================================== */ .label { margin: 0; font-size: 1rem; font-weight: 600; color: var(--fg); align-self: flex-start; } .field-control { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; } .subfield-list { width: 100%; } #settingsForm .subfield { display: flex; align-items: center; gap: 0.5em; margin-top: 0.15em; } .field-hint { margin-top: 0.25em; font-size: 0.95em; color: var(--fg-secondary); } .field-hint-warning-text { color: var(--error); } /* ====================================================== BUTTONS & ACTIONS ====================================================== */ .field > button.add-control-btn { margin-top: 0.75rem; align-self: start; grid-column: 1; grid-row: 2; position: relative; left: 0; } .setting-entry-actions { display: flex; flex-direction: row; gap: 0.5rem; margin-left: auto; align-self: center; } .remove-directory { min-width: 0; min-height: 20px; padding: 0.25rem 0.5rem; } /* ====================================================== DRAG & DROP/ENTRY HOVER ====================================================== */ .draggable.drag-over { border: 2px dashed var(--primary-bg); transform: translateY(5px); } /* ====================================================== ENTRY CARDS & LAYOUTS ====================================================== */ .card.setting-entry { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-end; } /* ====================================================== TABLES (UPGRADINATORR) ====================================================== */ .upgradinatorr-table { width: 100%; max-width: 100vw; border-collapse: separate; border-spacing: 0; margin-top: 0.5rem; background: var(--card-bg); border-radius: 12px; box-shadow: 0 2px 6px var(--shadow); overflow: hidden; table-layout: auto; font-size: 1rem; } .upgradinatorr-table th, .upgradinatorr-table td { padding: 0.5rem 1.2rem; border-bottom: 1px solid var(--shadow); vertical-align: middle; } .upgradinatorr-table th { background: var(--table-header); font-weight: 700; color: var(--fg); text-align: center; font-size: 1.08rem; letter-spacing: 0.01em; } .upgradinatorr-table td { background: var(--card-bg); font-size: 1rem; } .upgradinatorr-table tr:last-child td { border-bottom: none; } .upgradinatorr-table td:last-child { text-align: center; vertical-align: middle; white-space: nowrap; } .upgradinatorr-table tr:hover td { background: var(--card-hover-bg); transition: background 0.15s; } .upgradinatorr-table { border-radius: 12px; overflow: hidden; } .upgradinatorr-table thead tr:first-child th:first-child { border-top-left-radius: 12px; } .upgradinatorr-table thead tr:first-child th:last-child { border-top-right-radius: 12px; } .upgradinatorr-table tr:last-child td:first-child { border-bottom-left-radius: 12px; } .upgradinatorr-table tr:last-child td:last-child { border-bottom-right-radius: 12px; } /* ====================================================== PLEX INSTANCE CARD & LIBRARIES ====================================================== */ .card.plex-instance-card { width: 100% !important; flex-grow: 1; align-self: stretch; box-sizing: border-box; border-radius: 10px; display: flex; flex-direction: column; margin-bottom: 2rem; } .plex-instance-header { display: flex; justify-content: space-between; align-items: center; } .plex-instance-header h3 { margin: 0; font-size: 1.25rem; color: var(--fg); } .plex-libraries { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; overflow: hidden; opacity: 0; margin-top: 1rem; transition: max-height 0.4s ease, opacity 0.4s ease; } .plex-libraries.open { opacity: 1; } /* ====================================================== BORDER COLOR PICKER ====================================================== */ #border-colors-container { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0; min-height: 3rem; } #border-colors-container .subfield { display: inline-flex; align-items: center; gap: 0.5rem; } #border-colors-container .subfield input[type='color'] { width: 2rem; height: 2rem; padding: 0; border: none; background: none; } /* ====================================================== HOLIDAY COLOR SWATCHES/CARDS ====================================================== */ .holiday-swatch { display: inline-block; width: 1rem; height: 1rem; border-radius: 2px; margin: 0 0.25rem; vertical-align: middle; border: 1px solid var(--fg); } .holiday-card { background: var(--card-bg); padding: var(--card-padding); border-radius: var(--card-radius); box-shadow: var(--card-shadow); margin-bottom: 1rem; } .holiday-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; } .holiday-app { font-weight: 600; color: var(--fg); } .holiday-labels { font-style: italic; color: var(--fg-secondary, #aaa); margin-left: 1rem; } /* ====================================================== LABELARR MAPPING CARDS ====================================================== */ .labelarr-mapping-card { display: flex; align-items: center; justify-content: space-between; gap: 1.7rem; padding: 1.15rem 1.7rem; background: var(--card-bg); border-radius: 8px; border: var(--card-border); margin-bottom: 1rem; box-shadow: 0 1px 6px var(--shadow); } .labelarr-mapping-left { display: flex; flex-direction: column; align-items: flex-start; min-width: 180px; gap: 0.45em; } .mapping-app { font-size: 1.13em; color: var(--fg); font-weight: 700; letter-spacing: 0.01em; } .mapping-instance { font-size: 1em; color: var(--mapping-instance-color); } .mapping-instance span { color: var(--fg); font-weight: 500; } .mapping-labels { margin-top: 0.1em; display: flex; gap: 0.5em; flex-wrap: wrap; } .labelarr-label { background: var(--labelarr-label-bg); color: var(--labelarr-label-color); font-weight: 500; font-size: 0.99em; border-radius: 5px; padding: 0.16em 0.75em; letter-spacing: 0.01em; } .labelarr-label-empty { color: var(--labelarr-label-empty-color); } .labelarr-mapping-center { display: flex; align-items: center; justify-content: center; min-width: 40px; } .labelarr-arrow { font-size: 1.5em; color: var(--labelarr-arrow-color); } .labelarr-mapping-right { display: flex; flex-direction: row; flex-wrap: wrap; gap: 0.7em; min-width: 130px; max-width: 380px; } .labelarr-plex-target { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; margin-bottom: 0.15em; } .labelarr-plex-instance { color: var(--labelarr-plex-instance-color); font-weight: 600; font-size: 1.06em; } .labelarr-library { background: var(--labelarr-library-bg); color: var(--labelarr-library-color); border-radius: 4px; padding: 0.1em 0.8em; font-size: 0.99em; margin-left: 0.32em; } .labelarr-mapping-actions { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 0.75em; min-width: 120px; } .labelarr-mapping-left, .labelarr-mapping-center, .labelarr-mapping-right, .labelarr-mapping-actions { min-width: 0; } /* ====================================================== LIBRARY PILL COMPONENTS & ACTIONS ====================================================== */ .library-actions { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; } .library-actions > div { display: flex; gap: 0.5rem; } .library-pill { display: inline-flex; align-items: center; gap: 0.7em; padding: 0.48em 1.1em; background: var(--card-bg); border: var(--pill-border); border-radius: var(--pill-radius); font-size: 1rem; font-weight: 500; color: var(--fg); cursor: pointer; margin-bottom: 0.45rem; transition: border-color 0.18s, background 0.2s, box-shadow 0.18s; user-select: none; min-width: 0; min-height: 2.2em; } .library-pill input[type='checkbox'] { margin-right: 0.7em; accent-color: var(--primary); width: 1.15em; height: 1.15em; } .library-pill:hover, .library-pill:focus-within { border-color: var(--focus); background: var(--pill-hover-bg); box-shadow: var(--pill-hover-shadow); } .library-pill:active { background: var(--pill-active-bg); } .library-pill input[type='checkbox']:checked + span, .library-pill input[type='checkbox']:checked { font-weight: 600; } .library-pill input[type='checkbox']:focus { outline: 2px solid var(--focus); outline-offset: 2px; } .library-pill input[type='checkbox']:checked ~ * { color: var(--focus); } /* Animate settings cards (top-level .card, and GDrive sync .card.setting-entry) */ #settingsForm .card, #settingsForm .card.setting-entry { opacity: 0; transform: translateY(24px) scale(0.98); transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98), transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98); } #settingsForm .card.show-card, #settingsForm .card.setting-entry.show-card { opacity: 1; transform: translateY(0) scale(1); } /* Animate all fields inside cards */ #settingsForm .field { opacity: 0; transform: translateY(20px) scale(0.98); transition: opacity 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98), transform 0.28s cubic-bezier(0.44, 1.13, 0.73, 0.98); } #settingsForm .field.show-field { opacity: 1; transform: translateY(0) scale(1); } ================================================ FILE: web/static/js/common.js ================================================ const DAPS = { bindSaveButton, setSaveButtonState, markDirty, isDirty: false, skipDirtyCheck: false, showUnsavedModal, humanize, showToast, }; function bindSaveButton(saveBtn, buildPayloadFn, key, postSave) { if (!saveBtn) return; saveBtn.type = 'button'; saveBtn.onclick = async () => { await saveSection(buildPayloadFn, key, postSave); }; } function setSaveButtonState(saveBtn, state, label = 'Save') { if (!saveBtn) return; if (state === 'saving') { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; saveBtn.classList.remove('btn--success'); } else if (state === 'success') { saveBtn.disabled = true; saveBtn.textContent = 'Saved!'; saveBtn.classList.add('btn--success'); setTimeout(() => { saveBtn.disabled = false; saveBtn.textContent = label; saveBtn.classList.remove('btn--success'); }, 2000); } else { saveBtn.disabled = false; saveBtn.textContent = label; saveBtn.classList.remove('btn--success'); } } function markDirty() { DAPS.isDirty = true; } async function saveSection(buildPayload, key, postSave, saveBtn) { if (!saveBtn) saveBtn = document.getElementById('saveBtn'); setSaveButtonState(saveBtn, 'saving'); const payload = await buildPayload(); if (!payload || typeof payload[key] === 'undefined') { setSaveButtonState(saveBtn, 'default'); return; } try { const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw res; DAPS.isDirty = false; showToast(`✅ ${key.charAt(0).toUpperCase() + key.slice(1)} updated!`, 'success'); setSaveButtonState(saveBtn, 'success'); if (typeof postSave === 'function') postSave(); } catch (err) { let msg = err.statusText || 'Save failed'; try { const data = await err.json(); msg = data.error || msg; } catch {} showToast(`❌ ${msg}`, 'error'); setSaveButtonState(saveBtn, 'default'); } } function showUnsavedModal() { return new Promise((resolve) => { let modal = document.getElementById('unsavedModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'unsavedModal'; modal.innerHTML = ` `; document.body.appendChild(modal); } modal.classList.add('show'); requestAnimationFrame(() => { modal.classList.add('show'); document.body.classList.add('modal-open'); }); const saveBtn = modal.querySelector('.save-btn'); const discardBtn = modal.querySelector('.discard-btn'); const cancelBtn = modal.querySelector('.cancel-btn'); function cleanup(choice) { modal.classList.remove('show'); document.body.classList.remove('modal-open'); setTimeout(() => { modal.classList.remove('show'); }, 250); resolve(choice); } saveBtn.addEventListener( 'click', async function handler() { setSaveButtonState(saveBtn, 'saving', 'Save'); const pageSaveBtn = document.getElementById('saveBtn'); if (pageSaveBtn) { pageSaveBtn.click(); DAPS.isDirty = false; } else { console.warn('No Save button found for this page.'); } setSaveButtonState(saveBtn, 'success', 'Save'); saveBtn.removeEventListener('click', handler); setTimeout(() => cleanup('save'), 700); }, { once: true } ); discardBtn.addEventListener('click', () => cleanup('discard'), { once: true }); cancelBtn.addEventListener('click', () => cleanup('cancel'), { once: true }); }); } function humanize(key) { return key.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()); } function showToast(message, type = 'info', timeout = 3000) { const container = document.getElementById('toast-container'); if (!container) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; toast.addEventListener('click', () => { toast.classList.remove('show'); setTimeout(() => { if (toast.parentNode === container) container.removeChild(toast); }, 300); }); container.appendChild(toast); setTimeout(() => { toast.classList.add('show'); }, 100); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => { if (toast.parentNode === container) container.removeChild(toast); }, 500); }, timeout); } export { DAPS, showToast, humanize, showUnsavedModal }; ================================================ FILE: web/static/js/help_content.js ================================================ export const HELP_CONTENT = { schedule: [ 'Options:', 'hourly(XX)', 'Examples: hourly(00) or hourly(18) – Will perform the action every hour at the specified time', '', 'daily(XX:XX)', 'Examples: daily(12:23) or daily(18:15) – Will perform the action every day at the specified time', 'Examples: daily(10:18|12:23) – Will perform the action every day at the specified times', '', 'weekly(day_of_week@XX:XX)', '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', 'Examples: weekly(monday@12:23)', '', 'monthly(day_of_month@XX:XX)', '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', '', 'cron()', 'Examples: cron(0 0 * * *) – Will perform the action every day at midnight', 'Examples: cron(*/5 * * * *) – Will perform the action every 5 minutes', 'Examples: cron(0 */3 * * *) – Will perform the action every 3rd hour', [ 'Please visit ', { type: 'link', text: 'https://crontab.guru/', url: 'https://crontab.guru/' }, ' for more information on cron expressions', ], '', 'Note: You cannot use both cron and human-readable expressions in the same schedule.', '', 'Schedule only supports the following options: hourly, daily, weekly, monthly, cron', ], settings: [ { gdrive_sync: [ 'Sync GDrive posters/assets to your local collection. Each entry represents a GDrive connection.', "name: Friendly name for this GDrive (e.g., 'Main Posters').", 'id: The unique GDrive folder or shared drive ID.', 'location: Local directory to sync assets to (destination folder).', 'token: Paste the service account token or OAuth JSON here.', { type: 'link', text: 'rclone configuration wiki', url: 'https://github.com/Drazzilb08/daps/wiki/rclone-configuration', }, ], poster_renamerr: [ 'Organizes and renames poster files for Kometa/Plex.', 'source_dirs: One or more folders to scan for posters, priority is:', ' • Top = Lowest priority', ' • Bottom = Highest priority', 'destination_dir: Where renamed/organized posters are moved.', 'Asset Folders: This setting MUST be the same to what you have set in Kometa', "Print Only Renames: Print each file as it's processed.", 'Run Border Replacerr: Run border_replacer after renaming posters.', 'Incremental Border Replacerr: Border replacerr will only run on posters that have been renamed.', 'Instances: List the Radarr/Sonarr instances you wish to use as source for renaming of posters,', 'Plex is used for collections only and not as a source for Movies/TV Shows.', ], poster_cleanarr: [ 'Ignore Media: List of media to ignore during cleaning of posters from your assets directory.', 'Source Dirs: Folders to scan for posters to clean, typically your Kometa assets directory.', ], unmatched_assets: [ 'Finds assets/posters not matched to any item in your media library.', 'source_dirs: Folders to search for unmatched assets. Typically your assets directory.', ], border_replacerr: [ 'Adds or replaces borders on posters. Supports holiday presets and custom colors.', "Source/Destination Dirs: These fields is not required if you're planning on running border_replacerr in line with poster_renaemrr.", 'border_colors: Array of colors (HEX codes) for the border.', 'skip: Skips running border replacerr until a Holiday', 'exclusion_list: List of items to exclude from border replacement.', 'holiday_name: Label for this border/holiday.', "schedule: When this border should be active (see 'schedule' help).", 'destination_dir: Output directory for processed posters.', ], health_checkarr: [ 'Scans for media deleted from TMDB/TVDB and removes them from Sonarr/Radarr.', 'data_dir: Root folder for media scan.', "print_files: Print each item as it's processed.", ], labelarr: [ 'Syncs Radarr/Sonarr tags/labels to Plex.', 'app_type: Radarr or Sonarr.', 'app_instance: Name of the Radarr/Sonarr config instance.', 'labels: Comma-separated list of tags to sync.', 'plex_instances: List of Plex servers/libraries to sync with.', ], upgradinatorr: [ 'Automatically triggers upgrades/searches to maximize quality in Radarr/Sonarr.', 'instance: Name of the Radarr/Sonarr server.', 'count: Max number of searches per run.', 'tag_name: The tag used to mark an item as having been searched for upgrades.', 'ignore_tag: Do not upgrade media with this tag.', 'unattended: If true, skip confirmation.', 'season_monitored_threshold: Minimum monitored percentage per season (Sonarr only).', ], renameinatorr: [ 'Triggers Radarr/Sonarr rename jobs.', 'tag_name: The tag that will be used to mark as been renamed.', 'Count: The maximum global number of renames to perform.', 'Radarr Count: The maximum number of renames to perform in Radarr.', 'Sonarr Count: The maximum number of renames to perform in Sonarr.', 'instance: Server to run renames on.', ], nohl: [ 'Scans for non-hardlinked files and can auto-resolve them.', 'source_dirs: One or more directories to scan.', 'mode (per folder):', ' • Resolve: Delete+search to restore missing hardlinks automatically.', ' • Scan: Only log/report non-hardlinked files, do not resolve.', ], jduparr: [ 'Runs jdupes to find/remove duplicate files.', 'source_dirs: Folders to deduplicate.', ], }, ], }; ================================================ FILE: web/static/js/helper.js ================================================ import { HELP_CONTENT } from './help_content.js'; import { humanize } from './common.js'; export const moduleOrder = [ 'sync_gdrive', 'poster_renamerr', 'poster_cleanarr', 'unmatched_assets', 'border_replacerr', 'renameinatorr', 'upgradinatorr', 'nohl', 'labelarr', 'health_checkarr', 'jduparr', 'main', ]; export const NOTIFICATION_LIST = [ 'poster_renamerr', 'unmatched_assets', 'renameinatorr', 'upgradinatorr', 'nohl', 'labelarr', 'health_checkarr', 'jduparr', 'main' ]; export const NOTIFICATION_DEFINITIONS = { email: { label: 'Email', fields: [ { key: 'smtp_server', label: 'SMTP Server', type: 'text', dataType: 'string', required: true, placeholder: 'smtp.gmail.com', }, { key: 'smtp_port', label: 'SMTP Port', type: 'number', dataType: 'int', required: true, placeholder: '587', }, { key: 'username', label: 'Username', type: 'text', dataType: 'string', required: true, placeholder: 'user@example.com', }, { key: 'password', label: 'Password', type: 'password', dataType: 'string', required: true, placeholder: 'yourpassword or app password on gmail', }, { key: 'from', label: 'From', type: 'email', dataType: 'string', required: true, placeholder: 'noreply@example.com', }, { key: 'to', label: 'Recipients', type: 'textarea', dataType: 'list', required: true, placeholder: 'admin@example.com\nsupport@example.com', }, { key: 'use_tls', label: 'Use TLS', type: 'checkbox', dataType: 'bool', required: false, }, ], }, discord: { label: 'Discord', fields: [ { key: 'webhook', label: 'Webhook URL', type: 'text', dataType: 'string', required: true, placeholder: 'https://discord.com/api/webhooks/...', }, ], }, notifiarr: { label: 'Notifiarr', fields: [ { key: 'webhook', label: 'Webhook URL', type: 'text', dataType: 'string', required: true, placeholder: 'https://notifiarr.com/api/...', }, { key: 'channel_id', label: 'Channel ID', type: 'text', dataType: 'string', required: true, placeholder: '123456789012345678', }, ], }, }; export const NOTIFICATION_TYPES_PER_MODULE = { unmatched_assets: ['email'], main: ['discord', 'notifiarr'], }; export function renderHelp(sectionName) { function animateHeight(element, open = true, duration = 350) { if (!element) return; const startHeight = element.offsetHeight; element.style.height = startHeight + 'px'; element.style.overflow = 'hidden'; element.style.transition = `height ${duration}ms cubic-bezier(.44,1.13,.73,.98)`; const targetHeight = open ? element.scrollHeight : 0; void element.offsetHeight; element.style.height = targetHeight + 'px'; function afterTransition() { element.style.transition = ''; element.style.height = open ? '' : '0px'; element.style.overflow = open ? '' : 'hidden'; element.removeEventListener('transitionend', afterTransition); } element.addEventListener('transitionend', afterTransition); } if (!HELP_CONTENT || !sectionName) return null; let entry = HELP_CONTENT[sectionName]; if ( !entry && HELP_CONTENT.settings && Array.isArray(HELP_CONTENT.settings) && HELP_CONTENT.settings[0][sectionName] ) { entry = HELP_CONTENT.settings[0][sectionName]; } if (!entry) return null; const wrapper = document.createElement('div'); wrapper.className = 'help'; const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'help-toggle'; toggle.setAttribute('aria-label', `Show help for ${humanize(sectionName)}`); toggle.innerHTML = ` Show help for ${humanize(sectionName)}? `; const content = document.createElement('pre'); content.className = 'help-content'; content.innerHTML = Array.isArray(entry) ? entry .map((line) => Array.isArray(line) ? `
${line .map((part) => (typeof part === 'string' ? part : renderHelpLink(part))) .join('')}
` : `
${typeof line === 'string' ? line : renderHelpLink(line)}
` ) .join('') : entry; let isToggling = false; toggle.addEventListener('click', () => { if (isToggling) return; isToggling = true; const isOpen = content.classList.toggle('show'); if (isOpen) { content.style.maxHeight = content.scrollHeight + 'px'; content.addEventListener('transitionend', function handler(e) { if (e.propertyName === 'max-height' && content.classList.contains('show')) { content.style.maxHeight = 'none'; // "auto" sizing from now on content.removeEventListener('transitionend', handler); isToggling = false; } }); } else { content.style.maxHeight = content.scrollHeight + 'px'; // (in case it was 'none') void content.offsetHeight; content.style.maxHeight = '0px'; content.addEventListener('transitionend', function handler(e) { if (e.propertyName === 'max-height' && !content.classList.contains('show')) { isToggling = false; content.removeEventListener('transitionend', handler); } }); } }); wrapper.appendChild(toggle); wrapper.appendChild(content); return wrapper; } function renderHelpLink(item) { if (item && item.type === 'link' && item.url) { return `${ item.text || item.url }`; } return ''; } export async function fetchConfig() { try { const res = await fetch('/api/config'); if (!res.ok) throw new Error('Failed to fetch config'); return await res.json(); } catch (err) { console.error('Error loading config:', err); return {}; } } export async function fetchStats(location) { if (!location) return { error: true, file_count: 0, size_bytes: 0, files: [], }; try { const res = await fetch('/api/poster-search-stats', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ location, }), }); if (!res.ok) { return { error: true, file_count: 0, size_bytes: 0, files: [], }; } return await res.json(); } catch (err) { return { error: true, file_count: 0, size_bytes: 0, files: [], }; } } ================================================ FILE: web/static/js/index.js ================================================ import { fetchConfig } from './helper.js'; function parseVersionString(ver) { if (!ver) return {}; const parts = ver.trim().split('.'); if (parts.length < 4) return {}; const version = parts.slice(0, 3).join('.'); const branchAndBuild = parts[3]; const m = branchAndBuild.match(/^([a-zA-Z]+)(\d+)$/); let branch, build; if (m) { branch = m[1]; build = parseInt(m[2], 10); } else { branch = branchAndBuild.replace(/(\d+)$/, ''); const buildMatch = branchAndBuild.match(/(\d+)$/); build = buildMatch ? parseInt(buildMatch[1], 10) : null; } return { version, branch, build, full: ver.trim(), }; } async function getRemoteBuildCount(owner, repo, branch) { const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits?sha=${branch}&per_page=1`; try { const response = await fetch(apiUrl); if (!response.ok) return null; const link = response.headers.get('Link'); if (!link) return 1; // If only one commit const match = link.match(/&page=(\d+)>; rel="last"/); if (match) return parseInt(match[1], 10); return 1; } catch { return null; } } async function mainVersionCheck() { const localVerStr = await fetch('/api/version') .then((r) => r.text()) .catch(() => null); const local = parseVersionString(localVerStr); if (!local.version || !local.branch || local.build === null) { document.getElementById('version').textContent = 'Version: ' + (localVerStr || 'unknown'); return; } const remoteVersion = await fetch( `https://raw.githubusercontent.com/Drazzilb08/daps/${local.branch}/VERSION` ) .then((r) => (r.ok ? r.text() : null)) .catch(() => null); const remoteBuild = await getRemoteBuildCount('Drazzilb08', 'daps', local.branch); let remoteFull = ''; let updateAvailable = false; if (remoteVersion && remoteBuild !== null) { remoteFull = `${remoteVersion.trim()}.${local.branch}${remoteBuild}`; if (remoteVersion.trim() === local.version && remoteBuild > local.build) { updateAvailable = true; } else if (remoteVersion.trim() !== local.version) { updateAvailable = true; } } document.getElementById('version').textContent = 'Version: ' + local.full; const badge = document.getElementById('update-badge'); if (updateAvailable) { badge.style.display = ''; badge.title = ''; // Use custom tooltip badge.onclick = () => window.open('https://github.com/Drazzilb08/daps/releases', '_blank'); document.getElementById('tooltip-current-version').innerText = local.full; document.getElementById('tooltip-latest-version').innerText = remoteFull; } else { badge.style.display = 'none'; } } export function setTheme() { fetchConfig() .then((config) => { let theme = config && config.main && typeof config.main.theme === 'string' ? config.main.theme.toLowerCase() : 'light'; function applySystemTheme() { const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); try { localStorage.setItem('theme', isDark ? 'dark' : 'light'); } catch {} } if (window._themeMediaListener) { window .matchMedia('(prefers-color-scheme: dark)') .removeEventListener('change', window._themeMediaListener); window._themeMediaListener = null; } if (theme === 'auto') { applySystemTheme(); window._themeMediaListener = applySystemTheme; window .matchMedia('(prefers-color-scheme: dark)') .addEventListener('change', window._themeMediaListener); } else { document.documentElement.setAttribute( 'data-theme', theme === 'dark' ? 'dark' : 'light' ); try { localStorage.setItem('theme', theme); } catch {} } }) .catch((err) => { console.error('Failed to fetch config:', err); document.documentElement.setAttribute('data-theme', 'light'); try { localStorage.setItem('theme', 'light'); } catch {} }); } function showSplashScreen() { const viewFrame = document.getElementById('viewFrame'); if (!viewFrame) return; viewFrame.innerHTML = `
🚀

Welcome to DAPS

Select one of the options above to get started.

`; viewFrame.classList.add('splash-mask', 'fade-in'); const canvas = document.getElementById('splash-particles'); if (canvas) animateSplashParticles(canvas); const title = document.querySelector('.splash-title'); if (title) { const text = title.textContent; title.textContent = ''; let idx = 0; const typer = setInterval(() => { title.textContent += text[idx++]; if (idx === text.length) { clearInterval(typer); title.classList.add('splash-typing'); } }, 75); } const icon = document.querySelector('.splash-icon'); if (icon) { icon.classList.add('pulse'); } } function animateSplashParticles(canvas) { canvas.style.display = 'block'; const ctx = canvas.getContext('2d'); function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } resizeCanvas(); window.addEventListener('resize', resizeCanvas); const particles = Array.from({ length: 60 }, () => ({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, r: Math.random() * 2 + 1, dx: (Math.random() - 0.5) * 0.5, dy: (Math.random() - 0.5) * 0.5, })); function animateParticlesFrame() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = getComputedStyle(document.documentElement) .getPropertyValue('--splash-particle-color') .trim(); particles.forEach((p) => { ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); p.x += p.dx; p.y += p.dy; if (p.x < 0 || p.x > canvas.width) p.dx *= -1; if (p.y < 0 || p.y > canvas.height) p.dy *= -1; }); requestAnimationFrame(animateParticlesFrame); } animateParticlesFrame(); } setTheme(); mainVersionCheck(); showSplashScreen(); ================================================ FILE: web/static/js/instances.js ================================================ import { fetchConfig } from './helper.js'; import { buildInstancesPayload } from './payload.js'; import { DAPS } from './common.js'; const { bindSaveButton, showToast, humanize, markDirty } = DAPS; export async function loadInstances() { const config = await fetchConfig(); const instances = config.instances || {}; const form = document.getElementById('instancesForm'); if (!form) return; form.innerHTML = ''; for (const [service, items] of Object.entries(instances)) { const section = document.createElement('div'); section.className = 'category'; const h2 = document.createElement('h2'); h2.textContent = humanize(service); section.appendChild(h2); const listDiv = document.createElement('div'); for (const [name, settings] of Object.entries(items)) { const entry = createEntry(service, name, settings); listDiv.appendChild(entry); } const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'instance-btn btn'; addBtn.textContent = `+ Add ${humanize(service)}`; addBtn.addEventListener('click', () => { const newEntry = createEntry( service, '', { url: '', api: '', }, true ); listDiv.appendChild(newEntry); setTimeout(() => newEntry.classList.add('show-card'), 10); }); section.appendChild(listDiv); section.appendChild(addBtn); form.appendChild(section); } document.querySelectorAll('.card').forEach((el, i) => { setTimeout(() => el.classList.add('show-card'), i * 80); }); const saveBtn = document.getElementById('saveBtn'); bindSaveButton(saveBtn, buildInstancesPayload, 'instances'); } /** * Creates a DOM element representing an instance entry for a given service. * * @param {string} service - The service name. * @param {string} name - The instance name. * @param {Object} settings - The instance settings containing url and api key. * @param {boolean} [isNew=false] - Whether the entry is a newly added one. * @returns {HTMLElement} The DOM element representing the instance entry. */ function createEntry(service, name, settings, isNew = false) { const card = document.createElement('div'); card.className = 'card'; const field = document.createElement('div'); field.className = 'field'; const nameLabel = document.createElement('label'); nameLabel.textContent = 'Name'; const urlLabel = document.createElement('label'); urlLabel.textContent = 'URL'; const apiLabel = document.createElement('label'); apiLabel.textContent = 'API Key'; field.appendChild(nameLabel); // col 1 field.appendChild(urlLabel); // col 2 field.appendChild(apiLabel); // col 3 field.appendChild(document.createElement('div')); // col 4 (empty) const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.name = `${service}__name`; nameInput.value = name; nameInput.required = true; nameInput.placeholder = 'Instance Name'; nameInput.className = 'input'; field.appendChild(nameInput); const urlInput = document.createElement('input'); urlInput.type = 'text'; urlInput.name = `${service}__url`; urlInput.value = settings.url || ''; urlInput.placeholder = 'Instance URL'; urlInput.className = 'input'; field.appendChild(urlInput); const apiWrap = document.createElement('div'); apiWrap.className = 'password-wrapper'; const apiInput = document.createElement('input'); apiInput.type = 'text'; apiInput.name = `${service}__api`; apiInput.value = settings.api || ''; apiInput.className = 'input masked-input'; apiInput.autocomplete = 'off'; apiInput.placeholder = 'Paste API Key here'; const toggle = document.createElement('span'); toggle.className = 'toggle-password'; toggle.textContent = '👁️'; toggle.addEventListener('click', () => { const masked = apiInput.classList.toggle('masked-input'); toggle.textContent = masked ? '👁️' : '🙈'; }); apiWrap.appendChild(apiInput); apiWrap.appendChild(toggle); field.appendChild(apiWrap); const btnContainer = document.createElement('div'); btnContainer.className = 'btn-container'; const testBtn = document.createElement('button'); testBtn.type = 'button'; testBtn.textContent = 'Test'; testBtn.className = 'btn run-btn'; testBtn.addEventListener('click', async () => { testBtn.classList.remove('btn--success', 'btn--cancel', 'error'); testBtn.textContent = 'Testing...'; testBtn.classList.add('running'); testBtn.disabled = true; const res = await fetch('/api/test-instance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service, name: nameInput.value.trim(), url: urlInput.value.trim(), api: apiInput.value.trim(), }), }); if (res.ok) { showToast(`✅ ${nameInput.value.trim()} connection successful`, 'success'); testBtn.textContent = 'Success'; testBtn.classList.remove('running'); testBtn.classList.add('btn--success'); } else { const err = await res.json(); showToast( `❌ ${nameInput.value.trim()} test failed: ${err.error || res.statusText}`, 'error' ); testBtn.textContent = 'Fail'; testBtn.classList.remove('running'); testBtn.classList.add('btn--cancel', 'error'); } setTimeout(() => { testBtn.textContent = 'Test'; testBtn.classList.remove('btn--success', 'btn--cancel', 'error', 'running'); testBtn.disabled = false; }, 2500); }); btnContainer.appendChild(testBtn); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.textContent = '✖'; removeBtn.className = 'btn btn--cancel remove-instance'; removeBtn.addEventListener('click', () => { const instanceName = nameInput.value || ''; if (confirm(`Are you sure you want to remove instance "${instanceName}"?`)) { markDirty(); card.classList.add('removing'); setTimeout(() => card.remove(), 350); } }); btnContainer.appendChild(removeBtn); field.appendChild(btnContainer); // col 4, row 2 for (let i = 0; i < 4; ++i) field.appendChild(document.createElement('div')); card.appendChild(field); if (isNew) setTimeout(() => nameInput.focus(), 50); [nameInput, urlInput, apiInput].forEach((input) => input.addEventListener('keydown', (e) => { if (e.key === 'Enter') testBtn.click(); }) ); return card; } ================================================ FILE: web/static/js/logs.js ================================================ import { humanize } from './common.js'; import { moduleOrder } from './helper.js'; let term = null; // xterm.js instance let fitAddon = null; // xterm-addon-fit instance let currentFullLogText = ''; let lastWrittenLineCount = 0; let lastRenderedFileKey = null; export function buildLogControls() { const controlsDiv = document.createElement('div'); controlsDiv.className = 'log-controls log-toolbar'; const moduleSelect = document.createElement('select'); moduleSelect.className = 'select module-select'; moduleSelect.innerHTML = ``; const logfileSelect = document.createElement('select'); logfileSelect.className = 'select logfile-select'; logfileSelect.disabled = true; logfileSelect.innerHTML = ``; const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.className = 'input search-logs'; searchInput.placeholder = 'Search logs...'; const clearBtn = document.createElement('button'); clearBtn.className = 'clear-search btn'; clearBtn.textContent = 'Clear'; const downloadBtn = document.createElement('button'); downloadBtn.className = 'download-log btn'; downloadBtn.textContent = 'Download'; controlsDiv.appendChild(moduleSelect); controlsDiv.appendChild(logfileSelect); controlsDiv.appendChild(searchInput); controlsDiv.appendChild(clearBtn); controlsDiv.appendChild(downloadBtn); return controlsDiv; } function ensureLogControls() { const scrollContainer = document.getElementById('scroll-output-container'); if (!scrollContainer) return; if (!document.getElementById('log-empty-msg')) { const msg = document.createElement('div'); msg.id = 'log-empty-msg'; msg.textContent = 'No logs available.'; msg.style.display = 'none'; scrollContainer.appendChild(msg); } if (!document.getElementById('scroll-to-top')) { const btn = document.createElement('button'); btn.id = 'scroll-to-top'; btn.className = 'scroll-to-top'; btn.innerHTML = '↑ Top'; btn.style.display = 'none'; btn.onclick = () => term && term.scrollToTop && term.scrollToTop(); scrollContainer.appendChild(btn); } if (!document.getElementById('scroll-to-bottom')) { const btn = document.createElement('button'); btn.id = 'scroll-to-bottom'; btn.className = 'scroll-to-bottom'; btn.innerHTML = '↓ Bottom'; btn.style.display = 'none'; btn.onclick = () => term && term.scrollToBottom && term.scrollToBottom(); scrollContainer.appendChild(btn); } } function escapeRegex(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } const ANSI_COLORS = { RESET: '\x1b[0m', RED: '\x1b[31m', GREEN: '\x1b[32m', YELLOW: '\x1b[33m', BLUE: '\x1b[34m', MAGENTA: '\x1b[35m', CYAN: '\x1b[36m', WHITE: '\x1b[37m', BRIGHT_RED: '\x1b[91m', HIGHLIGHT: '\x1b[7m', }; function applyLogLevelAnsiColors(line) { if (line.includes('CRITICAL')) return `${ANSI_COLORS.BRIGHT_RED}${line}${ANSI_COLORS.RESET}`; else if (line.includes('ERROR')) return `${ANSI_COLORS.RED}${line}${ANSI_COLORS.RESET}`; else if (line.includes('WARNING')) return `${ANSI_COLORS.YELLOW}${line}${ANSI_COLORS.RESET}`; else if (line.includes('INFO')) return `${ANSI_COLORS.GREEN}${line}${ANSI_COLORS.RESET}`; else if (line.includes('DEBUG')) return `${ANSI_COLORS.CYAN}${line}${ANSI_COLORS.RESET}`; return line; } function renderToXTerm(text, options = {}) { ensureLogControls(); if (!term) return; if (!text || !text.trim()) { term.clear(); return; } const { isFiltered = false, forceClear = false, fileKey = null } = options; const lines = text.split('\n'); if (isFiltered || forceClear || fileKey !== lastRenderedFileKey) { term.clear(); lines.forEach((line) => { let processedLine = line; if (!isFiltered) { processedLine = applyLogLevelAnsiColors(line); } term.writeln(processedLine); }); lastWrittenLineCount = lines.length; if (fileKey) lastRenderedFileKey = fileKey; } else { for (let i = lastWrittenLineCount; i < lines.length; i++) { let processedLine = lines[i]; processedLine = applyLogLevelAnsiColors(processedLine); term.writeln(processedLine); } lastWrittenLineCount = lines.length; } setTimeout(handleXTermScroll, 25); } function handleXTermScroll() { if (!term) return; const topBtn = document.getElementById('scroll-to-top'); const botBtn = document.getElementById('scroll-to-bottom'); const viewportY = term.buffer.active.viewportY; const maxScroll = term.buffer.active.length - term.rows; if (topBtn) topBtn.style.display = viewportY > 0 ? 'block' : 'none'; if (botBtn) botBtn.style.display = viewportY < maxScroll ? 'block' : 'none'; } export async function loadLogs() { let currentModule = null; let currentFile = null; let activeLoadSessionId = Symbol(); if (term) { term.dispose(); term = null; } if (fitAddon) { fitAddon.dispose(); fitAddon = null; } if (window._activeLogsDestroy) window._activeLogsDestroy(); const containerIframe = document.querySelector('.container-iframe'); if (!containerIframe) return; const oldControls = containerIframe.querySelector('.log-controls'); if (oldControls) oldControls.remove(); const controlsDiv = buildLogControls(); containerIframe.insertBefore(controlsDiv, containerIframe.firstChild); document.body.classList.add('logs-open'); document.documentElement.classList.add('logs-open'); const moduleSelect = document.querySelector('.module-select'); const logfileSelect = document.querySelector('.logfile-select'); const searchInput = document.querySelector('.search-logs'); searchInput.placeholder = 'Filter logs (Ctrl/CMD+F)'; const clearBtn = document.querySelector('.clear-search'); const downloadBtn = document.querySelector('.download-log'); const logOutput = document.querySelector('.log-output'); if (!logOutput) return; logOutput.innerHTML = ''; term = new Terminal({ cursorBlink: true, convertEol: true, wordWrap: false, scrollback: 100000, // Show much more history theme: { background: '#1e1e1e', foreground: '#d4d4d4' }, }); fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.open(logOutput); try { fitAddon.fit(); } catch (e) { console.error('Error fitting terminal:', e); } const handleResize = () => { if (fitAddon) try { fitAddon.fit(); } catch (e) {} }; window.addEventListener('resize', handleResize); let refreshInterval = null; function setRefreshTask(callback, delay = 1000) { if (refreshInterval) clearInterval(refreshInterval); refreshInterval = setInterval(callback, delay); } let filterTimeout; async function loadModules() { const res = await fetch('/api/logs'); const data = await res.json(); const availableModules = Object.keys(data); const orderedModules = (moduleOrder || []).filter((m) => availableModules.includes(m)); for (const module of orderedModules) { const opt = document.createElement('option'); opt.value = module; opt.textContent = humanize?.(module) || module; moduleSelect.appendChild(opt); } const preselectedModule = window._preselectedLogModule || new URLSearchParams(window.location.search).get('module'); window._preselectedLogModule = null; if (preselectedModule) { moduleSelect.value = preselectedModule; loadLogFiles(preselectedModule); } } async function loadLogFiles(moduleName) { logfileSelect.innerHTML = ''; logfileSelect.disabled = true; if (!moduleName) return; const res = await fetch('/api/logs'); const data = await res.json(); const files = data[moduleName] || []; let defaultLog = null; for (const file of files) { const opt = document.createElement('option'); opt.value = file; opt.textContent = file; logfileSelect.appendChild(opt); if (file === `${moduleName}.log`) defaultLog = file; } logfileSelect.disabled = false; if (defaultLog) { logfileSelect.value = defaultLog; loadLogContent(moduleName, defaultLog); setRefreshTask(() => loadLogContent(moduleName, defaultLog)); } } async function loadLogContent(moduleName, fileName) { const requestKey = `${moduleName}/${fileName}`; currentModule = moduleName; currentFile = fileName; const sessionId = Symbol(); activeLoadSessionId = sessionId; if (!moduleName || !fileName) { currentFullLogText = ''; renderToXTerm('', { forceClear: true, fileKey: null }); return; } let spinner = null; let spinnerTimeout = setTimeout(() => { spinner = document.querySelector('.log-spinner'); if (!spinner) { spinner = document.createElement('div'); spinner.className = 'log-spinner'; logOutput.appendChild(spinner); } }, 250); // Only show spinner if >250ms try { const res = await fetch(`/api/logs/${moduleName}/${fileName}`); const text = await res.text(); clearTimeout(spinnerTimeout); spinner = document.querySelector('.log-spinner'); if (spinner) spinner.remove(); if ( activeLoadSessionId !== sessionId || moduleName !== currentModule || fileName !== currentFile ) { return; } currentFullLogText = text; const fileKeyForRender = `${moduleName}/${fileName}`; const searchValue = searchInput.value.trim(); if (searchValue) filterLogs(); else renderToXTerm(currentFullLogText, { fileKey: fileKeyForRender }); } catch (e) { clearTimeout(spinnerTimeout); spinner = document.querySelector('.log-spinner'); if (spinner) spinner.remove(); throw e; } } function filterLogs() { if (!term) return; const search = searchInput.value.toLowerCase(); if (!search) { renderToXTerm(currentFullLogText, { forceClear: true, fileKey: lastRenderedFileKey }); return; } const searchRegex = new RegExp(`(${escapeRegex(search)})`, 'gi'); const filteredLines = currentFullLogText .split('\n') .filter((line) => line.toLowerCase().includes(search)); const highlightedAndColoredLines = filteredLines.map((line) => { let processedLine = applyLogLevelAnsiColors(line); return processedLine.replace( searchRegex, (match) => `${ANSI_COLORS.HIGHLIGHT}${match}${ANSI_COLORS.RESET}` ); }); renderToXTerm(highlightedAndColoredLines.join('\n'), { isFiltered: true, fileKey: lastRenderedFileKey, }); } moduleSelect.addEventListener('change', (e) => { lastWrittenLineCount = 0; lastRenderedFileKey = null; if (refreshInterval) clearInterval(refreshInterval); refreshInterval = null; loadLogFiles(e.target.value); }); logfileSelect.addEventListener('change', (e) => { lastWrittenLineCount = 0; lastRenderedFileKey = null; if (refreshInterval) clearInterval(refreshInterval); refreshInterval = null; const selectedFile = e.target.value; loadLogContent(moduleSelect.value, selectedFile); setRefreshTask(() => loadLogContent(moduleSelect.value, selectedFile)); }); searchInput.addEventListener('input', () => { clearTimeout(filterTimeout); filterTimeout = setTimeout(() => { filterLogs(); }, 150); }); clearBtn.addEventListener('click', () => { searchInput.value = ''; filterLogs(); }); document.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { e.preventDefault(); searchInput.focus(); searchInput.select(); } }); downloadBtn.addEventListener('click', () => { if (!moduleSelect.value || !logfileSelect.value) return; const link = document.createElement('a'); link.href = `/api/logs/${moduleSelect.value}/${logfileSelect.value}`; link.download = logfileSelect.value; document.body.appendChild(link); link.click(); document.body.removeChild(link); }); window.addEventListener('popstate', () => { if (refreshInterval) clearInterval(refreshInterval); refreshInterval = null; }); window.addEventListener('beforeunload', () => { if (refreshInterval) clearInterval(refreshInterval); refreshInterval = null; }); window._activeLogsDestroy = () => { if (refreshInterval) clearInterval(refreshInterval); refreshInterval = null; activeLoadSessionId = null; window.removeEventListener('resize', handleResize); if (term) { term.dispose(); term = null; } if (fitAddon) { fitAddon.dispose(); fitAddon = null; } const scrollContainer = document.querySelector('.scroll-output-container'); if (scrollContainer) { const classListToRemove = ['scroll-to-top', 'scroll-to-bottom', 'log-empty-msg']; for (const className of classListToRemove) { const el = scrollContainer.querySelector(`.${className}`); if (el && el.parentNode === scrollContainer) { scrollContainer.removeChild(el); } } } }; if (term && typeof term.onScroll === 'function') { term.onScroll(handleXTermScroll); } setTimeout(handleXTermScroll, 250); loadModules(); } ================================================ FILE: web/static/js/main.js ================================================ // Core system scripts import './payload.js'; import './navigation.js'; import './common.js'; import './helper.js'; // Pages import './index.js'; import './schedule.js'; import './instances.js'; import './notifications.js'; import './poster_search.js'; import './settings.js'; import './logs.js'; ================================================ FILE: web/static/js/navigation.js ================================================ import { loadSchedule } from './schedule.js'; import { loadInstances } from './instances.js'; import { loadLogs } from './logs.js'; import { loadNotifications } from './notifications.js'; import { loadSettings } from './settings.js'; import { initPosterSearch } from './poster_search.js'; import { moduleOrder } from './helper.js'; import { DAPS, humanize, showUnsavedModal } from './common.js'; export const PAGE_LOADERS = { schedule: loadSchedule, instances: loadInstances, logs: loadLogs, notifications: loadNotifications, settings: loadSettings, poster_search: initPosterSearch, }; const EDITABLE_PAGES = [ '/pages/settings', '/pages/instances', '/pages/schedule', '/pages/notifications', ]; function isEditablePage(currentUrl) { return EDITABLE_PAGES.some((page) => currentUrl && currentUrl.includes(page)); } function highlightNav(frag, url) { document .querySelectorAll('.menu a, .dropdown-toggle, .dropdown-menu li a, .dropdown') .forEach((el) => { el.classList.remove('active'); }); if (!frag || frag === 'index' || !PAGE_LOADERS.hasOwnProperty(frag)) { return; } const linkIdMap = { schedule: 'link-schedule', instances: 'link-instances', notifications: 'link-notifications', logs: 'link-logs', poster_search: 'link-poster-search', }; if (frag in linkIdMap) { document.getElementById(linkIdMap[frag])?.classList.add('active'); } if (frag === 'settings') { const dropdown = document.querySelector('.dropdown'); dropdown?.classList.add('active'); const settingsToggle = document.querySelector('.dropdown-toggle'); settingsToggle?.classList.add('active'); const moduleParam = new URL(url, window.location.origin).searchParams.get('module_name'); if (moduleParam) { const moduleLink = document.querySelector( `#settings-dropdown li a[href*="module_name=${moduleParam}"]` ); moduleLink?.classList.add('active'); } } } export async function navigateTo(link) { document.querySelectorAll('.dropdown').forEach((d) => d.classList.remove('open')); const viewFrame = document.getElementById('viewFrame'); if (!viewFrame) return; viewFrame.classList.remove('fade-in'); viewFrame.classList.add('fade-out'); viewFrame.classList.remove('splash-mask'); let url = typeof link === 'string' ? link : link.href; let frag = ''; if (/\/pages\/([a-zA-Z0-9_\-]+)/.test(url)) { frag = url.match(/\/pages\/([a-zA-Z0-9_\-]+)/)[1]; } else if (/\/([a-zA-Z0-9_\-]+)$/.test(url)) { frag = url.match(/\/([a-zA-Z0-9_\-]+)$/)[1]; } frag = frag.replace(/-/g, '_').replace(/\.html$/, ''); if (viewFrame) viewFrame.dataset.currentUrl = url; window.currentFragmentUrl = url; highlightNav(frag, url); try { const response = await fetch(url); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); let bodyContent = doc.body ? doc.body.innerHTML : html; bodyContent = bodyContent.replace(/]*>/g, '').replace(/<\/script>/g, ''); setTimeout(async () => { viewFrame.innerHTML = bodyContent; document.body.classList.remove('logs-open'); viewFrame.classList.remove('fade-out'); viewFrame.classList.add('fade-in'); if (PAGE_LOADERS[frag]) { if (frag === 'settings') { const params = new URLSearchParams(url.split('?')[1] || ''); const moduleName = params.get('module_name'); await PAGE_LOADERS[frag](moduleName); } else { await PAGE_LOADERS[frag](); } } setupDropdownMenus(); }, 200); } catch (err) { if (typeof DAPS?.showToast === 'function') DAPS.showToast('Failed to load page', 'error'); console.error(err); } } async function populateSettingsDropdown() { const res = await fetch('/api/config'); const config = await res.json(); const dropdown = document.getElementById('settings-dropdown'); if (!dropdown) return; dropdown.innerHTML = ''; let currentModule = null; const url = window.currentFragmentUrl || ''; if (url.includes('/pages/settings')) { const params = new URLSearchParams(url.split('?')[1] || ''); currentModule = params.get('module_name'); } (moduleOrder || Object.keys(config)) .filter( (key) => config.hasOwnProperty(key) && !Object.keys(PAGE_LOADERS).includes(key) && key !== 'discord' ) .forEach((module) => { const li = document.createElement('li'); const a = document.createElement('a'); a.href = `/pages/settings?module_name=${module}`; a.textContent = humanize(module); if (currentModule && module === currentModule) { a.classList.add('active'); } li.appendChild(a); dropdown.appendChild(li); }); } document.addEventListener('change', function (e) { const viewFrame = document.getElementById('viewFrame'); const currentUrl = viewFrame?.dataset?.currentUrl || window.currentFragmentUrl || ''; const target = e.target; if ( isEditablePage(currentUrl) && target && target.matches('input, select, textarea') && target.id !== 'schedule-search' && target.id !== 'notifications-search' ) { DAPS.markDirty(); } }); window.addEventListener('beforeunload', function (e) { if (DAPS.isDirty) { e.preventDefault(); e.returnValue = ''; } }); document.addEventListener('click', async function (e) { let skip = false; if (DAPS.skipDirtyCheck) { skip = true; DAPS.skipDirtyCheck = false; } let el = e.target; while (el && el.nodeType !== 1) el = el.parentNode; if (!el) return; const anchor = el.closest('a'); if (!anchor || !anchor.href) return; const hrefUrl = new URL(anchor.href, window.location.origin); if (hrefUrl.origin !== window.location.origin) return; if ( anchor.target === '_blank' || anchor.href.startsWith('mailto:') || anchor.href.startsWith('javascript:') ) return; if (!hrefUrl.pathname.startsWith('/pages/')) return; e.preventDefault(); let dirty = DAPS.isDirty; const iframe = document.getElementById('viewFrame'); if ( iframe && iframe.contentWindow && iframe.contentWindow.DAPS && iframe.contentWindow.DAPS.isDirty ) { dirty = true; } let choice = null; if (!skip && dirty) { choice = await showUnsavedModal(); } if (!dirty || choice === 'save' || skip) { await navigateTo(anchor); } else if (choice === 'discard') { DAPS.isDirty = false; if (iframe && iframe.contentWindow && iframe.contentWindow.DAPS) { iframe.contentWindow.DAPS.isDirty = false; } await navigateTo(anchor); } }); function setupDropdownMenus() { document.querySelectorAll('.dropdown').forEach((dropdown) => { const oldToggle = dropdown.querySelector('.dropdown-toggle'); const oldMenu = dropdown.querySelector('.dropdown-menu'); if (!oldToggle || !oldMenu) return; const toggle = oldToggle.cloneNode(true); const menu = oldMenu.cloneNode(true); oldToggle.replaceWith(toggle); oldMenu.replaceWith(menu); let closeTimeout = null; toggle.addEventListener('mouseenter', () => { clearTimeout(closeTimeout); dropdown.classList.add('open'); }); toggle.addEventListener('click', (e) => { e.preventDefault(); clearTimeout(closeTimeout); dropdown.classList.toggle('open'); }); menu.addEventListener('mouseenter', () => { clearTimeout(closeTimeout); }); dropdown.addEventListener('mouseleave', () => { closeTimeout = setTimeout(() => { dropdown.classList.remove('open'); }, 500); // Adjust delay as needed }); menu.querySelectorAll('a').forEach((link) => { link.addEventListener('click', () => { dropdown.classList.remove('open'); }); }); }); } document.addEventListener('DOMContentLoaded', async () => { await populateSettingsDropdown(); setupDropdownMenus(); let path = window.location.pathname; let frag = ''; if (/\/pages\/([a-zA-Z0-9_\-]+)/.test(path)) { frag = path.match(/\/pages\/([a-zA-Z0-9_\-]+)/)[1]; } else if (/\/([a-zA-Z0-9_\-]+)$/.test(path)) { frag = path.match(/\/([a-zA-Z0-9_\-]+)$/)[1]; } frag = frag.replace(/-/g, '_').replace(/\.html$/, ''); highlightNav(frag, path); document.addEventListener('click', (e) => { if (!e.target.closest('.dropdown')) { document.querySelectorAll('.dropdown').forEach((d) => d.classList.remove('open')); } }); document.querySelectorAll('nav .menu a, .dropdown-toggle').forEach((link) => { link.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); link.click(); } }); }); }); export { populateSettingsDropdown }; ================================================ FILE: web/static/js/notifications.js ================================================ import { fetchConfig, NOTIFICATION_LIST, NOTIFICATION_DEFINITIONS, NOTIFICATION_TYPES_PER_MODULE, } from './helper.js'; import { buildNotificationPayload } from './payload.js'; import { DAPS } from './common.js'; const { bindSaveButton, showToast } = DAPS; export async function loadNotifications() { const form = document.getElementById('notificationsForm'); if (!form) return; const config = await fetchConfig(); const notifications = config.notifications || {}; const modules = Array.isArray(NOTIFICATION_LIST) ? NOTIFICATION_LIST : Object.keys(notifications); const DEFINITIONS = NOTIFICATION_DEFINITIONS || {}; const notifyTypes = Object.keys(DEFINITIONS); const allowedTypesMap = NOTIFICATION_TYPES_PER_MODULE || {}; form.innerHTML = ''; let cardIndex = 0; for (const module of modules) { const moduleSettings = notifications[module] || {}; const enabledTypes = Object.keys(moduleSettings); const card = document.createElement('div'); card.className = 'card'; const header = document.createElement('div'); header.className = 'card-header'; header.textContent = module.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); card.appendChild(header); const moduleAllowedTypes = allowedTypesMap[module] || notifyTypes; for (const type of moduleAllowedTypes) { const def = DEFINITIONS[type]; if (!def || !def.fields) continue; const isEnabled = enabledTypes.includes(type); const notifyObj = moduleSettings[type] && typeof moduleSettings[type] === 'object' ? moduleSettings[type] : {}; const fieldRow = document.createElement('div'); fieldRow.className = 'field toggle-row'; const toggleWrapper = document.createElement('label'); toggleWrapper.className = 'toggle-switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.name = `${module}_${type}`; input.checked = isEnabled; const slider = document.createElement('span'); slider.className = 'slider'; toggleWrapper.appendChild(input); toggleWrapper.appendChild(slider); const typeLabel = document.createElement('span'); typeLabel.textContent = def.label; typeLabel.className = 'toggle-label'; const flexSpacer = document.createElement('div'); flexSpacer.className = 'flex-spacer'; const testBtn = document.createElement('button'); testBtn.type = 'button'; testBtn.textContent = 'Test'; testBtn.className = 'btn btn--test'; if (isEnabled) testBtn.classList.add('enabled'); fieldRow.appendChild(toggleWrapper); fieldRow.appendChild(typeLabel); fieldRow.appendChild(flexSpacer); fieldRow.appendChild(testBtn); const fieldset = document.createElement('div'); fieldset.className = 'field notification-fieldset'; if (isEnabled) { fieldset.classList.add('expanded'); testBtn.classList.add('enabled'); fieldRow.classList.add('toggle-row--expanded'); } fieldset.dataset.notifyType = type; const legend = document.createElement('div'); legend.className = 'fieldset-legend'; legend.textContent = `${def.label} Settings`; fieldset.appendChild(legend); for (const fieldDef of def.fields) { const fieldContainer = document.createElement('div'); fieldContainer.className = 'notification-field-container'; const fieldLabel = document.createElement('label'); fieldLabel.textContent = fieldDef.label; fieldLabel.setAttribute('for', `${type}_${fieldDef.key}_${module}`); fieldContainer.appendChild(fieldLabel); let inputElement; const isPassword = fieldDef.key.toLowerCase().includes('password'); if (fieldDef.type === 'checkbox') { const toggleWrap = document.createElement('label'); toggleWrap.className = 'toggle-switch'; inputElement = document.createElement('input'); inputElement.type = 'checkbox'; inputElement.className = 'toggle-input'; inputElement.name = `${type}_${fieldDef.key}_${module}`; inputElement.required = fieldDef.required || false; inputElement.id = `${type}_${fieldDef.key}_${module}`; inputElement.checked = notifyObj[fieldDef.key] || false; const toggleSlider = document.createElement('span'); toggleSlider.className = 'slider'; toggleWrap.appendChild(inputElement); toggleWrap.appendChild(toggleSlider); fieldContainer.appendChild(toggleWrap); } else if (fieldDef.type === 'textarea') { inputElement = document.createElement('textarea'); inputElement.name = `${type}_${fieldDef.key}_${module}`; inputElement.className = 'input textarea-input'; inputElement.required = fieldDef.required || false; inputElement.id = `${type}_${fieldDef.key}_${module}`; inputElement.rows = 1; if (fieldDef.placeholder) inputElement.placeholder = fieldDef.placeholder; if (notifyObj[fieldDef.key] !== undefined && notifyObj[fieldDef.key] !== null) { inputElement.value = Array.isArray(notifyObj[fieldDef.key]) ? notifyObj[fieldDef.key].join(', ') : notifyObj[fieldDef.key]; } function autoExpandTextarea(el) { el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; } inputElement.addEventListener('input', () => autoExpandTextarea(inputElement)); setTimeout(() => autoExpandTextarea(inputElement), 0); fieldContainer.appendChild(inputElement); } else { inputElement = document.createElement('input'); inputElement.type = fieldDef.type === 'password' ? 'password' : fieldDef.type === 'number' ? 'number' : 'text'; inputElement.name = `${type}_${fieldDef.key}_${module}`; inputElement.className = 'input'; inputElement.required = fieldDef.required || false; inputElement.id = `${type}_${fieldDef.key}_${module}`; if (fieldDef.placeholder) inputElement.placeholder = fieldDef.placeholder; if (notifyObj[fieldDef.key] !== undefined && notifyObj[fieldDef.key] !== null) { inputElement.value = notifyObj[fieldDef.key]; } } if (isPassword && fieldDef.type !== 'checkbox') { const wrap = document.createElement('div'); wrap.className = 'password-wrapper'; wrap.style.position = 'relative'; inputElement.type = 'password'; const toggle = document.createElement('span'); toggle.className = 'toggle-password'; toggle.innerHTML = '👁️'; toggle.style.cursor = 'pointer'; toggle.addEventListener('click', () => { if (inputElement.type === 'password') { inputElement.type = 'text'; toggle.textContent = '🙈'; } else { inputElement.type = 'password'; toggle.textContent = '👁️'; } }); wrap.appendChild(inputElement); wrap.appendChild(toggle); fieldContainer.appendChild(wrap); } else if (fieldDef.type !== 'checkbox') { fieldContainer.appendChild(inputElement); } fieldset.appendChild(fieldContainer); } testBtn.addEventListener('click', async () => { testBtn.classList.remove('btn--success', 'btn--cancel', 'running'); testBtn.textContent = 'Testing...'; testBtn.classList.add('running'); testBtn.disabled = true; const missingFields = []; for (const fieldDef of def.fields) { if (fieldDef.required) { const name = `${type}_${fieldDef.key}_${module}`; const inputEl = fieldset.querySelector(`[name="${name}"]`); let value = inputEl?.type === 'checkbox' ? inputEl.checked : inputEl?.value?.trim(); if (inputEl?.tagName === 'TEXTAREA') value = inputEl.value .split(/[\n,]+/) .map((s) => s.trim()) .filter(Boolean); if (!value || (Array.isArray(value) && value.length === 0)) { missingFields.push(fieldDef.label); } } } if (missingFields.length > 0) { showToast( '❌ Required fields missing:\n' + missingFields.map((f) => `• ${f}`).join('\n'), 'error', 6000 ); resetTestButton(); return; } const notifyObj = {}; def.fields.forEach((fieldDef) => { const name = `${type}_${fieldDef.key}_${module}`; const input = fieldset.querySelector(`[name="${name}"]`); if (!input) return; let val; if (input.type === 'checkbox') val = input.checked; else if (input.tagName === 'TEXTAREA') val = input.value .split(/[\n,]+/) .map((s) => s.trim()) .filter(Boolean); else if (input.type === 'number') val = Number(input.value); else val = input.value; notifyObj[fieldDef.key] = val; }); const payload = { module, notifications: { [type]: notifyObj }, }; const res = await fetch('/api/test-notification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); let result; try { result = await res.json(); } catch { result = null; } if (res.ok && result && typeof result === 'object' && 'result' in result) { if (result.result) { showToast( `✅ ${module} (${type}) test notification: ${ result.message || 'Success' }`, 'success' ); testBtn.textContent = 'Success'; testBtn.classList.remove('running'); testBtn.classList.add('btn--success'); } else { showToast( `❌ ${module} (${type}) test notification: ${ result.message || 'Failed' }`, 'error', 6000 ); testBtn.textContent = 'Fail'; testBtn.classList.remove('running'); testBtn.classList.add('btn--cancel'); } } else { showToast( `❌ ${module} (${type}) test notification: Unexpected response`, 'error', 6000 ); testBtn.textContent = 'Fail'; testBtn.classList.remove('running'); testBtn.classList.add('btn--cancel'); } setTimeout(() => { testBtn.textContent = 'Test'; testBtn.classList.remove('btn--success', 'btn--cancel', 'running'); testBtn.disabled = false; }, 1200); }); function resetTestButton() { testBtn.textContent = 'Test'; testBtn.classList.remove('btn--success', 'btn--cancel', 'running'); testBtn.disabled = false; } input.addEventListener('change', () => { if (input.checked) { fieldset.classList.add('expanded'); testBtn.classList.add('enabled'); } else { fieldset.classList.remove('expanded'); testBtn.classList.remove('enabled'); } }); input.addEventListener('change', () => { if (input.checked) { fieldRow.classList.add('toggle-row--expanded'); fieldset.classList.add('expanded'); testBtn.classList.add('enabled'); } else { fieldRow.classList.remove('toggle-row--expanded'); fieldset.classList.remove('expanded'); testBtn.classList.remove('enabled'); } }); card.appendChild(fieldRow); card.appendChild(fieldset); } form.appendChild(card); setTimeout(() => card.classList.add('show-card'), 40 * cardIndex); cardIndex++; } const searchInput = document.getElementById('notifications-search'); if (searchInput) { searchInput.addEventListener('input', (e) => { window.skipDirtyCheck = true; searchInput.defaultValue = searchInput.value; const query = e.target.value.toLowerCase(); document.querySelectorAll('.card').forEach((card) => { let text = ''; const header = card.querySelector('.card-header'); if (header) text += header.textContent + ' '; card.querySelectorAll('.fieldset-legend').forEach((leg) => { text += leg.textContent + ' '; }); card.querySelectorAll('input, textarea').forEach((input) => { if ( input.tagName === 'TEXTAREA' || input.type === 'text' || input.type === 'number' ) { text += input.value + ' '; } else if (input.type === 'checkbox') { text += (input.checked ? 'true' : 'false') + ' '; } }); text = text.toLowerCase().trim(); card.style.display = query === '' || text.includes(query) ? 'flex' : 'none'; }); }); } const saveBtn = document.getElementById('saveBtn'); bindSaveButton(saveBtn, buildNotificationPayload, 'notifications'); } ================================================ FILE: web/static/js/payload.js ================================================ import { BOOL_FIELDS, INT_FIELDS, TEXTAREA_FIELDS, JSON_FIELDS } from './settings/constants.js'; import { NOTIFICATION_DEFINITIONS } from './helper.js'; import { getBorderReplacerrData } from './settings/modules/border_replacerr.js'; import { getLabelarrData } from './settings/modules/labelarr.js'; import { getGdriveSyncData } from './settings/modules/sync_gdrive.js'; import { getUpgradinatorrData } from './settings/modules/upgradinatorr.js'; export async function buildNotificationPayload() { const form = document.getElementById('notificationsForm'); if (!form) return null; const DEFINITIONS = NOTIFICATION_DEFINITIONS || {}; const result = {}; const missing = []; form.querySelectorAll('.card').forEach((card) => { const module = card .querySelector('.card-header') ?.textContent?.toLowerCase() .replace(/\s+/g, '_'); if (!module) return; const moduleObj = {}; const toggles = Array.from(card.querySelectorAll('.toggle-switch input')); toggles.forEach((toggle) => { const m = toggle.name.match(new RegExp(`^${module}_(.+)$`)); if (!m) return; const type = m[1], def = DEFINITIONS[type], fields = {}; if (def?.fields && toggle.checked) { def.fields.forEach((fd) => { const input = form.querySelector(`[name="${type}_${fd.key}_${module}"]`); if (!input) return; let val = input.type === 'checkbox' ? input.checked : input.tagName === 'TEXTAREA' ? input.value .split(/[\n,]+/) .map((s) => s.trim()) .filter(Boolean) : input.type === 'number' ? Number(input.value) : input.value.trim(); if (fd.required && (val === '' || (Array.isArray(val) && !val.length))) { missing.push(`${module}: ${type} – ${fd.label}`); } if (fd.key === 'channel_id' && (isNaN(val) || !Number.isInteger(Number(val)))) { missing.push(`${module}: ${type} – ${fd.label} must be integer`); } fields[fd.key] = val; }); moduleObj[type] = fields; } else if (toggle.checked) { moduleObj[type] = {}; } }); result[module] = moduleObj; }); if (missing.length) return null; return { notifications: result, }; } export async function buildSchedulePayload() { const form = document.getElementById('scheduleForm'); if (!form) return null; const data = new FormData(form), out = {}; for (const [k, v] of data.entries()) { out[k] = v.trim() || null; } return { schedule: out, }; } export async function buildInstancesPayload() { const form = document.getElementById('instancesForm'); if (!form) return null; const out = {}; form.querySelectorAll('.category').forEach((sec) => { const svc = sec.querySelector('h2')?.textContent.toLowerCase().replace(/ /g, '_'); out[svc] = {}; sec.querySelectorAll('.card').forEach((card) => { const field = card.querySelector('.field'); if (!field) return; const name = field.querySelector('input[name$="__name"]')?.value.trim(); const url = field.querySelector('input[name$="__url"]')?.value.trim(); const api = field.querySelector('input[name$="__api"]')?.value.trim(); if (name) out[svc][name] = { url, api, }; }); }); if (!Object.values(out).some((o) => Object.keys(o).length)) return null; return { instances: out, }; } export async function buildSettingsPayload(moduleName) { function fillPayloadFromFormData(data, payload, excludeKeys = []) { for (const [key, val] of data.entries()) { if (excludeKeys.includes(key)) continue; if (BOOL_FIELDS.includes(key)) { payload[key] = val === 'true'; } else if (INT_FIELDS.includes(key)) { payload[key] = parseInt(val, 10) || 0; } else if (TEXTAREA_FIELDS.includes(key)) { payload[key] = val .split('\n') .map((s) => s.trim()) .filter(Boolean); } else if (JSON_FIELDS.includes(key)) { try { payload[key] = JSON.parse(val); } catch { payload[key] = val; } } else { payload[key] = val; } } } function normalizeJsonStringKeysAndValues(jsonStr) { try { const parsed = JSON.parse(jsonStr); return JSON.stringify(parsed); } catch { let normalized = jsonStr.replace(/:\s*'([^']*)'/g, ': "$1"'); normalized = normalized.replace(/([{,]\s*)([a-zA-Z0-9_]+)(\s*:)/g, '$1"$2"$3'); normalized = normalized.replace(/:\s*([^"{\[\]\s,]+)(?=\s*[,}])/g, (match, val) => { const trimmed = val.trim(); if ( /^".*"$/.test(trimmed) || // already double-quoted /^[\d.eE+-]+$/.test(trimmed) || // number /^(true|false|null)$/.test(trimmed) // bool/null ) { return match; } return `: "${trimmed}"`; }); return normalized; } } const form = document.getElementById('settingsForm'); if (!form) return null; const data = new FormData(form); const payload = {}; const excludeKeys = []; if (moduleName === 'nohl') { excludeKeys.push('mode', 'source_dirs'); } if (moduleName === 'sync_gdrive') { try { const raw = data.get('token') || '{}'; const fixed = normalizeJsonStringKeysAndValues(raw); payload.token = JSON.parse(fixed); } catch { alert('Invalid token JSON'); return null; } payload.gdrive_list = (getGdriveSyncData() || []).filter( (e) => e && Object.keys(e).length > 0 ); excludeKeys.push('token', 'gdrive_list'); } if (moduleName === 'labelarr') { payload.mappings = getLabelarrData() || []; } if (moduleName === 'upgradinatorr') { payload.instances_list = getUpgradinatorrData(); } if (moduleName === 'border_replacerr') { const holidayArray = getBorderReplacerrData() || []; const holidaysObj = {}; holidayArray.forEach((entry) => { holidaysObj[entry.holiday] = { schedule: entry.schedule, color: entry.color, }; }); const globalColorContainer = document.querySelector('#border-colors-container'); const globalColorInputs = Array.from(globalColorContainer.children || []) .filter( (el) => el.classList.contains('subfield') && el.querySelector('input[type="color"]') ) .flatMap((el) => Array.from(el.querySelectorAll('input[type="color"]'))); payload.border_colors = globalColorInputs .map((i) => i.value) .filter((val, idx, arr) => arr.indexOf(val) === idx); // remove duplicates payload.holidays = holidaysObj; } if (moduleName === 'nohl') { // Always output source_dirs as array of {path, mode} for nohl, matching fields order const sourceFields = form.querySelectorAll('.subfield-list .subfield'); if (sourceFields.length > 0) { payload.source_dirs = Array.from(sourceFields) .map((sub) => { const pathInput = sub.querySelector('input[name="source_dirs"]'); const select = sub.querySelector('select[name="mode"]'); const path = pathInput ? pathInput.value.trim() : ''; // Always default mode to 'scan' if not set const mode = select && select.value ? select.value : 'scan'; if (!path) return null; return { path, mode }; }) .filter(Boolean); } else { payload.source_dirs = []; } } else if (moduleName === 'jduparr') { const sourceFields = form.querySelectorAll('.subfield-list .subfield'); if (sourceFields.length > 0) { payload.source_dirs = Array.from(sourceFields) .map((sub) => sub.querySelector('input[name="source_dirs"]')?.value.trim()) .filter(Boolean); } } const scalarInstances = data.getAll('instances'); const nestedInstances = {}; for (const [key, val] of data.entries()) { const match = key.match(/^instances\.(.+?)\.library_names$/); if (match) { const inst = match[1]; nestedInstances[inst] = nestedInstances[inst] || { library_names: [], }; nestedInstances[inst].library_names.push(val); } } const combinedInstances = [ ...scalarInstances, ...Object.entries(nestedInstances).map(([k, v]) => ({ [k]: v, })), ]; excludeKeys.push( 'instances', ...Array.from(data.keys ? data.keys() : []).filter((k) => k.startsWith('instances.')) ); fillPayloadFromFormData(data, payload, excludeKeys); // For nohl, do not apply legacy fallback for source_dirs (handled above). if (moduleName !== 'nohl' && data.has('source_dirs')) { payload.source_dirs = data .getAll('source_dirs') .map((v) => v.trim()) .filter(Boolean); } if (combinedInstances.length > 0) { payload.instances = combinedInstances; } return { [moduleName]: payload, }; } ================================================ FILE: web/static/js/poster_search.js ================================================ import { fetchConfig } from './helper.js'; import { showToast } from './common.js'; const IDS = { searchInput: 'poster-search-input', searchResults: 'poster-search-results', statsSpinner: 'poster-stats-spinner', scopeToggle: 'search-scope-toggle', scopeLabel: 'search-scope-label', }; let config = {}; let gdriveLocations = []; let customLocations = []; let gdriveFiles = []; let customFiles = []; let assetsDir = ''; let assetsFiles = []; let gdriveStatsData = []; let assetsStatsData = []; let gdriveTotals = { files: 0, size: 0 }; let assetsTotals = { files: 0, size: 0 }; let gdriveSortMode = 'priority-desc'; let priorityMap = {}; let loaderStartTime = 0; function showLoaderModal(show = true) { const container = document.querySelector('.container-iframe'); let loader = container.querySelector('.poster-search-loader-modal'); if (show) { loaderStartTime = Date.now(); if (!loader) { loader = document.createElement('div'); loader.className = 'poster-search-loader-modal'; loader.innerHTML = `
Status
Loading Posters...
`; container.insertBefore(loader, container.firstChild); } loader.style.display = 'flex'; } else if (loader) { const elapsed = Date.now() - loaderStartTime; const delay = Math.max(0, 4000 - elapsed); // 4s min for 1 cycle setTimeout(() => { loader.style.display = 'none'; }, delay); } } function formatBytes(bytes) { if (bytes < 1024) return bytes + ' B'; let kb = bytes / 1024; if (kb < 1024) return kb.toFixed(1) + ' KB'; let mb = kb / 1024; if (mb < 1024) return mb.toFixed(1) + ' MB'; return (mb / 1024).toFixed(2) + ' GB'; } function renderStatsTable(statsArr, totals, title) { if (!statsArr.length) return ''; const columns = [ { key: 'name', label: 'Folder', isNumeric: false }, { key: 'file_count', label: 'Files', isNumeric: true }, { key: 'size_bytes', label: 'Size', isNumeric: true }, { key: 'percent', label: '% of Total', isNumeric: true }, ]; let arr = statsArr.map((s) => ({ ...s, percent: totals.files ? (s.file_count / totals.files) * 100 : 0, })); let header = columns.map((col) => `${col.label}`).join(''); let rows = arr .map((s) => { let badge = s.isCustom ? ' (Custom)' : ''; let folderCol = ''; if (s.notInSource) { folderCol = ` ${s.name} This GDrive is not present in Poster Renamerr's Source Directories `; } else { folderCol = `${s.name}`; } const rowClass = s.error ? 'gdrive-row-error' : ''; return ` ${folderCol} ${s.file_count || 0} ${formatBytes(s.size_bytes || 0)}
${s.percent.toFixed(1)}% `; }) .join('\n'); return `
${title}
${header}${rows}
`; } function sortGdriveStats(statsArr, mode, priorityMap = {}) { if (!Array.isArray(statsArr)) return; let compare = () => 0; statsArr.forEach((s) => { s.file_count = typeof s.file_count === 'number' && !isNaN(s.file_count) ? s.file_count : Array.isArray(s.files) ? s.files.length : 0; }); if (mode.startsWith('priority')) { const asc = mode.endsWith('asc'); const inSource = statsArr.filter((s) => !s.notInSource); const notInSource = statsArr.filter((s) => s.notInSource); const compare = (a, b) => { const ap = priorityMap[a.location] ?? 9999; const bp = priorityMap[b.location] ?? 9999; return asc ? ap - bp : bp - ap; }; inSource.sort(compare); notInSource.sort((a, b) => String(a.name).localeCompare(String(b.name))); statsArr.splice(0, statsArr.length, ...inSource, ...notInSource); return; } else if (mode.startsWith('files')) { const asc = mode.endsWith('asc'); compare = (a, b) => (asc ? a.file_count - b.file_count : b.file_count - a.file_count); } else if (mode.startsWith('size')) { const asc = mode.endsWith('asc'); compare = (a, b) => (asc ? a.size_bytes - b.size_bytes : b.size_bytes - a.size_bytes); } else if (mode.startsWith('name')) { const asc = mode.endsWith('asc'); compare = (a, b) => asc ? String(a.name).localeCompare(String(b.name)) : String(b.name).localeCompare(String(a.name)); } statsArr.sort(compare); } async function fetchAndRenderStats() { showSpinner(true); if (!config || !Object.keys(config).length) config = await fetchConfig(); let gdriveLocations = (config.sync_gdrive?.gdrive_list || []).map((g) => ({ name: g.name, location: g.location, })); let gdriveLocSet = new Set(gdriveLocations.map((g) => g.location)); let sourceDirs = config.poster_renamerr?.source_dirs || []; let customDirs = sourceDirs.filter((dir) => !gdriveLocSet.has(dir)); let sourceDirSet = new Set(sourceDirs); let assetsDir = config.poster_renamerr?.destination_dir || ''; let customStatsArr = []; let gdriveStatsArr = []; if (customDirs.length) { let statsArr = await Promise.all( customDirs.map(async (dir) => { let res = await fetch('/api/poster-search-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: dir }), }); let stats = await res.json(); if (stats && !stats.error && typeof stats.file_count === 'number') { return { name: dir.split('/').pop(), location: dir, ...stats, isCustom: true, }; } if (stats.error) { return { name: dir.split('/').pop(), location: dir, file_count: 0, size_bytes: 0, files: [], isCustom: true, error: true, }; } return null; }) ); customStatsArr = statsArr.filter(Boolean); } let gdriveStatRaw = await Promise.all( gdriveLocations.map(async (l) => { let res = await fetch('/api/poster-search-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: l.location }), }); let stats = await res.json(); if (stats && !stats.error && typeof stats.file_count === 'number') { return { ...stats, name: l.name, location: l.location, isCustom: false, notInSource: !sourceDirSet.has(l.location), }; } if (stats.error) { return { name: l.name, location: l.location, file_count: 0, size_bytes: 0, files: [], isCustom: false, notInSource: !sourceDirSet.has(l.location), error: true, }; } return null; }) ); gdriveStatsArr = gdriveStatRaw.filter(Boolean); let mergedGdriveStats = [...gdriveStatsArr, ...customStatsArr]; mergedGdriveStats.forEach((s) => { s.file_count = Number(s.file_count) || 0; s.size_bytes = Number(s.size_bytes) || 0; }); let gTotals = { files: mergedGdriveStats.reduce((sum, s) => sum + s.file_count, 0), size: mergedGdriveStats.reduce((sum, s) => sum + s.size_bytes, 0), }; let aStats = null, aTotals = { files: 0, size: 0 }; if (assetsDir) { let res = await fetch('/api/poster-search-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: assetsDir }), }); let stats = await res.json(); if (!stats.error && typeof stats.file_count === 'number') { aStats = [ { name: 'Assets Dir', ...stats, }, ]; aTotals = { files: stats.file_count, size: stats.size_bytes }; } } gdriveStatsData = mergedGdriveStats.map((s) => ({ ...s })); gdriveTotals = gTotals; assetsStatsData = aStats || []; assetsTotals = aTotals; priorityMap = {}; sourceDirs.forEach((dir, idx) => { priorityMap[dir] = idx; }); sortGdriveStats(gdriveStatsData, gdriveSortMode, priorityMap); renderStatsSection(); showSpinner(false); } function renderStatsSection() { const statsCard = document.getElementById('poster-stats-card'); if (!statsCard) return; statsCard.className = 'card'; if (!statsCard.dataset.expanded) { statsCard.style.display = 'none'; } statsCard.style.marginBottom = '2em'; statsCard.innerHTML = `
${renderStatsTable([...gdriveStatsData], gdriveTotals, 'GDrive Locations')}
${renderStatsTable(assetsStatsData, assetsTotals, 'Assets Directory')}
`; const select = document.getElementById('gdrive-sort-select'); if (select) { select.value = gdriveSortMode; select.onchange = function () { gdriveSortMode = this.value; let arr = gdriveStatsData.map((s) => ({ ...s })); sortGdriveStats(arr, gdriveSortMode, priorityMap); document.getElementById('gdrive-stats-table').innerHTML = renderStatsTable( arr, gdriveTotals, 'GDrive Locations' ); }; } } function setupStatsToggle() { const btn = getById('toggle-stats-btn'); const card = getById('poster-stats-card'); btn.textContent = '📊 Show Statistics'; card.style.display = 'none'; card.dataset.expanded = ''; // Not expanded btn.onclick = function () { const expanded = card.style.display !== '' && card.style.display !== 'block'; if (expanded) { card.style.display = ''; card.dataset.expanded = '1'; btn.textContent = '📊 Hide Statistics'; } else { card.style.display = 'none'; card.dataset.expanded = ''; btn.textContent = '📊 Show Statistics'; } }; } function getById(id) { return document.getElementById(id); } function highlight(str, term) { if (!term) return str; const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); return str.replace(regex, `$1`); } function showSpinner(show) { const spinner = getById(IDS.statsSpinner); if (spinner) spinner.style.display = show ? '' : 'none'; } function materialIcon(name, style = '') { return `${name}`; } function showImageModal(imgSrc, caption) { closeImageModal(); const modal = document.createElement('div'); modal.id = 'img-preview-modal'; modal.className = 'show'; modal.innerHTML = `
Preview
${caption || ''}
`; document.body.appendChild(modal); modal.querySelector('.img-modal-bg').onclick = closeImageModal; modal.querySelector('.img-modal-close').onclick = closeImageModal; } function closeImageModal() { const old = document.getElementById('img-preview-modal'); if (old) old.remove(); } let hoverPreviewImg = null; function setupHoverPreview() { hoverPreviewImg = document.querySelector('.hover-preview'); if (!hoverPreviewImg) { hoverPreviewImg = document.createElement('img'); hoverPreviewImg.className = 'hover-preview'; hoverPreviewImg.style.display = 'none'; hoverPreviewImg.style.position = 'absolute'; hoverPreviewImg.style.pointerEvents = 'none'; hoverPreviewImg.style.maxWidth = '200px'; hoverPreviewImg.style.maxHeight = '200px'; hoverPreviewImg.style.zIndex = '10002'; document.body.appendChild(hoverPreviewImg); } } setupHoverPreview(); async function fetchAllFileLists() { showSpinner(true); config = await fetchConfig(); gdriveLocations = (config.sync_gdrive?.gdrive_list || []).map((g) => ({ name: g.name, location: g.location, })); const gdriveLocSet = new Set(gdriveLocations.map((g) => g.location)); const sourceDirs = config.poster_renamerr?.source_dirs || []; customLocations = sourceDirs.filter((dir) => !gdriveLocSet.has(dir)); assetsDir = config.poster_renamerr?.destination_dir || ''; gdriveFiles = []; for (const { name, location } of gdriveLocations) { try { const res = await fetch('/api/poster-search-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location }), }); const stats = await res.json(); if (Array.isArray(stats.files)) { stats.files.forEach((f) => gdriveFiles.push({ file: f, name, location })); } } catch {} } customFiles = []; for (const dir of customLocations) { try { const res = await fetch('/api/poster-search-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: dir }), }); const stats = await res.json(); if (Array.isArray(stats.files)) { stats.files.forEach((f) => customFiles.push({ file: f, name: dir.split('/').pop() + ' (Custom)', location: dir, }) ); } } catch {} } assetsFiles = []; if (assetsDir) { try { const res = await fetch('/api/poster-search-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: assetsDir }), }); const stats = await res.json(); if (Array.isArray(stats.files)) { assetsFiles = stats.files; } } catch { assetsFiles = []; } } showSpinner(false); } function renderResults(term) { const resultsDiv = getById(IDS.searchResults); let html = ''; let useAssets = getById(IDS.scopeToggle).checked; if (!useAssets) { const groups = {}; [...gdriveFiles, ...customFiles].forEach(({ file, name, location }) => { if (!term || file.toLowerCase().includes(term)) { const key = name + '||' + location; if (!groups[key]) groups[key] = { name, location, files: [] }; groups[key].files.push(file); } }); Object.values(groups).forEach((group) => { const locate = encodeURIComponent(group.location); html += `
${ group.name }
    ${group.files .map( (f) => `` ) .join('')}
`; }); } if (useAssets && assetsFiles.length) { const matches = assetsFiles.filter((file) => { if (file.startsWith('tmp/')) return false; if (file === '.DS_Store') return false; if (!term) return true; const lower = file.toLowerCase(); const fname = file.split('/').pop().toLowerCase(); return lower.includes(term) || fname.includes(term); }); if (matches.length) { const locate = encodeURIComponent(assetsDir); html += `
Assets Dir
    ${matches .map( (f) => `` ) .join('')}
`; } } resultsDiv.innerHTML = html || `
No results found. Try another search or check your filters.
`; } function copyToClipboard(btn, text) { navigator.clipboard .writeText(text) .then(() => { const def = btn.querySelector('.copy-btn-default'); const copied = btn.querySelector('.copy-btn-copied'); if (def && copied) { def.style.display = 'none'; copied.style.display = 'inline'; setTimeout(() => { def.style.display = ''; copied.style.display = 'none'; }, 1400); } }) .catch(() => { showToast && showToast('Could not copy to clipboard.', 'error'); }); } function setupEventListeners() { const toggle = getById(IDS.scopeToggle); const label = getById(IDS.scopeLabel); toggle.checked = false; label.textContent = 'GDrive Locations'; toggle.onchange = () => { label.textContent = toggle.checked ? 'Assets Directory' : 'GDrive Locations'; getById(IDS.searchInput).value = ''; getById(IDS.searchResults).innerHTML = ''; }; document.addEventListener('keydown', (e) => { const input = getById(IDS.searchInput); const modal = document.getElementById('img-preview-modal'); if ((e.key === '/' && !e.ctrlKey) || (e.key === 'f' && e.ctrlKey)) { e.preventDefault(); input && input.focus(); } else if (e.key === 'Escape') { if (modal) closeImageModal(); else input && (input.value = ''); } else if (e.key === 'Enter' && document.activeElement === input) { e.preventDefault(); renderResults(input.value.trim().toLowerCase()); } }); getById(IDS.searchInput).onkeypress = (e) => { if (e.key === 'Enter') { e.preventDefault(); renderResults(e.target.value.trim().toLowerCase()); } }; getById(IDS.searchResults).addEventListener('click', (e) => { const copyBtn = e.target.closest('.copy-btn'); if (copyBtn) { e.stopPropagation(); let file = copyBtn.getAttribute('aria-label') || ''; file = file .replace(/^Copy filename\s*/i, '') .replace(/^Copied\s*/i, '') .trim(); if (!file) { const span = copyBtn.closest('li')?.querySelector('.poster-file-label'); if (span) file = span.textContent; } copyToClipboard(copyBtn, file); return false; } const label = e.target.closest('.poster-file-label'); if (label) { let location = decodeURIComponent(label.getAttribute('data-location') || ''); let path = decodeURIComponent(label.getAttribute('data-file') || ''); let caption = label.textContent; if (location && path) { const url = `/api/preview-poster?location=${encodeURIComponent( location )}&path=${encodeURIComponent(path)}`; showImageModal(url, caption); } return false; } }); getById(IDS.searchResults).addEventListener('mouseover', (e) => { const label = e.target.closest('.poster-file-label'); if (label) { let location = decodeURIComponent(label.getAttribute('data-location') || ''); let path = decodeURIComponent(label.getAttribute('data-file') || ''); if (location && path) { const url = `/api/preview-poster?location=${encodeURIComponent( location )}&path=${encodeURIComponent(path)}&thumb=1`; hoverPreviewImg.src = url; hoverPreviewImg.style.display = 'block'; } } }); getById(IDS.searchResults).addEventListener('mousemove', (e) => { if (hoverPreviewImg && hoverPreviewImg.style.display === 'block') { const imgWidth = hoverPreviewImg.naturalWidth ? Math.min(hoverPreviewImg.naturalWidth, 200) : 200; const imgHeight = hoverPreviewImg.naturalHeight ? Math.min(hoverPreviewImg.naturalHeight, 200) : 200; const vpWidth = window.innerWidth; const vpHeight = window.innerHeight; let left = e.pageX + 14; let top = e.pageY + 14; if (left + imgWidth > vpWidth - 10) left = Math.max(10, vpWidth - imgWidth - 10); if (top + imgHeight > vpHeight - 10) top = Math.max(10, vpHeight - imgHeight - 10); hoverPreviewImg.style.left = left + 'px'; hoverPreviewImg.style.top = top + 'px'; } }); getById(IDS.searchResults).addEventListener('mouseout', (e) => { if (e.target.closest('.poster-file-label')) { hoverPreviewImg.style.display = 'none'; } }); } export async function initPosterSearch() { showLoaderModal(true); getById(IDS.searchResults).innerHTML = ''; getById(IDS.searchInput).value = ''; await fetchAllFileLists(); setupEventListeners(); showLoaderModal(false); setupStatsToggle(); await fetchAndRenderStats(); } ================================================ FILE: web/static/js/schedule.js ================================================ import { fetchConfig, renderHelp, moduleOrder } from './helper.js'; import { buildSchedulePayload } from './payload.js'; import { navigateTo } from './navigation.js'; import { DAPS } from './common.js'; const { bindSaveButton, showToast, humanize } = DAPS; export async function loadSchedule() { const config = await fetchConfig(); const schedule = config.schedule || {}; const form = document.getElementById('scheduleForm'); if (!form) return; form.innerHTML = ''; const help = renderHelp('schedule'); if (help) form.before(help); const orderedModules = (moduleOrder || Object.keys(schedule)).filter((m) => schedule.hasOwnProperty(m) ); for (const [i, module] of orderedModules.entries()) { const time = schedule[module]; const label = document.createElement('label'); label.textContent = humanize(module); const input = document.createElement('input'); input.type = 'text'; input.name = module; input.value = time || ''; input.className = 'input'; input.placeholder = 'e.g. hourly(01), daily(12:00|18:00), weekly(Mon@12:00|Tue@18:00)'; const field = document.createElement('div'); field.className = 'field'; field.appendChild(label); field.appendChild(input); input.addEventListener('input', () => { if (!input.value.trim() || isValidSchedule(input.value.trim())) { input.classList.remove('input-invalid'); } else { input.classList.add('input-invalid'); } }); const runBtn = document.createElement('button'); runBtn.type = 'button'; runBtn.textContent = 'Run Now'; runBtn.className = 'run-btn btn'; runBtn.addEventListener('mouseenter', () => { if (runBtn.classList.contains('running')) { runBtn.textContent = 'Cancel'; runBtn.classList.add('cancel-hover'); } }); runBtn.addEventListener('mouseleave', () => { if (runBtn.classList.contains('running')) { runBtn.textContent = 'Running'; runBtn.classList.remove('cancel-hover'); } }); runBtn.addEventListener('click', async () => { if (runBtn.classList.contains('running')) { runBtn.textContent = 'Canceling'; await fetch('/api/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ module }), }); runBtn.classList.remove('running'); runBtn.textContent = 'Run Now'; showToast(`🛑 ${humanize(module)} cancelled successfully.`, 'info'); return; } runBtn.textContent = 'Running'; runBtn.classList.add('running'); if (!btnContainer.querySelector('.run-btn + .run-btn')) { const viewLogsBtn = document.createElement('button'); viewLogsBtn.type = 'button'; viewLogsBtn.textContent = 'View Logs'; viewLogsBtn.className = 'run-btn btn'; viewLogsBtn.addEventListener('click', () => { window._preselectedLogModule = module; window.skipDirtyCheck = true; const link = document.createElement('a'); link.href = '/pages/logs'; navigateTo(link); }); btnContainer.appendChild(viewLogsBtn); } const res = await fetch('/api/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ module }), }); if (!res.ok) { const err = await res.json(); runBtn.classList.remove('running'); runBtn.textContent = 'Run Now'; showToast( `❌ Failed to start ${humanize(module)}: ${err.error || res.statusText}`, 'error' ); return; } showToast(`▶️ ${humanize(module)} started successfully!`, 'success'); const interval = setInterval(async () => { const resStatus = await fetch(`/api/status?module=${module}`); const { running } = await resStatus.json(); if (!running) { runBtn.classList.remove('running'); runBtn.textContent = 'Run Now'; clearInterval(interval); } }, 2000); }); const btnContainer = document.createElement('div'); btnContainer.className = 'btn-container'; btnContainer.appendChild(runBtn); field.appendChild(btnContainer); (async () => { const resStatus = await fetch(`/api/status?module=${module}`); const { running } = await resStatus.json(); if (running) { runBtn.textContent = 'Running'; runBtn.classList.add('running'); const viewLogsBtn = document.createElement('button'); viewLogsBtn.type = 'button'; viewLogsBtn.textContent = 'View Logs'; viewLogsBtn.className = 'run-btn btn '; viewLogsBtn.addEventListener('click', () => { window._preselectedLogModule = module; window.skipDirtyCheck = true; const link = document.createElement('a'); link.href = '/fragments/logs'; window.DAPS.navigateTo(link); }); btnContainer.appendChild(viewLogsBtn); } })(); const card = document.createElement('div'); card.className = 'card'; card.appendChild(field); form.appendChild(card); setTimeout(() => card.classList.add('show-card'), 40 * i); } if (window._scheduleRunInterval) { clearInterval(window._scheduleRunInterval); window._scheduleRunInterval = null; } window._scheduleRunInterval = setInterval(() => { document.querySelectorAll('.field').forEach((field) => { const inp = field.querySelector('input'); const runBtn = field.querySelector('button.run-btn'); if (!inp || !runBtn) return; const module = inp.name; fetch(`/api/status?module=${module}`) .then((res) => res.json()) .then(({ running }) => { if (running && !runBtn.classList.contains('running')) { runBtn.textContent = 'Running'; runBtn.classList.add('running'); const btnContainer = runBtn.parentElement; const viewExists = btnContainer.querySelector('.run-btn + .run-btn'); if (!viewExists) { const viewLogsBtn = document.createElement('button'); viewLogsBtn.type = 'button'; viewLogsBtn.textContent = 'View Logs'; viewLogsBtn.className = 'run-btn'; viewLogsBtn.addEventListener('click', () => { window._preselectedLogModule = module; const link = document.createElement('a'); link.href = '/fragments/logs'; window.DAPS.navigateTo(link); }); btnContainer.appendChild(viewLogsBtn); } } else if (!running && runBtn.classList.contains('running')) { runBtn.classList.remove('running'); runBtn.textContent = 'Run Now'; const btnContainer = runBtn.parentElement; const viewLogsBtn = btnContainer.querySelector('.run-btn + .run-btn'); if (viewLogsBtn) btnContainer.removeChild(viewLogsBtn); } }); }); }, 3000); const saveBtn = document.getElementById('saveBtn'); bindSaveButton(saveBtn, buildSchedulePayload, 'schedule'); const searchInput = document.getElementById('schedule-search'); if (searchInput) { searchInput.addEventListener('input', (e) => { DAPS.skipDirtyCheck = true; searchInput.defaultValue = searchInput.value; const query = e.target.value.toLowerCase(); document.querySelectorAll('.card').forEach((card) => { const text = card.textContent.toLowerCase(); card.style.display = text.includes(query) ? 'flex' : 'none'; }); }); } } function isValidSchedule(val) { if (!val) return true; if (/^hourly\(\d{2}\)$/i.test(val)) return true; if (/^daily\(\d{2}:\d{2}(?:\|\d{2}:\d{2})*\)$/i.test(val)) return true; if (/^weekly\([a-z]+@\d{2}:\d{2}(?:\|[a-z]+@\d{2}:\d{2})*\)$/i.test(val)) return true; if (/^monthly\(\d{1,2}@\d{2}:\d{2}(?:\|\d{1,2}@\d{2}:\d{2})*\)$/i.test(val)) return true; if (/^cron\([^\)]+\)$/i.test(val)) return true; return false; } ================================================ FILE: web/static/js/settings/constants.js ================================================ export const BOOL_FIELDS = [ 'dry_run', 'skip', 'sync_posters', 'run_border_replacerr', 'print_files', 'rename_folders', 'unattended', 'enable_batching', 'asset_folders', 'print_only_renames', 'incremental_border_replacerr', 'silent', 'disable_batching', 'replace_border', 'update_notifications', ]; export const TEXT_FIELDS = [ 'tag_name', 'ignore_tag', 'custom_format', 'title', 'alt_title', 'poster_path', ]; export const TEXTAREA_FIELDS = [ 'exclude_profiles', 'exclude_movies', 'exclude_series', 'exclusion_list', 'exclude', 'token', 'ignore_collections', 'ignore_root_folders', 'ignore_media', ]; export const INT_FIELDS = [ 'count', 'radarr_count', 'sonarr_count', 'season_monitored_threshold', 'border_width', 'searches', ]; export const JSON_FIELDS = ['token']; export const DROP_DOWN_FIELDS = ['log_level', 'action_type', 'app_type', 'app_instance', 'theme']; // Add to constants.js export const DROP_DOWN_OPTIONS = { mode: ['resolve', 'symlink', 'hardlink'], log_level: ['info', 'debug'], action_type: ['copy', 'move', 'hardlink', 'symlink'], theme: ['light', 'dark', 'auto'], month: [ { value: '01', label: 'Jan', days: 31 }, { value: '02', label: 'Feb', days: 28 }, { value: '03', label: 'Mar', days: 31 }, { value: '04', label: 'Apr', days: 30 }, { value: '05', label: 'May', days: 31 }, { value: '06', label: 'Jun', days: 30 }, { value: '07', label: 'Jul', days: 31 }, { value: '08', label: 'Aug', days: 31 }, { value: '09', label: 'Sep', days: 30 }, { value: '10', label: 'Oct', days: 31 }, { value: '11', label: 'Nov', days: 30 }, { value: '12', label: 'Dec', days: 31 }, ], }; export const DIR_PICKER = ['source_dirs', 'destination_dir', 'data_dir']; export const ARR_AND_PLEX_INSTANCES = [ 'poster_renamerr', 'labelarr', 'border_replacerr', 'sync_gdrive', 'nohl', 'unmatched_assets', 'poster_cleanarr', 'health_checkarr', 'renameinatorr', ]; export const SHOW_PLEX_IN_INSTANCE_FIELD = [ 'poster_renamerr', 'unmatched_assets', 'poster_cleanarr' ]; export const DRAG_AND_DROP = { poster_renamerr: ['source_dirs'], }; export const LIST_FIELD = { unmatched_assets: ['source_dirs'], poster_cleanarr: ['source_dirs'], nohl: ['source_dirs'], }; export const PLACEHOLDER_TEXT = { sync_gdrive: { name: 'Unique name for your Gdrive', 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}', gdrive_sa_location: 'Click to pick your service account file…', location: 'Click to pick the destination directory', id: 'Paste the Gdrive ID to pull posters from', client_id: 'asdasds.apps.googleusercontent.com', client_secret: 'GOCSPX-asda123', }, poster_renamerr: { source_dirs: 'Click to pick a source directory...', destination_dir: '/path/to/Kometa/assets_directory', }, upgradinatorr: { data_dir: '/path/to/media_folder', instance: 'Select an instance', count: '0', tag_name: 'Enter the tag you wish to use', ignore_tag: 'The tag you wish to use to ignore an entry', }, renameinatorr: { tag_name: 'Enter the tag you wish to use', }, nohl: { source_dirs: 'Click to pick a source directory...', }, border_replacerr: { holiday_name: 'Holiday name', }, labelarr: { labels: 'Comma-separated list of labels', }, }; ================================================ FILE: web/static/js/settings/modal_helpers.js ================================================ import { DROP_DOWN_OPTIONS } from './constants.js'; import { holidayPresets } from './presets.js'; export function populateScheduleDropdowns() { const months = DROP_DOWN_OPTIONS.month; // Array of { value, label, days } ['from', 'to'].forEach((type) => { const monthSel = document.getElementById(`schedule-${type}-month`); const daySel = document.getElementById(`schedule-${type}-day`); if (!monthSel || !daySel) return; monthSel.innerHTML = months .map((m) => ``) .join(''); function updateDays() { const mIdx = months.findIndex((m) => m.value === monthSel.value); const days = mIdx >= 0 ? months[mIdx].days : 31; let opts = ''; for (let d = 1; d <= days; d++) { const dd = String(d).padStart(2, '0'); opts += ``; } daySel.innerHTML = opts; } monthSel.addEventListener('change', updateDays); updateDays(); }); } export function loadHolidayPresets() { const presetSelect = document.getElementById('holiday-preset'); if (!presetSelect) return; presetSelect.innerHTML = '' + Object.keys(holidayPresets || {}) .map((label) => ``) .join(''); presetSelect.onchange = function () { const label = presetSelect.value; const modal = presetSelect.closest('.modal-content'); if (!label || !holidayPresets[label]) return; const preset = holidayPresets[label]; modal.querySelector('#holiday-name').value = label; if ( preset.schedule && preset.schedule.startsWith('range(') && preset.schedule.endsWith(')') ) { const range = preset.schedule.slice(6, -1); const [from, to] = range.split('-'); if (from) { const [fromMonth, fromDay] = from.split('/'); modal.querySelector('#schedule-from-month').value = fromMonth || ''; modal.querySelector('#schedule-from-day').value = fromDay || ''; } if (to) { const [toMonth, toDay] = to.split('/'); modal.querySelector('#schedule-to-month').value = toMonth || ''; modal.querySelector('#schedule-to-day').value = toDay || ''; } } const colorContainer = modal.querySelector('#border-colors-container'); colorContainer.innerHTML = ''; (preset.colors || []).forEach((color) => { const swatch = document.createElement('div'); swatch.className = 'subfield'; swatch.innerHTML = ` `; swatch.querySelector('.remove-btn').onclick = () => swatch.remove(); colorContainer.appendChild(swatch); }); }; } export async function populateGDrivePresetsDropdown(gdriveSyncData, editingIdx = null) { const presetSelect = document.getElementById('gdrive-sync-preset'); const presetDetail = document.getElementById('gdrive-preset-detail'); const searchBox = document.getElementById('gdrive-preset-search'); if (!presetSelect) return; const entries = await gdrivePresets(); const idsInUse = gdriveSyncData .filter((entry, i) => i !== editingIdx) .map((entry) => String(entry.id)); presetSelect.innerHTML = '' + entries .map( (drive) => `` ) .join(''); setTimeout(function () { if ($('#gdrive-sync-preset').data('select2')) { $('#gdrive-sync-preset').select2('destroy'); } $('#gdrive-sync-preset').select2({ placeholder: 'Select a GDrive preset', allowClear: true, width: '100%', dropdownParent: $('#gdrive-sync-preset').closest('.modal-content'), language: { searching: () => 'Type to filter drives…', noResults: () => 'No matching presets', inputTooShort: () => 'Type to search…', }, }); $('#gdrive-sync-preset').on('select2:open', function () { setTimeout(() => { $('.select2-search__field').attr('placeholder', 'Type to search presets…'); }, 0); }); }, 0); function updatePresetDetail() { const id = presetSelect.value; const drive = entries.find((d) => String(d.id) === String(id)); if (id && drive) { if (document.getElementById('gdrive-id')) document.getElementById('gdrive-id').value = drive.id ?? ''; if (document.getElementById('gdrive-name')) document.getElementById('gdrive-name').value = drive.name ?? ''; if (document.getElementById('gdrive-location')) document.getElementById('gdrive-location').value = drive.location ?? ''; if (presetDetail) { let metaLines = ''; if ('type' in drive) { metaLines += `
Type: ${drive.type}
`; } if ('content' in drive && drive.content) { metaLines += `
Content:
`; if (Array.isArray(drive.content)) { metaLines += `
${drive.content .map((line) => `
${line}
`) .join('')}
`; } else { metaLines += `
${drive.content}
`; } } for (const key of Object.keys(drive)) { if (['name', 'id', 'type', 'content'].includes(key)) continue; metaLines += `
${ key.charAt(0).toUpperCase() + key.slice(1) }: ${drive[key]}
`; } presetDetail.innerHTML = `
${ metaLines || 'No extra metadata' }
`; } } else if (presetDetail) { presetDetail.innerHTML = ''; } } presetSelect.onchange = updatePresetDetail; updatePresetDetail(); if (searchBox) { searchBox.addEventListener('input', () => { const filter = searchBox.value.toLowerCase(); Array.from(presetSelect.options).forEach((opt) => { if (!opt.value) return; opt.style.display = opt.text.toLowerCase().includes(filter) ? '' : 'none'; }); let firstVisible = Array.from(presetSelect.options).find( (opt) => opt.style.display !== 'none' && opt.value ); if (firstVisible) { presetSelect.value = firstVisible.value; updatePresetDetail(); } else { presetSelect.value = ''; updatePresetDetail(); } }); } } async function gdrivePresets() { if (window._gdrivePresetsCache) return window._gdrivePresetsCache; // use cache try { const response = await fetch( 'https://raw.githubusercontent.com/Drazzilb08/daps-gdrive-presets/main/presets.json' ); if (!response.ok) throw new Error('Failed to fetch GDrive presets'); const data = await response.json(); window._gdrivePresetsCache = Array.isArray(data) ? data : Object.entries(data).map(([name, value]) => typeof value === 'object' ? { name, ...value, } : { name, id: value, } ); } catch (err) { console.error('Error loading GDrive presets:', err); window._gdrivePresetsCache = []; } return window._gdrivePresetsCache; } ================================================ FILE: web/static/js/settings/modals.js ================================================ import { PLACEHOLDER_TEXT } from './constants.js'; import { populateScheduleDropdowns, loadHolidayPresets, populateGDrivePresetsDropdown, } from './modal_helpers.js'; import { DAPS } from '../common.js'; const { markDirty, humanize } = DAPS; const directoryCache = {}; function modalFooterHtml(saveId, cancelId, saveLabel = 'Save') { return ` `; } function attachModalSaveCancel(modal, saveSelector, cancelSelector, onSave) { const saveBtn = modal.querySelector(saveSelector); const cancelBtn = modal.querySelector(cancelSelector); if (saveBtn) saveBtn.onclick = onSave; if (cancelBtn) cancelBtn.onclick = () => modal.classList.remove('show'); } export function gdriveSyncModal(editIdx, gdriveSyncData, updateGdriveList) { const moduleName = 'sync_gdrive'; const isEdit = typeof editIdx === 'number'; let modal = document.getElementById('gdrive-sync-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'gdrive-sync-modal'; modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); modal .querySelector('#gdrive-location') .addEventListener('click', () => directoryPickerModal(modal.querySelector('#gdrive-location')) ); setTimeout(() => populateGDrivePresetsDropdown(gdriveSyncData, modal.editingIdx), 0); } modal.editingIdx = isEdit ? editIdx : null; const presetSelect = modal.querySelector('#gdrive-sync-preset'); const presetDetail = modal.querySelector('#gdrive-preset-detail'); if (presetSelect) { if ($(presetSelect).data('select2')) { $(presetSelect).val('').trigger('change'); } else { presetSelect.value = ''; } } if (presetDetail) presetDetail.innerHTML = ''; const nameInput = modal.querySelector('#gdrive-name'); const idInput = modal.querySelector('#gdrive-id'); const locInput = modal.querySelector('#gdrive-location'); if (isEdit) { const entry = gdriveSyncData[editIdx]; nameInput.value = entry.name || ''; idInput.value = entry.id || ''; locInput.value = entry.location || ''; } else { nameInput.value = ''; idInput.value = ''; locInput.value = ''; } const heading = modal.querySelector('h2'); if (heading) { heading.textContent = (isEdit ? 'Edit' : 'Add') + ' GDrive Sync'; } function handleGDriveSave() { const name = modal.querySelector('#gdrive-name').value.trim(); const id = modal.querySelector('#gdrive-id').value.trim(); const loc = modal.querySelector('#gdrive-location').value.trim(); if (!name || !id || !loc) { return alert('All fields must be filled.'); } const entry = { id, location: loc, name }; if (typeof editIdx === 'number') { gdriveSyncData[editIdx] = entry; } else { gdriveSyncData.push(entry); } if (typeof updateGdriveList === 'function') updateGdriveList(); markDirty(); populateGDrivePresetsDropdown(gdriveSyncData, modal.editingIdx); modal.classList.remove('show'); } attachModalSaveCancel(modal, '#gdrive-save-btn', '#gdrive-cancel-btn', handleGDriveSave); modal.classList.add('show'); } export function borderReplacerrModal(editIdx, borderReplacerrData, onUpdate) { const moduleName = 'border_replacerr'; const isEdit = typeof editIdx === 'number'; let modal = document.getElementById('border-replacerr-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'border-replacerr-modal'; modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); } loadHolidayPresets(); populateScheduleDropdowns(); const heading = modal.querySelector('h2'); if (heading) { heading.textContent = (isEdit ? 'Edit' : 'Add') + ' Holiday'; } const saveBtn = modal.querySelector('#holiday-save-btn'); if (saveBtn) { saveBtn.textContent = isEdit ? 'Save' : 'Add'; } const colorContainer = modal.querySelector('#border-colors-container'); const addColorBtn = modal.querySelector('#addBorderColor'); function addBorderColor(color = '#ffffff') { const swatch = document.createElement('div'); swatch.className = 'subfield'; swatch.innerHTML = ` `; swatch.querySelector('.remove-btn').onclick = () => swatch.remove(); colorContainer.appendChild(swatch); } addColorBtn.onclick = () => addBorderColor(); modal.querySelector('#holiday-cancel-btn').onclick = () => { modal.classList.remove('show'); }; modal.querySelector('#holiday-save-btn').onclick = () => { const name = modal.querySelector('#holiday-name').value.trim(); const existing = borderReplacerrData || []; const duplicate = existing.some( (entry, i) => entry.holiday === name && (!isEdit || i !== editIdx) ); if (duplicate) { alert('A holiday with this name already exists.'); return; } const scheduleFrom = `${modal.querySelector('#schedule-from-month').value}/${ modal.querySelector('#schedule-from-day').value }`; const scheduleTo = `${modal.querySelector('#schedule-to-month').value}/${ modal.querySelector('#schedule-to-day').value }`; const colors = Array.from( modal.querySelectorAll('#border-colors-container input[type="color"]') ).map((input) => input.value); if (!name || !scheduleFrom || !scheduleTo || !colors.length) { alert('All fields are required.'); return; } const schedule = `range(${scheduleFrom}-${scheduleTo})`; const holidayEntry = { holiday: name, schedule, color: colors, }; if (isEdit) { borderReplacerrData[editIdx] = holidayEntry; } else { borderReplacerrData.push(holidayEntry); } modal.classList.remove('show'); if (typeof onUpdate === 'function') onUpdate(); markDirty(); }; colorContainer.innerHTML = ''; if (isEdit) { const entry = borderReplacerrData[editIdx]; modal.querySelector('#holiday-name').value = entry.holiday || ''; let from = '', to = ''; if (entry.schedule && entry.schedule.startsWith('range(') && entry.schedule.endsWith(')')) { const range = entry.schedule.slice(6, -1); const [f, t] = range.split('-'); from = f || ''; to = t || ''; } if (from) { const [fromMonth, fromDay] = from.split('/'); modal.querySelector('#schedule-from-month').value = fromMonth || ''; modal.querySelector('#schedule-from-day').value = fromDay || ''; } if (to) { const [toMonth, toDay] = to.split('/'); modal.querySelector('#schedule-to-month').value = toMonth || ''; modal.querySelector('#schedule-to-day').value = toDay || ''; } (entry.color || []).forEach(addBorderColor); } else { modal.querySelector('#holiday-name').value = ''; modal.querySelector('#schedule-from-month').selectedIndex = 0; modal.querySelector('#schedule-from-day').selectedIndex = 0; modal.querySelector('#schedule-to-month').selectedIndex = 0; modal.querySelector('#schedule-to-day').selectedIndex = 0; addBorderColor(); } modal.classList.add('show'); } export function labelarrModal(editIdx, labelarrData, rootConfig, updateLabelarrTable) { const moduleName = 'labelarr'; const isEdit = typeof editIdx === 'number'; let modal = document.getElementById('labelarr-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'labelarr-modal'; modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); const plexListDiv = modal.querySelector('#labelarr-plex-list'); plexListDiv.innerHTML = ''; const plexInstances = Object.keys(rootConfig.instances?.plex || {}); if (plexInstances.length) { plexInstances.forEach((pi) => { const wrapper = document.createElement('div'); wrapper.className = 'card plex-instance-card'; wrapper.innerHTML = `

${humanize(pi)}

`; plexListDiv.appendChild(wrapper); const loadBtn = wrapper.querySelector('.load-libs-btn'); const libsDiv = wrapper.querySelector(`#labelarr-plex-libs-${pi}`); loadBtn.addEventListener('click', async () => { loadBtn.disabled = true; try { const res = await fetch( `/api/plex/libraries?instance=${encodeURIComponent(pi)}` ); if (!res.ok) throw new Error(await res.text()); const fetchedLibs = await res.json(); const checkedLibs = Array.from( libsDiv.querySelectorAll('input[type="checkbox"]:checked') ).map((cb) => cb.value); libsDiv.innerHTML = fetchedLibs .map( (l) => ` ` ) .join(''); requestAnimationFrame(() => { libsDiv.classList.add('open'); libsDiv.style.maxHeight = libsDiv.scrollHeight + 'px'; }); } catch (err) { } finally { loadBtn.disabled = false; } }); wrapper.querySelector('.select-all-libs').addEventListener('click', () => { libsDiv .querySelectorAll('input[type="checkbox"]') .forEach((cb) => (cb.checked = true)); }); wrapper.querySelector('.deselect-all-libs').addEventListener('click', () => { libsDiv .querySelectorAll('input[type="checkbox"]') .forEach((cb) => (cb.checked = false)); }); }); } else { plexListDiv.innerHTML = `

Plex

🚫 No Plex instances configured.

`; } modal.querySelector('#labelarr-cancel-btn').onclick = () => { modal.classList.remove('show'); }; modal.querySelector('#labelarr-app-type').onchange = () => { const type = modal.querySelector('#labelarr-app-type').value; const instSel = modal.querySelector('#labelarr-app-instance'); instSel.innerHTML = ''; Object.keys(rootConfig.instances?.[type] || {}).forEach((inst) => { const o = document.createElement('option'); o.value = inst; o.textContent = humanize(inst); instSel.appendChild(o); }); }; } modal = document.getElementById('labelarr-modal'); delete modal.dataset.editing; const heading = modal.querySelector('#labelarr-modal-heading'); if (heading) heading.textContent = (isEdit ? 'Edit' : 'Add') + ' Mapping'; const saveBtn = modal.querySelector('#labelarr-save-btn'); if (saveBtn) saveBtn.textContent = isEdit ? 'Save' : 'Add'; if (saveBtn) { saveBtn.onclick = null; saveBtn.onclick = () => { console.log('Save button clicked!'); const appType = modal.querySelector('#labelarr-app-type').value; const appInstance = modal.querySelector('#labelarr-app-instance').value; const labels = modal .querySelector('#labelarr-labels') .value.split(',') .map((s) => s.trim()) .filter(Boolean); const plex_instances = []; const plexListDiv = modal.querySelector('#labelarr-plex-list'); plexListDiv.querySelectorAll('.card.plex-instance-card').forEach((card) => { const inst = card.querySelector('.load-libs-btn').dataset.inst; const libs = Array.from( card.querySelectorAll('.plex-libraries input[type="checkbox"]:checked') ).map((cb) => cb.value); if (libs.length) { plex_instances.push({ instance: inst, library_names: libs }); } }); if (!labels.length || (!appInstance && plex_instances.length === 0)) { alert('You must fill out labels and at least an App or Plex instance.'); return; } const mapping = { app_type: appType, app_instance: appInstance, labels, plex_instances, }; if (typeof modal.dataset.editing !== 'undefined') { labelarrData[modal.dataset.editing] = mapping; } else { labelarrData.push(mapping); } if ( window.config && Array.isArray(window.config.mappings) && window.config.mappings !== labelarrData ) { window.config.mappings.length = 0; Array.prototype.push.apply(window.config.mappings, labelarrData); } console.log('labelarrData now:', JSON.stringify(labelarrData, null, 2)); if (typeof updateLabelarrTable === 'function') updateLabelarrTable(); markDirty(); modal.classList.remove('show'); }; console.log('Save handler attached to', saveBtn); } if (isEdit) { const entry = labelarrData[editIdx]; modal.dataset.editing = editIdx; modal.querySelector('#labelarr-app-type').value = entry.app_type; modal.querySelector('#labelarr-app-type').dispatchEvent(new Event('change')); modal.querySelector('#labelarr-app-instance').value = entry.app_instance; modal.querySelector('#labelarr-labels').value = (entry.labels || []).join(', '); const plexInstObj = {}; (entry.plex_instances || []).forEach((inst) => { if (typeof inst === 'object' && inst.instance) { plexInstObj[inst.instance] = { library_names: inst.library_names || [] }; } }); modal.querySelectorAll('.card.plex-instance-card').forEach((card) => { const inst = card.querySelector('.load-libs-btn').dataset.inst; const libsDiv = card.querySelector(`.plex-libraries`); const loadBtn = card.querySelector('.load-libs-btn'); if (plexInstObj[inst]) { loadBtn.disabled = true; fetch(`/api/plex/libraries?instance=${encodeURIComponent(inst)}`) .then((res) => res.json()) .then((allLibs) => { libsDiv.innerHTML = allLibs .map( (l) => ` ` ) .join(''); requestAnimationFrame(() => { libsDiv.classList.add('open'); libsDiv.style.maxHeight = libsDiv.scrollHeight + 'px'; }); }) .finally(() => { loadBtn.disabled = false; }); } else { libsDiv.classList.remove('open'); libsDiv.innerHTML = ''; libsDiv.style.maxHeight = null; } }); } else { modal.querySelector('#labelarr-app-type').value = 'radarr'; modal.querySelector('#labelarr-app-type').dispatchEvent(new Event('change')); modal.querySelector('#labelarr-labels').value = ''; modal.querySelectorAll('.plex-libraries').forEach((div) => { div.innerHTML = ''; div.classList.remove('open'); div.style.maxHeight = null; }); } modal.classList.add('show'); } export function upgradinatorrModal(editIdx, upgradinatorrData, rootConfig, updateTable) { const moduleName = 'upgradinatorr'; const isEdit = typeof editIdx === 'number'; let modal = document.getElementById('upgradinatorr-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'upgradinatorr-modal'; modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); const instSelect = modal.querySelector('#upgradinatorr-instance'); const instList = [ ...Object.keys(rootConfig.instances.radarr || {}), ...Object.keys(rootConfig.instances.sonarr || {}), ]; instList.forEach((inst) => { const opt = document.createElement('option'); opt.value = inst; opt.textContent = humanize(inst); instSelect.appendChild(opt); }); modal.querySelector('#upgradinatorr-cancel-btn').onclick = () => { modal.classList.remove('show'); }; const thresholdField = modal.querySelector('#season-threshold-container'); instSelect.addEventListener('change', () => { const selected = instSelect.value; const isSonarr = Object.keys(rootConfig.instances.sonarr || {}).includes(selected); thresholdField.style.display = isSonarr ? '' : 'none'; }); instSelect.dispatchEvent(new Event('change')); modal.querySelector('#upgradinatorr-save-btn').onclick = () => { const inst = modal.querySelector('#upgradinatorr-instance').value; const count = parseInt(modal.querySelector('#upgradinatorr-count').value, 10) || 0; const tag_name = modal.querySelector('#upgradinatorr-tag-name').value.trim(); const ignore_tag = modal.querySelector('#upgradinatorr-ignore-tag').value.trim(); const unattended = modal.querySelector('#upgradinatorr-unattended').value === 'true'; const isSonarr = Object.keys(rootConfig.instances.sonarr || {}).includes(inst); const season_threshold = isSonarr ? parseInt(modal.querySelector('#upgradinatorr-season-threshold').value, 10) || 0 : undefined; const entry = { instance: inst, count, tag_name, ignore_tag, unattended, }; if (isSonarr) entry.season_monitored_threshold = season_threshold; const existingIdx = upgradinatorrData.findIndex((e) => e.instance === inst); if (existingIdx !== -1) { upgradinatorrData[existingIdx] = entry; } else { upgradinatorrData.push(entry); } if (typeof updateTable === 'function') updateTable(); markDirty(); modal.classList.remove('show'); }; } modal.querySelector('#upgradinatorr-instance').value = isEdit ? upgradinatorrData[editIdx].instance : ''; modal.querySelector('#upgradinatorr-count').value = isEdit ? upgradinatorrData[editIdx].count : ''; modal.querySelector('#upgradinatorr-tag-name').value = isEdit ? upgradinatorrData[editIdx].tag_name : ''; modal.querySelector('#upgradinatorr-ignore-tag').value = isEdit ? upgradinatorrData[editIdx].ignore_tag : ''; modal.querySelector('#upgradinatorr-unattended').value = isEdit ? String(upgradinatorrData[editIdx].unattended) : 'false'; const seasonThresholdInput = modal.querySelector('#upgradinatorr-season-threshold'); if (seasonThresholdInput) { seasonThresholdInput.value = isEdit ? typeof upgradinatorrData[editIdx].season_monitored_threshold !== 'undefined' ? upgradinatorrData[editIdx].season_monitored_threshold : '' : '99'; } const instSelect = modal.querySelector('#upgradinatorr-instance'); const thresholdField = modal.querySelector('#season-threshold-container'); if (instSelect && thresholdField) { instSelect.dispatchEvent(new Event('change')); } const heading = modal.querySelector('h2'); if (heading) { heading.textContent = (isEdit ? 'Edit' : 'Add') + ' Upgradinatorr Instance List'; } const saveBtn = modal.querySelector('#upgradinatorr-save-btn'); if (saveBtn) { saveBtn.textContent = isEdit ? 'Save' : 'Add'; } modal.classList.add('show'); } export function directoryPickerModal(inputElement) { let suggestionTimeout; let modal = document.getElementById('dir-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'dir-modal'; modal.className = 'modal'; modal.classList.remove('show'); modal.innerHTML = ` `; document.body.appendChild(modal); const dirList = modal.querySelector('#dir-list'); const pathInput = modal.querySelector('#dir-path-input'); async function updateDirList() { const current = modal.currentPath; const list = directoryCache[current] || []; dirList.innerHTML = ''; const up = document.createElement('li'); up.textContent = '..'; up.onclick = () => { if (current !== '/') { modal.currentPath = current.split('/').slice(0, -1).join('/') || '/'; showPath(modal.currentPath); } }; dirList.appendChild(up); (directoryCache[current] || []).sort().forEach((name) => { const li = document.createElement('li'); li.textContent = name; li.onclick = () => { modal.currentPath = current.endsWith('/') ? current + name : current + '/' + name; showPath(modal.currentPath); }; li.ondblclick = () => { inputElement.value = modal.currentPath; closeModal(); }; dirList.appendChild(li); }); } function showPath(val) { modal.currentPath = val; pathInput.value = val; if (!directoryCache[val]) { fetch(`/api/list?path=${encodeURIComponent(val)}`) .then((res) => res.json()) .then((d) => { directoryCache[val] = d.directories; updateDirList(); }) .catch((e) => { console.error('List error:', e); }); } else { updateDirList(); } } function closeModal() { modal.classList.remove('show'); window.currentInput = null; } pathInput.addEventListener('input', () => { const val = pathInput.value.trim() || '/'; modal.currentPath = val; clearTimeout(suggestionTimeout); suggestionTimeout = setTimeout(() => { const parent = val === '/' ? '/' : val.replace(/\/?[^/]+$/, '') || '/'; const partial = val.slice(parent.length).replace(/^\/+/, '').toLowerCase(); const entries = directoryCache[parent] || []; if (entries.length) { dirList.innerHTML = ''; const up = document.createElement('li'); up.textContent = '..'; up.onclick = () => { if (parent !== '/') { modal.currentPath = parent.split('/').slice(0, -1).join('/') || '/'; showPath(modal.currentPath); } }; dirList.appendChild(up); entries .filter((name) => name.toLowerCase().startsWith(partial)) .sort() .forEach((name) => { const li = document.createElement('li'); li.textContent = name; li.onclick = () => { modal.currentPath = parent.endsWith('/') ? parent + name : parent + '/' + name; showPath(modal.currentPath); }; li.ondblclick = () => { inputElement.value = modal.currentPath; closeModal(); }; dirList.appendChild(li); }); } const entry = val.slice(parent.length).replace(/^\/+/, ''); if (directoryCache[parent]?.includes(entry)) { showPath(val); } }, 200); }); pathInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); showPath(pathInput.value.trim() || '/'); } }); modal.querySelector('#dir-create').onclick = async () => { const name = prompt('New folder name:'); if (!name) return; const newPath = modal.currentPath.endsWith('/') ? modal.currentPath + name : modal.currentPath + '/' + name; try { await fetch(`/api/create-folder?path=${encodeURIComponent(newPath)}`, { method: 'POST', }); if (!directoryCache[modal.currentPath]) directoryCache[modal.currentPath] = []; directoryCache[modal.currentPath].push(name); showPath(newPath); } catch (e) { alert('Create failed: ' + e.message); } }; modal.querySelector('#dir-cancel').onclick = closeModal; modal.updateDirList = updateDirList; modal.showPath = showPath; modal.closeModal = closeModal; } modal.currentInput = inputElement; const acceptBtn = modal.querySelector('#dir-accept'); acceptBtn.onclick = () => { modal.currentInput.value = modal.currentPath; modal.closeModal(); }; modal.currentPath = inputElement.value.trim() || '/'; const pathInput = modal.querySelector('#dir-path-input'); pathInput.value = modal.currentPath; if (inputElement.placeholder) { pathInput.placeholder = inputElement.placeholder; } if (!directoryCache[modal.currentPath]) { fetch(`/api/list?path=${encodeURIComponent(modal.currentPath)}`) .then((res) => res.json()) .then((d) => { directoryCache[modal.currentPath] = d.directories; modal.updateDirList(); }); } else { modal.updateDirList(); } modal.classList.add('show'); } ================================================ FILE: web/static/js/settings/modules/border_replacerr.js ================================================ import { renderHelp } from '../../helper.js'; import { renderTextareaArrayField } from '../settings_helpers.js'; import { borderReplacerrModal } from '../modals.js'; import { renderField, renderRemoveBordersBooleanField } from '../settings_helpers.js'; let borderReplacerrData = []; export function renderReplacerrSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); wrapper.className = 'settings-wrapper'; const help = renderHelp('border_replacerr'); if (help) wrapper.appendChild(help); Object.entries(config).forEach(([key, value]) => { if ( !['holidays', 'border_colors', 'remove_borders', 'exclusion_list', 'exclude'].includes( key ) ) { renderField(wrapper, key, value); } }); ['exclusion_list', 'exclude'].forEach((fieldKey) => { if (config[fieldKey]) { wrapper.appendChild(renderTextareaArrayField(fieldKey, config[fieldKey])); } }); let removeBordersField = renderRemoveBordersBooleanField(config); wrapper.appendChild(removeBordersField); // Border Colors const borderColorField = document.createElement('div'); borderColorField.className = 'field'; borderColorField.innerHTML = `
`; wrapper.appendChild(borderColorField); const borderColorsContainer = borderColorField.querySelector('#border-colors-container'); function updateBorderColorsFromDOM() { config.border_colors = Array.from( borderColorsContainer.querySelectorAll('input[type="color"]') ).map((input) => input.value); if (removeBordersField && removeBordersField.parentNode) removeBordersField.parentNode.removeChild(removeBordersField); removeBordersField = renderRemoveBordersBooleanField(config); let insertAfter = null; for (let i = wrapper.children.length - 1; i >= 0; i--) { const node = wrapper.children[i]; const label = node.querySelector && node.querySelector('label'); if (label && /exclusion list|exclude/i.test(label.textContent.trim())) { insertAfter = node; break; } } if (insertAfter && insertAfter.nextSibling) wrapper.insertBefore(removeBordersField, insertAfter.nextSibling); else if (insertAfter) wrapper.appendChild(removeBordersField); else wrapper.insertBefore(removeBordersField, borderColorField); } function addColorPicker(container, color = '#ffffff') { const subfield = document.createElement('div'); subfield.className = 'subfield'; subfield.innerHTML = ` `; const colorInput = subfield.querySelector('input[type="color"]'); colorInput.addEventListener('input', updateBorderColorsFromDOM); subfield.querySelector('.remove-color').onclick = () => { subfield.remove(); updateBorderColorsFromDOM(); }; container.appendChild(subfield); updateBorderColorsFromDOM(); } (config.border_colors || []).forEach((color) => addColorPicker(borderColorsContainer, color)); borderColorField.querySelector('#addBorderColor').onclick = () => addColorPicker(borderColorsContainer, '#ffffff'); if (rootConfig?.poster_renamerr?.run_border_replacerr === true) { ['source_dirs', 'destination_dir'].forEach((fieldKey) => { const fields = wrapper.querySelectorAll(`[name="${fieldKey}"]`); fields.forEach((field) => { field.disabled = true; field.value = ''; field.placeholder = "🔒 Managed by Poster Renamerr with 'Run Border Replacerr'"; field.title = "Managed by Poster Renamerr with 'Run Border Replacerr'"; if (fieldKey === 'source_dirs') { const fieldContainer = field.closest('.field'); if (fieldContainer) { const addBtn = fieldContainer.querySelector('.add-control-btn'); if (addBtn) addBtn.style.display = 'none'; fieldContainer .querySelectorAll('.remove-item') .forEach((btn) => (btn.style.display = 'none')); } } }); }); } const holidaysField = document.createElement('div'); holidaysField.className = 'field'; holidaysField.innerHTML = `
`; wrapper.appendChild(holidaysField); borderReplacerrData = Object.entries(config.holidays || {}).map(([holiday, details]) => ({ holiday, schedule: details.schedule, color: details.color, })); const holidaysContainer = holidaysField.querySelector('#holidays-container'); function updateBorderReplacerrUI() { holidaysContainer.innerHTML = ''; if (borderReplacerrData.length === 0) { holidaysContainer.innerHTML = `

🎄 No holidays configured yet.

`; } else { borderReplacerrData.forEach((entry, i) => { const card = document.createElement('div'); card.className = 'holiday-card card show-card'; card.innerHTML = `
${entry.holiday} ${entry.schedule}
${entry.color .map((c) => ``) .join('')}
`; holidaysContainer.appendChild(card); }); holidaysContainer.querySelectorAll('.edit-btn').forEach((btn) => { btn.onclick = () => { const idx = parseInt(btn.dataset.idx, 10); if (!isNaN(idx)) borderReplacerrModal(idx, borderReplacerrData, updateBorderReplacerrUI); }; }); holidaysContainer.querySelectorAll('.remove-btn').forEach((btn) => { btn.onclick = () => { const confirmed = confirm('Are you sure you want to remove this holiday?'); if (confirmed) { const idx = parseInt(btn.dataset.idx, 10); borderReplacerrData.splice(idx, 1); updateBorderReplacerrUI(); } }; }); } } holidaysField.querySelector('#add-holiday-btn').onclick = () => borderReplacerrModal(null, borderReplacerrData, updateBorderReplacerrUI); updateBorderReplacerrUI(); formFields.appendChild(wrapper); } export function getBorderReplacerrData() { return borderReplacerrData; } ================================================ FILE: web/static/js/settings/modules/health_checkarr.js ================================================ import { renderHelp } from '../../helper.js'; import { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js'; export function renderHealthCheckarrSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); const help = renderHelp('health_checkarr'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { if (key === 'instances') { renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'health_checkarr'); } else { renderField(wrapper, key, value); } }); formFields.appendChild(wrapper); } ================================================ FILE: web/static/js/settings/modules/jduparr.js ================================================ import { renderField } from '../settings_helpers.js'; import { renderHelp } from '../../helper.js'; export function renderJduparrSettings(formFields, config) { const wrapper = document.createElement('div'); const help = renderHelp('jduparr'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { renderField(wrapper, key, value); }); formFields.appendChild(wrapper); } ================================================ FILE: web/static/js/settings/modules/labelarr.js ================================================ import { renderHelp } from '../../helper.js'; import { renderField } from '../settings_helpers.js'; import { labelarrModal } from '../modals.js'; import { humanize } from '../../common.js'; let labelarrData = []; export function renderLabelarrSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); wrapper.className = 'settings-wrapper'; const help = renderHelp('labelarr'); if (help) wrapper.appendChild(help); Object.entries(config).forEach(([key, value]) => { if (key !== 'mappings') { renderField(wrapper, key, value); } }); const mappingsField = document.createElement('div'); mappingsField.className = 'field setting-field'; mappingsField.innerHTML = `
`; wrapper.appendChild(mappingsField); const mappingsContainer = mappingsField.querySelector('#labelarr-mappings-container'); labelarrData = Array.isArray(config.mappings) ? JSON.parse(JSON.stringify(config.mappings)) : []; function updateMappings() { mappingsContainer.innerHTML = ''; if (labelarrData.length === 0) { mappingsContainer.innerHTML = `
🚫 No mappings yet.
Click "Add Mapping" to create your first sync mapping.
`; return; } labelarrData.forEach((entry, i) => { try { const card = document.createElement('div'); card.className = 'labelarr-mapping-card card show-card'; const left = document.createElement('div'); left.className = 'labelarr-mapping-left'; left.innerHTML = `
${humanize(entry.app_type)}
Instance ${humanize( entry.app_instance )}:
${ entry.labels && entry.labels.length ? entry.labels .map( (l) => `${humanize(l)}` ) .join('') : 'No label' }
`; const center = document.createElement('div'); center.className = 'labelarr-mapping-center'; center.innerHTML = ``; const right = document.createElement('div'); right.className = 'labelarr-mapping-right'; let plexHtml = ''; (entry.plex_instances || []).forEach((inst) => { const instance = inst.instance || Object.keys(inst).find((k) => k !== 'library_names'); const libraries = Array.isArray(inst.library_names) ? inst.library_names : []; plexHtml += `
${humanize(instance)}: ${libraries .map( (lib) => `${humanize(lib)}` ) .join('')}
`; }); right.innerHTML = plexHtml || `No Plex Target`; const actions = document.createElement('div'); actions.className = 'labelarr-mapping-actions'; actions.innerHTML = ` `; card.appendChild(left); card.appendChild(center); card.appendChild(right); card.appendChild(actions); mappingsContainer.appendChild(card); } catch (err) { console.error('Error rendering mapping entry:', entry, err); } }); mappingsContainer.querySelectorAll('.remove-btn').forEach((btn) => { btn.onclick = () => { const idx = parseInt(btn.dataset.idx, 10); if (!isNaN(idx)) { const confirmed = confirm('Are you sure you want to remove this mapping?'); if (!confirmed) return; labelarrData.splice(idx, 1); updateMappings(); } }; }); mappingsContainer.querySelectorAll('.edit-btn').forEach((btn) => { btn.onclick = () => { const idx = parseInt(btn.dataset.idx, 10); if (!isNaN(idx)) { labelarrModal(idx, labelarrData, rootConfig, updateMappings); } }; }); } mappingsField.querySelector('#add-mapping-btn').onclick = () => labelarrModal(undefined, labelarrData, rootConfig, updateMappings); updateMappings(); formFields.appendChild(wrapper); wrapper.flushLabelarrToConfig = () => { config.mappings = JSON.parse(JSON.stringify(labelarrData)); }; } export function getLabelarrData() { return labelarrData; } ================================================ FILE: web/static/js/settings/modules/main.js ================================================ import { renderField } from '../settings_helpers.js'; import { renderHelp } from '../../helper.js'; export function renderMain(formFields, config) { const wrapper = document.createElement('div'); const help = renderHelp('main'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { renderField(wrapper, key, value); }); formFields.appendChild(wrapper); } ================================================ FILE: web/static/js/settings/modules/nohl.js ================================================ import { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js'; import { renderHelp } from '../../helper.js'; export function renderNohlSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); const help = renderHelp('nohl'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { if (key === 'source_dirs') { renderField(wrapper, key, value); } else if (key === 'instances') { renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'nohl'); } else { renderField(wrapper, key, value); } }); formFields.appendChild(wrapper); } ================================================ FILE: web/static/js/settings/modules/poster_cleanarr.js ================================================ import { renderHelp } from '../../helper.js'; import { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js'; export function renderPosterCleanarrSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); const help = renderHelp('poster_cleanarr'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { if (key === 'instances') { renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'poster_cleanarr'); } else { renderField(wrapper, key, value); } }); formFields.appendChild(wrapper); } ================================================ FILE: web/static/js/settings/modules/poster_renamerr.js ================================================ import { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js'; import { renderHelp } from '../../helper.js'; export function renderPosterRenamerrSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); const help = renderHelp('poster_renamerr'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { if (key === 'instances') { renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'poster_renamerr'); } else { renderField(wrapper, key, value); } }); formFields.appendChild(wrapper); } ================================================ FILE: web/static/js/settings/modules/renameinatorr.js ================================================ import { renderHelp } from '../../helper.js'; import { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js'; export function renderRenameinatorrSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); const help = renderHelp('renameinatorr'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { if (key === 'instances') { renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'renameinatorr'); } else { renderField(wrapper, key, value); } }); formFields.appendChild(wrapper); } ================================================ FILE: web/static/js/settings/modules/sync_gdrive.js ================================================ import { gdriveSyncModal } from '../modals.js'; import { renderHelp } from '../../helper.js'; import { renderField, renderTextareaArrayField } from '../settings_helpers.js'; let gdriveSyncData = []; export function renderGdriveSettings(formFields, config) { const wrapper = document.createElement('div'); const help = renderHelp('gdrive_sync'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { if (key === 'token') { wrapper.appendChild(renderTextareaArrayField(key, value)); } else if (key === 'gdrive_list') { const field = document.createElement('div'); field.className = 'field setting-field'; field.innerHTML = `
`; wrapper.appendChild(field); const syncList = field.querySelector('#gdrive-sync-list'); gdriveSyncData = Array.isArray(value) ? [...value] : []; function updateList() { if (!Array.isArray(gdriveSyncData) || gdriveSyncData.length === 0) { syncList.innerHTML = `

🚫 No drives to list.

Click "Add gDrive" to configure one.

`; } else { const validEntries = gdriveSyncData.filter((e) => e && e.id && e.location); if (validEntries.length === 0) { syncList.innerHTML = `

🚫 No valid drives to list.

Click "Add gDrive" to configure one.

`; return; } syncList.innerHTML = validEntries .map( (entry, i) => `
${entry.name || entry.id}${ entry.location }
` ) .join(''); } syncList.querySelectorAll('.remove-btn').forEach((btn) => { btn.onclick = () => { const confirmed = confirm('Are you sure you want to remove this sync?'); if (confirmed) { gdriveSyncData.splice(parseInt(btn.dataset.idx), 1); updateList(); } }; }); syncList.querySelectorAll('.edit-btn').forEach((btn) => { btn.onclick = () => { const idx = parseInt(btn.dataset.idx, 10); gdriveSyncModal(idx, gdriveSyncData, updateList); }; }); } field.querySelector('#add-gdrive-sync').onclick = () => gdriveSyncModal(undefined, gdriveSyncData, updateList); updateList(); } else { renderField(wrapper, key, value); } }); formFields.appendChild(wrapper); } export function getGdriveSyncData() { return gdriveSyncData; } ================================================ FILE: web/static/js/settings/modules/unmatched_assets.js ================================================ import { renderHelp } from '../../helper.js'; import { renderField, renderPlexSonarrRadarrInstancesField } from '../settings_helpers.js'; export function renderUnmatchedAssetsSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); const help = renderHelp('unmatched_assets'); if (help) wrapper.appendChild(help); wrapper.className = 'settings-wrapper'; Object.entries(config).forEach(([key, value]) => { if (key === 'instances') { renderPlexSonarrRadarrInstancesField(wrapper, value, rootConfig, 'unmatched_assets'); } else { renderField(wrapper, key, value); } }); formFields.appendChild(wrapper); } ================================================ FILE: web/static/js/settings/modules/upgradinatorr.js ================================================ import { renderField } from '../settings_helpers.js'; import { renderHelp } from '../../helper.js'; import { upgradinatorrModal } from '../modals.js'; import { humanize } from '../../common.js'; let upgradinatorrData = []; export function renderUpgradinatorrSettings(formFields, config, rootConfig) { const wrapper = document.createElement('div'); wrapper.className = 'settings-wrapper'; const help = renderHelp('upgradinatorr'); if (help) wrapper.appendChild(help); Object.entries(config).forEach(([key, value]) => { if (key !== 'instances_list') { if (typeof value === 'object' && !Array.isArray(value) && value !== null) { return; } renderField(wrapper, key, value); } }); const instanceField = document.createElement('div'); instanceField.className = 'field setting-field'; instanceField.innerHTML = `
Instance Count Tag Name Ignore Tag Unattended Threshold Actions
`; wrapper.appendChild(instanceField); const tbody = instanceField.querySelector('tbody'); upgradinatorrData = Object.entries(config.instances_list || {}).map(([inst, opts]) => { const entry = { instance: opts.instance, count: opts.count, tag_name: opts.tag_name, ignore_tag: opts.ignore_tag, unattended: opts.unattended, }; if (typeof opts.season_monitored_threshold !== 'undefined') { entry.season_monitored_threshold = opts.season_monitored_threshold; } return entry; }); function updateTable() { tbody.innerHTML = upgradinatorrData .map( (entry, i) => ` ${humanize(entry.instance)} ${entry.count} ${entry.tag_name} ${entry.ignore_tag} ${entry.unattended} ${entry.season_monitored_threshold ?? ''} ` ) .join(''); tbody.querySelectorAll('.remove-btn').forEach((btn) => { btn.onclick = () => { const confirmed = confirm('Are you sure you want to remove this instance?'); if (confirmed) { const idx = parseInt(btn.dataset.idx, 10); upgradinatorrData.splice(idx, 1); updateTable(); } }; }); tbody.querySelectorAll('.edit-upgrade').forEach((btn) => { btn.onclick = () => { const idx = parseInt(btn.dataset.idx, 10); upgradinatorrModal(idx, upgradinatorrData, rootConfig, updateTable); }; }); } instanceField .querySelector('#add-instance-btn') .addEventListener('click', () => upgradinatorrModal(undefined, upgradinatorrData, rootConfig, updateTable) ); updateTable(); formFields.appendChild(wrapper); } export function getUpgradinatorrData() { return upgradinatorrData; } ================================================ FILE: web/static/js/settings/presets.js ================================================ export const holidayPresets = { "🎆 New Year's Day": { schedule: 'range(12/30-01/02)', colors: ['#00BFFF', '#FFD700'], }, "💘 Valentine's Day": { schedule: 'range(02/05-02/15)', colors: ['#D41F3A', '#FFC0CB'], }, '🐣 Easter': { schedule: 'range(03/31-04/02)', colors: ['#FFB6C1', '#87CEFA', '#98FB98'], }, "🌸 Mother's Day": { schedule: 'range(05/10-05/15)', colors: ['#FF69B4', '#FFDAB9'], }, "👨‍👧‍👦 Father's Day": { schedule: 'range(06/15-06/20)', colors: ['#1E90FF', '#4682B4'], }, '🗽 Independence Day': { schedule: 'range(07/01-07/05)', colors: ['#FF0000', '#FFFFFF', '#0000FF'], }, '🧹 Labor Day': { schedule: 'range(09/01-09/07)', colors: ['#FFD700', '#4682B4'], }, '🎃 Halloween': { schedule: 'range(10/01-10/31)', colors: ['#FFA500', '#000000'], }, '🦃 Thanksgiving': { schedule: 'range(11/01-11/30)', colors: ['#FFA500', '#8B4513'], }, '🎄 Christmas': { schedule: 'range(12/01-12/31)', colors: ['#FF0000', '#00FF00'], }, }; ================================================ FILE: web/static/js/settings/settings_helpers.js ================================================ import { directoryPickerModal } from './modals.js'; import { BOOL_FIELDS, TEXT_FIELDS, TEXTAREA_FIELDS, INT_FIELDS, DROP_DOWN_OPTIONS, DROP_DOWN_FIELDS, DIR_PICKER, ARR_AND_PLEX_INSTANCES, PLACEHOLDER_TEXT, DRAG_AND_DROP, LIST_FIELD, SHOW_PLEX_IN_INSTANCE_FIELD, } from './constants.js'; import { humanize, showToast } from '../common.js'; function createListField(name, list) { const label = humanize(name); const moduleName = window.currentModuleName; const placeholder = PLACEHOLDER_TEXT[moduleName]?.[name] ?? ''; const field = document.createElement('div'); field.className = 'field setting-field'; field.innerHTML = `
`; const container = field.querySelector('.subfield-list'); let data = Array.isArray(list) ? [...list] : []; const supportsMode = moduleName === 'nohl'; data = data.map((entry) => { if (typeof entry === 'string') { return supportsMode ? { path: entry, mode: 'scan', } : { path: entry, }; } return entry; }); if (data.length === 0) { data = [ supportsMode ? { path: '', mode: 'scan', } : { path: '', }, ]; } function renderSubfield(entry) { const sub = document.createElement('div'); sub.className = 'subfield'; sub.innerHTML = ` ${ supportsMode ? ` ` : '' } `; const txt = sub.querySelector('input'); txt.addEventListener('click', () => directoryPickerModal(txt)); sub.querySelector('.remove-directory').onclick = () => { sub.remove(); updateRemoveButtons(); }; return sub; } data.forEach((entry) => container.appendChild(renderSubfield(entry))); function updateRemoveButtons() { const subs = container.querySelectorAll('.subfield'); subs.forEach((sub) => { const btn = sub.querySelector('.remove-directory'); btn.disabled = subs.length <= 1; btn.style.opacity = btn.disabled ? '0.5' : ''; btn.style.cursor = btn.disabled ? 'not-allowed' : ''; }); } updateRemoveButtons(); field.querySelector('.add-control-btn').onclick = () => { container.appendChild( renderSubfield( supportsMode ? { path: '', mode: 'resolve', } : { path: '', } ) ); updateRemoveButtons(); }; return field; } function createField(label, html) { const div = document.createElement('div'); div.className = 'field'; div.innerHTML = `
${html}
`; return div; } function boolDropdown(name, selected) { return ``; } export function renderTextField(name, value) { /** * Render a list-of-directories field (no drag handles, no drag logic). * @param {string} name * @param {string[]} list */ const label = humanize(name); const isDir = DIR_PICKER.includes(name); const readonly = isDir ? 'readonly' : ''; const moduleName = window.currentModuleName; const placeholder = PLACEHOLDER_TEXT[moduleName]?.[name] ?? (isDir ? 'Click to pick a directory' : ''); const field = createField( label, `` ); if (isDir) { const input = field.querySelector(`input[name="${name}"]`); input.addEventListener('click', () => directoryPickerModal(input)); } return field; } export function renderBooleanField(name, value) { const label = humanize(name); return createField(label, boolDropdown(name, value === true || value === 'true')); } export function renderDropdownField(name, value, options) { const moduleName = window.currentModuleName; const placeholder = PLACEHOLDER_TEXT[moduleName]?.[name]; let html = ``; return createField(humanize(name), html); } /** * Render a textarea for array or JSON input, auto-resizing to content. * @param {string} name - The field name (key). * @param {Array|Object|string} values - Array of lines or JSON object/string. * @returns {HTMLDivElement} The created field element. */ export function renderTextareaArrayField(name, values) { let content = ''; let placeholder = ''; if (name === 'token') { placeholder = PLACEHOLDER_TEXT?.[window.currentModuleName]?.token ?? ''; content = values === null || values === 'null' ? '' : typeof values === 'object' ? JSON.stringify(values, null, 2) : values; } else { content = Array.isArray(values) ? values.join('\n') : ''; placeholder = 'Enter items, one per line'; } const moduleName = window.currentModuleName; placeholder = PLACEHOLDER_TEXT?.[moduleName]?.[name] ?? placeholder; const textarea = document.createElement('textarea'); textarea.name = name; textarea.rows = 6; textarea.className = 'textarea'; textarea.value = content; textarea.placeholder = placeholder; const field = createField(humanize(name), ''); field.querySelector('.field-control').appendChild(textarea); setTimeout(() => { if (textarea) { textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; textarea.addEventListener('input', () => { textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; }); } }, 0); return field; } /** * Render a number input field. * @param {string} name - The field name. * @param {number} value - The current value. * @returns {HTMLDivElement} The created field element. */ export function renderNumberField(name, value) { const label = humanize(name); const html = ``; const div = createField(label, html); div.classList.add('show-field'); return div; } export function renderRemoveBordersBooleanField(config) { const name = 'remove_borders'; const label = humanize(name); const borderColors = Array.isArray(config.border_colors) ? config.border_colors.filter(Boolean) : []; let forcedValue, disabled, warning = ''; if (borderColors.length === 0) { forcedValue = true; disabled = true; warning = 'Borders will be removed because no border colors are set. Add a border color to disable this option.'; } else { forcedValue = false; disabled = true; warning = 'Cannot remove borders while custom border colors are set. Remove all border colors to enable this option.'; } let html = ``; html += `
Note: This setting is automatically controlled:
  • If any border colors are set, borders will not be removed.
  • If no border colors are set, borders will always be removed.
${warning ? `${warning}` : ''}
`; const div = createField(label, html); div.classList.add('show-field'); return div; } export function renderPlexSonarrRadarrInstancesField( formFields, instanceList, rootConfig, moduleName ) { const allInstancesEmpty = !rootConfig.instances || !Object.values(rootConfig.instances).some( (group) => group && typeof group === 'object' && Object.keys(group).length > 0 ); if (allInstancesEmpty) { const field = document.createElement('div'); field.className = `field instances-field ${moduleName}`; field.innerHTML = `
`; formFields.appendChild(field); const listDiv = field.querySelector('.instances-list'); const noCard = document.createElement('div'); noCard.className = 'card plex-instance-card'; noCard.innerHTML = `

🚫 No instances configured for ${humanize(moduleName)}.

`; listDiv.appendChild(noCard); return; } const field = document.createElement('div'); field.className = 'field instances-field poster-cleanarr'; field.innerHTML = `
`; formFields.appendChild(field); const listDiv = field.querySelector('.instances-list'); const scalarInst = []; const plexData = {}; (instanceList || []).forEach((item) => { if (typeof item === 'string') scalarInst.push(item); else if (typeof item === 'object') { const inst = Object.keys(item)[0]; plexData[inst] = item[inst]; } }); function renderARRGroupCard(instType, instances) { const card = document.createElement('div'); card.className = `card plex-instance-card`; card.innerHTML = `

${humanize(instType)}

`; const groupDiv = card.querySelector('.plex-libraries.open'); instances.forEach((instanceName) => { const label = document.createElement('label'); label.className = 'library-pill'; label.innerHTML = ` ${humanize(instanceName)} `; groupDiv.appendChild(label); }); return card; } const radarrInstances = Object.keys(rootConfig.instances.radarr || {}); if (radarrInstances.length) { listDiv.appendChild(renderARRGroupCard('radarr', radarrInstances)); } else { const noRadarrCard = document.createElement('div'); noRadarrCard.className = 'card plex-instance-card'; noRadarrCard.innerHTML = `

${humanize('radarr')}

🚫 No instances configured for ${humanize( 'radarr' )}.

`; listDiv.appendChild(noRadarrCard); } const sonarrInstances = Object.keys(rootConfig.instances.sonarr || {}); if (sonarrInstances.length) { listDiv.appendChild(renderARRGroupCard('sonarr', sonarrInstances)); } else { const noSonarrCard = document.createElement('div'); noSonarrCard.className = 'card plex-instance-card'; noSonarrCard.innerHTML = `

${humanize('sonarr')}

🚫 No instances configured for ${humanize( 'sonarr' )}.

`; listDiv.appendChild(noSonarrCard); } // Only render Plex if SHOW_PLEX_IN_INSTANCE_FIELD includes this module if (SHOW_PLEX_IN_INSTANCE_FIELD.includes(moduleName)) { const plexInstances = Object.keys(rootConfig.instances.plex || {}); if (plexInstances.length) { const plexWrapper = document.createElement('div'); plexWrapper.className = 'card'; plexWrapper.innerHTML = '

Plex

'; listDiv.appendChild(plexWrapper); plexInstances.forEach((pi) => { const libs = plexData[pi]?.library_names || []; const wrapper = document.createElement('div'); wrapper.innerHTML = `

${humanize(pi)}

`; plexWrapper.appendChild(wrapper); const loadBtn = wrapper.querySelector('.load-libs-btn'); const libsDiv = wrapper.querySelector(`#plex-libs-${pi}`); loadBtn.addEventListener('click', async () => { try { const res = await fetch( `/api/plex/libraries?instance=${encodeURIComponent(pi)}` ); if (!res.ok) throw new Error(await res.text()); const fetchedLibs = await res.json(); const existing = plexData[pi]?.library_names || []; libsDiv.innerHTML = fetchedLibs .map( (l) => ` ` ) .join(''); requestAnimationFrame(() => { libsDiv.classList.add('open'); libsDiv.style.maxHeight = libsDiv.scrollHeight + 'px'; }); showToast?.(`✅ Loaded libraries for ${humanize(pi)}`, 'success'); } catch (err) { showToast?.( `❌ Failed to load libraries for ${humanize(pi)}: ${err.message}`, 'error' ); } }); wrapper.querySelector('.select-all-libs')?.addEventListener('click', () => { libsDiv .querySelectorAll('input[type="checkbox"]') .forEach((cb) => (cb.checked = true)); }); wrapper.querySelector('.deselect-all-libs')?.addEventListener('click', () => { libsDiv .querySelectorAll('input[type="checkbox"]') .forEach((cb) => (cb.checked = false)); }); if (libs.length) { libsDiv.innerHTML = libs .map( (l) => ` ` ) .join(''); requestAnimationFrame(() => { libsDiv.classList.add('open'); libsDiv.style.maxHeight = libsDiv.scrollHeight + 'px'; }); } }); } else { const noPlexCard = document.createElement('div'); noPlexCard.className = 'card plex-instance-card'; noPlexCard.innerHTML = `

${humanize('plex')}

🚫 No instances configured for ${humanize( 'plex' )}.

`; listDiv.appendChild(noPlexCard); } } } export function createDragDropField(name, list) { const field = document.createElement('div'); const moduleName = window.currentModuleName; const placeholder = PLACEHOLDER_TEXT[moduleName]?.[name] || ''; field.className = 'field setting-field'; field.innerHTML = `
`; const container = field.querySelector('.subfield-list'); (Array.isArray(list) ? list : [list]).forEach((dir, idx) => { const sub = document.createElement('div'); sub.className = 'subfield'; sub.innerHTML = ` ⋮⋮ `; const txt = sub.querySelector('input[type="text"]'); txt.addEventListener('click', () => directoryPickerModal(txt)); sub.querySelector('.remove-directory').onclick = () => { sub.remove(); updateRemoveButtons(); }; container.appendChild(sub); }); const updateRemoveButtons = () => { const subs = container.querySelectorAll('.subfield'); subs.forEach((sub) => { const btn = sub.querySelector('.remove-directory'); if (subs.length <= 1) { btn.disabled = true; btn.style.opacity = '0.5'; btn.style.cursor = 'not-allowed'; } else { btn.disabled = false; btn.style.opacity = ''; btn.style.cursor = ''; } }); }; updateRemoveButtons(); const addBtn = field.querySelector('.add-control-btn'); addBtn.onclick = () => { const sub = document.createElement('div'); sub.className = 'subfield'; sub.innerHTML = ` ⋮⋮ `; const txt = sub.querySelector('input[type="text"]'); txt.addEventListener('click', () => directoryPickerModal(txt)); sub.querySelector('.remove-directory').onclick = () => { sub.remove(); updateRemoveButtons(); }; container.appendChild(sub); updateRemoveButtons(); makeDraggable(container); }; function makeDraggable(list) { let dragged; list.querySelectorAll('.subfield').forEach((item) => { item.setAttribute('draggable', true); item.classList.add('draggable'); item.style.transition = 'transform 0.2s ease, opacity 0.2s ease'; item.addEventListener('dragstart', (e) => { dragged = item; item.classList.add('dragging'); item.style.opacity = '0.5'; item.style.transform = 'scale(1.05)'; e.dataTransfer.effectAllowed = 'move'; }); item.addEventListener('dragover', (e) => { e.preventDefault(); const bounding = item.getBoundingClientRect(); const offset = e.clientY - bounding.top + bounding.height / 2; if (offset > bounding.height) { list.insertBefore(dragged, item.nextSibling); } else { list.insertBefore(dragged, item); } }); item.addEventListener('dragleave', () => { item.classList.remove('drag-over'); }); item.addEventListener('drop', (e) => { e.preventDefault(); item.classList.remove('drag-over'); }); item.addEventListener('dragend', () => { dragged.classList.remove('dragging'); dragged.style.opacity = ''; dragged.style.transform = ''; list.querySelectorAll('.subfield').forEach((sub) => sub.classList.remove('drag-over') ); }); }); } makeDraggable(container); return field; } export function renderField(formFields, key, value) { const moduleName = window.currentModuleName; if (LIST_FIELD[moduleName] && LIST_FIELD[moduleName].includes(key)) { formFields.appendChild(createListField(key, value)); return; } else if (DRAG_AND_DROP[moduleName] && DRAG_AND_DROP[moduleName].includes(key)) { formFields.appendChild(createDragDropField(key, value)); return; } else if (DROP_DOWN_FIELDS.includes(key)) { const opts = DROP_DOWN_OPTIONS[key] || []; formFields.appendChild(renderDropdownField(key, value, opts)); } else if (BOOL_FIELDS.includes(key)) { formFields.appendChild(renderBooleanField(key, value)); } else if (INT_FIELDS.includes(key)) { formFields.appendChild(renderNumberField(key, value)); } else if (TEXTAREA_FIELDS.includes(key)) { formFields.appendChild(renderTextareaArrayField(key, value)); } else if (TEXT_FIELDS.includes(key)) { formFields.appendChild(renderTextField(key, value)); } else if (DIR_PICKER.includes(key)) { formFields.appendChild(renderTextField(key, value)); } else { formFields.appendChild(renderTextField(key, value)); } } ================================================ FILE: web/static/js/settings.js ================================================ import { fetchConfig } from './helper.js'; import { renderPosterRenamerrSettings } from './settings/modules/poster_renamerr.js'; import { renderLabelarrSettings } from './settings/modules/labelarr.js'; import { renderReplacerrSettings } from './settings/modules/border_replacerr.js'; import { renderUpgradinatorrSettings } from './settings/modules/upgradinatorr.js'; import { renderGdriveSettings } from './settings/modules/sync_gdrive.js'; import { renderNohlSettings } from './settings/modules/nohl.js'; import { renderJduparrSettings } from './settings/modules/jduparr.js'; import { renderHealthCheckarrSettings } from './settings/modules/health_checkarr.js'; import { renderPosterCleanarrSettings } from './settings/modules/poster_cleanarr.js'; import { renderRenameinatorrSettings } from './settings/modules/renameinatorr.js'; import { renderUnmatchedAssetsSettings } from './settings/modules/unmatched_assets.js'; import { buildSettingsPayload } from './payload.js'; import { renderMain } from './settings/modules/main.js'; import { DAPS } from './common.js'; import { setTheme } from './index.js'; const { bindSaveButton, markDirty } = DAPS; const MODULE_RENDERERS = { poster_renamerr: renderPosterRenamerrSettings, labelarr: renderLabelarrSettings, border_replacerr: renderReplacerrSettings, upgradinatorr: renderUpgradinatorrSettings, sync_gdrive: renderGdriveSettings, nohl: renderNohlSettings, jduparr: renderJduparrSettings, health_checkarr: renderHealthCheckarrSettings, poster_cleanarr: renderPosterCleanarrSettings, renameinatorr: renderRenameinatorrSettings, unmatched_assets: renderUnmatchedAssetsSettings, main: renderMain, }; export async function loadSettings(moduleName) { window.currentModuleName = moduleName; const formFields = document.getElementById('form-fields'); formFields.innerHTML = ''; const rootConfig = await fetchConfig(); const moduleConfig = rootConfig[moduleName] || {}; const renderer = MODULE_RENDERERS[moduleName]; if (renderer) { renderer(formFields, moduleConfig, rootConfig); } DAPS.isDirty = false; const settingsForm = document.getElementById('settingsForm'); if (settingsForm) { settingsForm.addEventListener('change', () => { markDirty(); }); settingsForm.addEventListener('click', (e) => { if ( e.target.classList.contains('remove-btn') || e.target.classList.contains('add-btn') || e.target.classList.contains('edit-btn') ) { markDirty(); } }); } const saveBtn = document.getElementById('saveBtn'); bindSaveButton( saveBtn, () => Promise.resolve(buildSettingsPayload(window.currentModuleName)), window.currentModuleName, () => { if (window.currentModuleName === 'main') setTheme(); } ); if (settingsForm) { const allCards = settingsForm.querySelectorAll('.card'); allCards.forEach((card, i) => { card.classList.remove('show-card'); setTimeout(() => { card.classList.add('show-card'); const fields = card.querySelectorAll('.field'); fields.forEach((field, j) => { field.classList.remove('show-field'); setTimeout(() => field.classList.add('show-field'), 40 * j); }); }, 40 * i); }); const wrapperFields = settingsForm.querySelectorAll('.settings-wrapper > .field'); wrapperFields.forEach((field, i) => { field.classList.remove('show-field'); setTimeout(() => field.classList.add('show-field'), 40 * i); }); } } ================================================ FILE: web/templates/index.html ================================================ DAPS
DAPS Dashboard
📦 Version: ... 🚀 Update Available Update Available
Current:
Latest:
GitHub Discord
================================================ FILE: web/templates/pages/instances.html ================================================
================================================ FILE: web/templates/pages/logs.html ================================================
================================================ FILE: web/templates/pages/notifications.html ================================================
================================================ FILE: web/templates/pages/poster_search.html ================================================
Search in: GDrive Locations
================================================ FILE: web/templates/pages/schedule.html ================================================
================================================ FILE: web/templates/pages/settings.html ================================================