Skip to content

Commit

Permalink
Merge pull request #6 from westsurname/dev
Browse files Browse the repository at this point in the history
Add scripts image and make use of it from repair compose service
  • Loading branch information
westsurname authored May 16, 2024
2 parents 4397629 + 0eaa24b commit a517761
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 65 deletions.
5 changes: 5 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ OVERSEERR_API_KEY=<overseerr_api_key>

SONARR_HOST=<sonarr_host>
SONARR_API_KEY=<sonarr_api_key>
SONARR_ROOT_FOLDER=<sonarr_root_folder>

RADARR_HOST=<radarr_host>
RADARR_API_KEY=<radarr_api_key>
RADARR_ROOT_FOLDER=<radarr_root_folder>

TAUTULLI_HOST=<tautulli_host>
TAUTULLI_API_KEY=<tautulli_api_key>
Expand All @@ -42,6 +44,9 @@ DISCORD_ENABLED=false
DISCORD_UPDATE_ENABLED=false
DISCORD_WEBHOOK_URL=<discord_webhook_url>

REPAIR_REPAIR_INTERVAL="10m"
REPAIR_RUN_INTERVAL="1d"

PYTHONUNBUFFERED=TRUE
PUID=
PGID=
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/docker-build-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
image: ghcr.io/${{ github.repository }}/plex_authentication
- dockerfile: ./Dockerfile.plex_request
image: ghcr.io/${{ github.repository }}/plex_request
- dockerfile: ./Dockerfile.scripts
image: ghcr.io/${{ github.repository }}/scripts
steps:
- name: Checkout code
uses: actions/checkout@v2
Expand Down
20 changes: 20 additions & 0 deletions Dockerfile.scripts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM python:3.8-slim

# Metadata labels
LABEL org.opencontainers.image.source="https://github.com/westsurname/scripts"
LABEL org.opencontainers.image.description="Docker image for the scripts service"

ARG SERVICE_NAME=scripts

# Set working directory
WORKDIR /app

# Copy only the files needed for pip install to maximize cache utilization
COPY requirements.txt ./

# Install Python dependencies
RUN grep -E "#.*($SERVICE_NAME|all)" requirements.txt | awk '{print $0}' > service_requirements.txt && \
pip install --no-cache-dir -r service_requirements.txt

# Copy the rest of the application
COPY . .
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@
- **Sonarr** - Blackhole, Repair, Move Media to Directory, Reclaim Space, Add Next Episode:
- `SONARR_HOST`: The host address of your Sonarr instance.
- `SONARR_API_KEY`: The API key for accessing Sonarr.
- `SONARR_ROOT_FOLDER`: The root folder path for Sonarr media files. (Required for repair compose service only)
- **Radarr** - Blackhole, Repair, Move Media to Directory, Reclaim Space:
- `RADARR_HOST`: The host address of your Radarr instance.
- `RADARR_API_KEY`: The API key for accessing Radarr.
- `RADARR_ROOT_FOLDER`: The root folder path for Radarr media files. (Required for repair compose service only)
- **Tautulli** - Reclaim Space:
- `TAUTULLI_HOST`: The host address of your Tautulli instance.
Expand Down Expand Up @@ -81,6 +83,10 @@
- `DISCORD_UPDATE_ENABLED`: Set to `true` to enable update notifications as well on Discord.
- `DISCORD_WEBHOOK_URL`: The Discord webhook URL for sending notifications.
- **Repair** - Repair:
- `REPAIR_REPAIR_INTERVAL`: The interval in smart format (e.g., '1h2m3s') to wait between repairing each media file.
- `REPAIR_RUN_INTERVAL`: The interval in smart format (e.g., '1w2d3h4m5s') to run the repair process.
- **General Configuration**:
- `PYTHONUNBUFFERED`: Set to `TRUE` to ensure Python output is displayed in the logs in real-time.
- `PUID`: Set this to the user ID that the service should run as.
Expand Down
31 changes: 31 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ x-blackhole: &blackhole
- .env
restart: unless-stopped

x-repair: &repair
build:
context: .
dockerfile: Dockerfile.scripts
image: ghcr.io/westsurname/scripts/scripts:latest
command: python repair.py --no-confirm
env_file:
- .env
restart: unless-stopped

