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.
[](https://opensource.org/licenses/MIT)
[](https://github.com/Drazzilb08/daps/issues)
[](https://github.com/Drazzilb08/daps/pulls)
[](https://github.com/Drazzilb08/daps/stargazers)
[](https://www.python.org/)
[](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}
"
)
for entry in items:
blocks.append(f"
{entry}
")
blocks.append("
")
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"
",
]
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("
")
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}
",
"
",
]
for entry in entries:
block.append(f"
{entry}
")
block.append("
")
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:
"
)
for item in search_media:
title = item.get("title", "Unknown")
year = f" ({item.get('year')})" if item.get("year") else ""
section.append(f"
{title}{year}")
seasons = item.get("seasons", [])
if seasons:
section.append("
")
for season in seasons:
section.append(f"
Season {season.get('season_number')}")
episodes = season.get("episode_data", [])
if episodes:
section.append("
")
for ep in episodes:
section.append(
f"
Episode {ep.get('episode_number')}
"
)
section.append("
")
section.append("
")
section.append("
")
section.append("
")
section.append("
")
if filtered_media:
section.append(
"
🎛️ Filtered Media:
"
)
for item in filtered_media:
title = item.get("title", "Unknown")
year = f" ({item.get('year')})" if item.get("year") else ""
section.append(f"
")
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()}
",
"
",
]
for item in data_set:
title = item.get("title", "Unknown")
year = f" ({item['year']})" if item.get("year") else ""
if asset_type == "series":
missing_seasons = item.get("missing_seasons", [])
missing_main = item.get("missing_main_poster", False)
if missing_seasons and missing_main:
block.append(f"
{title}{year}
")
for season in missing_seasons:
block.append(
f"
Season: {season} ← Missing
"
)
block.append("
")
elif missing_seasons:
block.append(f"
{title}{year}
")
for season in missing_seasons:
block.append(f"
Season: {season}
")
block.append("
")
elif missing_main:
block.append(
f"
{title}{year}← Main series poster missing
"
)
else:
block.append(f"
{title}{year}
")
else:
block.append(f"
{title}{year}
")
block.append("
")
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"
{row[0]}
")
elif len(row) == 4:
summary_block.append(
f"
{row[0]}
{row[1]}
{row[2]}
{row[3]}
"
)
else:
summary_block.append(
"
" + "".join(f"
{cell}
" for cell in row) + "
"
)
summary_block.append("
")
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("
")
for fname in parsed_files:
block.append(f"
{fname}
")
block.append("
")
block.append(
f"
Total items for '{os.path.basename(path)}': {sub_count}
You have unsaved changes. What would you like to do?
`;
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)}