services:
blackhole:
<<: *blackhole
Expand Down Expand Up @@ -37,6 +47,27 @@ services:
- ${BLACKHOLE_BASE_WATCH_PATH}/${BLACKHOLE_RADARR_PATH} 4k:/${BLACKHOLE_BASE_WATCH_PATH}/${BLACKHOLE_RADARR_PATH}
profiles: [blackhole_4k, blackhole_all, all]

repair_service:
<<: *repair
container_name: repair_service
profiles: [repair, repair_all, all]
volumes:
- ${SONARR_ROOT_FOLDER}:${SONARR_ROOT_FOLDER}
- ${RADARR_ROOT_FOLDER}:${RADARR_ROOT_FOLDER}

repair_4k:
<<: *repair
container_name: repair_4k_service
environment:
- SONARR_HOST=${SONARR_HOST_4K}
- SONARR_API_KEY=${SONARR_API_KEY_4K}
- RADARR_HOST=${RADARR_HOST_4K}
- RADARR_API_KEY=${RADARR_API_KEY_4K}
profiles: [repair_4k, repair_all, all]
volumes:
- ${SONARR_ROOT_FOLDER_4K}:${SONARR_ROOT_FOLDER}
- ${RADARR_ROOT_FOLDER_4K}:${RADARR_ROOT_FOLDER}

watchlist:
build:
context: .
Expand Down
149 changes: 84 additions & 65 deletions repair.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import os
import argparse
import time
from datetime import timedelta
from shared.arr import Sonarr, Radarr
from shared.discord import discordUpdate
from shared.shared import intersperse
from shared.shared import repair, intersperse

def parse_interval(interval_str):
"""Parse a smart interval string formatted like a YouTube timestamp (e.g., '1h2m3s') into seconds."""
"""Parse a smart interval string (e.g., '1w2d3h4m5s') into seconds."""
if not interval_str:
return 0
total_seconds = 0
time_dict = {'h': 3600, 'm': 60, 's': 1}
time_dict = {'w': 604800, 'd': 86400, 'h': 3600, 'm': 60, 's': 1}
current_number = ''
for char in interval_str:
if char.isdigit():
Expand All @@ -21,81 +20,101 @@ def parse_interval(interval_str):
current_number = ''
return total_seconds

# Parse arguments for dry run, no confirm options, and optional interval
# Parse arguments for dry run, no confirm options, and optional intervals
parser = argparse.ArgumentParser(description='Repair broken symlinks and manage media files.')
parser.add_argument('--dry-run', action='store_true', help='Perform a dry run without making any changes.')
parser.add_argument('--no-confirm', action='store_true', help='Execute without confirmation prompts.')
parser.add_argument('--interval', type=str, default='', help='Optional interval in smart format (e.g. 1h2m3s) to wait between processing each media file.')
parser.add_argument('--repair-interval', type=str, default=repair['repairInterval'], help='Optional interval in smart format (e.g. 1h2m3s) to wait between repairing each media file.')
parser.add_argument('--run-interval', type=str, default=repair['runInterval'], help='Optional interval in smart format (e.g. 1w2d3h4m5s) to run the repair process.')
args = parser.parse_args()

# Convert interval from smart format to seconds
if not args.repair_interval and not args.run_interval:
print("Running repair once")
else:
print(f"Running repair{' once every ' + args.run_interval if args.run_interval else ''}{', and waiting ' + args.repair_interval + ' between each repair.' if args.repair_interval else '.'}")

try:
interval_seconds = parse_interval(args.interval)
repair_interval_seconds = parse_interval(args.repair_interval)
except Exception as e:
print(f"Invalid interval format: {args.interval}")
print(f"Invalid interval format for repair interval: {args.repair_interval}")
exit(1)

sonarr = Sonarr()
radarr = Radarr()
sonarrMedia = [(sonarr, media) for media in sonarr.getAll() if media.anyMonitoredChildren]
radarrMedia = [(radarr, media) for media in radarr.getAll() if media.anyMonitoredChildren]
try:
run_interval_seconds = parse_interval(args.run_interval)
except Exception as e:
print(f"Invalid interval format for run interval: {args.run_interval}")
exit(1)

for arr, media in intersperse(sonarrMedia, radarrMedia):
files = {}
for file in arr.getFiles(media):
if file.parentId in files:
files[file.parentId].append(file)
else:
files[file.parentId] = [file]
for childId in media.monitoredChildrenIds:
realPaths = []
brokenSymlinks = []
def main():
print("Collecting media...")
sonarr = Sonarr()
radarr = Radarr()
sonarrMedia = [(sonarr, media) for media in sonarr.getAll() if media.anyMonitoredChildren]
radarrMedia = [(radarr, media) for media in radarr.getAll() if media.anyMonitoredChildren]
print("Finished collecting media.")

for arr, media in intersperse(sonarrMedia, radarrMedia):
files = {}
for file in arr.getFiles(media):
if file.parentId in files:
files[file.parentId].append(file)
else:
files[file.parentId] = [file]
for childId in media.monitoredChildrenIds:
realPaths = []
brokenSymlinks = []

childFiles = files.get(childId, [])
for childFile in childFiles:
childFiles = files.get(childId, [])
for childFile in childFiles:

fullPath = childFile.path
realPath = os.path.realpath(fullPath)
realPaths.append(realPath)

fullPath = childFile.path
realPath = os.path.realpath(fullPath)
realPaths.append(realPath)

if os.path.islink(fullPath) and not os.path.exists(realPath):
brokenSymlinks.append(realPath)

# If not full season just repair individual episodes?
if brokenSymlinks:
print("Title:", media.title)
print("Movie ID/Season Number:", childId)
print("Broken symlinks:")
[print(brokenSymlink) for brokenSymlink in brokenSymlinks]
print()
if args.dry_run or args.no_confirm or input("Do you want to delete and re-grab? (y/n): ").lower() == 'y':
discordUpdate(f"Repairing... {media.title} - {childId}")
print("Deleting files:")
[print(childFile.path) for childFile in childFiles]
if not args.dry_run:
results = arr.deleteFiles(childFiles)
print("Remonitoring")
media = arr.get(media.id)
media.setChildMonitored(childId, False)
arr.put(media)
media.setChildMonitored(childId, True)
arr.put(media)
print("Searching for new files")
results = arr.automaticSearch(media, childId)
print(results)
else:
print("Skipping")
print()
else:
parentFolders = set(os.path.dirname(path) for path in realPaths)
if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1:
if os.path.islink(fullPath) and not os.path.exists(realPath):
brokenSymlinks.append(realPath)

# If not full season just repair individual episodes?
if brokenSymlinks:
print("Title:", media.title)
print("Movie ID/Season Number:", childId)
print("Inconsistent folders:")
[print(parentFolder) for parentFolder in parentFolders]
print("Broken symlinks:")
[print(brokenSymlink) for brokenSymlink in brokenSymlinks]
print()
if args.dry_run or args.no_confirm or input("Do you want to delete and re-grab? (y/n): ").lower() == 'y':
discordUpdate(f"Repairing... {media.title} - {childId}")
print("Deleting files:")
[print(childFile.path) for childFile in childFiles]
if not args.dry_run:
results = arr.deleteFiles(childFiles)
print("Remonitoring")
media = arr.get(media.id)
media.setChildMonitored(childId, False)
arr.put(media)
media.setChildMonitored(childId, True)
arr.put(media)
print("Searching for new files")
results = arr.automaticSearch(media, childId)
print(results)

if repair_interval_seconds > 0:
time.sleep(repair_interval_seconds)
else:
print("Skipping")
print()
else:
parentFolders = set(os.path.dirname(path) for path in realPaths)
if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1:
print("Title:", media.title)
print("Movie ID/Season Number:", childId)
print("Inconsistent folders:")
[print(parentFolder) for parentFolder in parentFolders]
print()

if interval_seconds > 0:
time.sleep(interval_seconds)

if run_interval_seconds > 0:
while True:
main()
time.sleep(run_interval_seconds)
else:
main()
5 changes: 5 additions & 0 deletions shared/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ def stringEnvParser(value):
'webhookUrl': env.string('DISCORD_WEBHOOK_URL', default=None)
}

repair = {
'repairInterval': env.string('REPAIR_REPAIR_INTERVAL', default=None),
'runInterval': env.string('REPAIR_RUN_INTERVAL', default=None)
}

plexHeaders = {
'Accept': 'application/json',
'X-Plex-Product': watchlist['plexProduct'],
Expand Down

0 comments on commit a517761

Please sign in to comment.