Skip to content

Commit

Permalink
ADD: setting to wait for media to be available before downloading tra…
Browse files Browse the repository at this point in the history
…iler
  • Loading branch information
nandyalu committed Aug 8, 2024
1 parent 6149df3 commit 70e7577
Show file tree
Hide file tree
Showing 12 changed files with 146 additions and 77 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@
"task.allowAutomaticTasks": "off",
"task.autoDetect": "off",
"typescript.tsc.autoDetect": "off",
"git.enableCommitSigning": true,
"git-graph.repository.sign.commits": true,
"git-graph.repository.sign.tags": true,
}
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \

# Install pip requirements
COPY ./backend/requirements.txt .
RUN python -m pip install --disable-pip-version-check --no-cache-dir --upgrade -r requirements.txt
RUN python -m pip install --disable-pip-version-check --upgrade -r requirements.txt

# Install ffmpeg using install_ffmpeg.sh script
COPY install_ffmpeg.sh /tmp/install_ffmpeg.sh
Expand Down
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@

# Trailarr

[![Python](https://img.shields.io/badge/python-3.12-3670A0?style=flat&logo=python)](https://www.python.org/)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.112.0-009688.svg?style=flat&logo=FastAPI)](https://fastapi.tiangolo.com)
[![Angular](https://img.shields.io/badge/angular-17.3.6-%23DD0031.svg?style=flat&logo=angular)](https://angular.dev/)
[![Python](https://img.shields.io/badge/python-3.12-3670A0?style=flat&logo=python)](https://www.python.org/)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.112.0-009688.svg?style=flat&logo=FastAPI)](https://fastapi.tiangolo.com)
[![Angular](https://img.shields.io/badge/angular-17.3.6-%23DD0031.svg?style=flat&logo=angular)](https://angular.dev/)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://github.com/nandyalu/trailarr)

[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/nandyalu/trailarr)
[![Docker Build](https://github.com/nandyalu/trailarr/actions/workflows/docker-build.yml/badge.svg)](https://github.com/nandyalu/trailarr/actions/workflows/docker-build.yml)
[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Container&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/nandyalu/trailarr)
[![Docker Build](https://github.com/nandyalu/trailarr/actions/workflows/docker-build.yml/badge.svg)](https://github.com/nandyalu/trailarr/actions/workflows/docker-build.yml)
[![Docker Pulls](https://badgen.net/docker/pulls/nandyalu/trailarr?icon=docker&label=pulls)](https://hub.docker.com/r/nandyalu/trailarr/)
![GitHub Issues](https://img.shields.io/github/issues/nandyalu/trailarr?logo=github&link=https%3A%2F%2Fgithub.com%2Fnandyalu%2Ftrailarr%2Fissues)
![GitHub last commit](https://img.shields.io/github/last-commit/nandyalu/trailarr?logo=github&link=https%3A%2F%2Fgithub.com%2Fnandyalu%2Ftrailarr%2Fissues)


Trailarr is a Docker application to download and manage trailers for your media library. It integrates with your existing services, such as [Plex](https://www.plex.tv/), [Radarr](https://radarr.video/), and [Sonarr](https://sonarr.tv/)!

Expand Down Expand Up @@ -46,8 +50,8 @@ Environment variables are optional.
Volume mapping is required.
- Change `<LOCAL_APPDATA_FOLDER>` to the folder where you want to store the application data.
- Change `<LOCAL_MEDIA_FOLDER>` to the folder where your media is stored.
- Change `<RADARR_ROOT_FOLDERS>` to the folder where Radarr stores movies.
- Change `<SONARR_ROOT_FOLDERS>` to the folder where Sonarr stores TV shows.
- Change `<RADARR_ROOT_FOLDER>` to the folder where Radarr stores movies.
- Change `<SONARR_ROOT_FOLDER>` to the folder where Sonarr stores TV shows.
- Repeat the volume mapping for each Radarr and Sonarr instance you want to monitor.

For example, if you want to store the application data in `/var/appdata/trailarr`, local folder `/mnt/disk1/media/movies` is mapped in Radarr as `/media/movies`, and local folder `/mnt/disk1/media/tv` is mapped in Sonarr as `/media/tv`, the volume mapping would look like this:
Expand Down Expand Up @@ -87,8 +91,8 @@ services:
- 7889:7889
volumes:
- <LOCAL_APPDATA_FOLDER>:/data
- <LOCAL_MEDIA_FOLDER>:<RADARR_ROOT_FOLDERS>
- <LOCAL_MEDIA_FOLDER>:<SONARR_ROOT_FOLDERS>
- <LOCAL_MEDIA_FOLDER>:<RADARR_ROOT_FOLDER>
- <LOCAL_MEDIA_FOLDER>:<SONARR_ROOT_FOLDER>
restart: on-failure
```

Expand Down Expand Up @@ -120,8 +124,8 @@ docker run -d \
-e PGID=1000 \
-p 7889:7889 \
-v <LOCAL_APPDATA_FOLDER>:/data \
-v <LOCAL_MEDIA_FOLDER>:<RADARR_ROOT_FOLDERS> \
-v <LOCAL_MEDIA_FOLDER>:<SONARR_ROOT_FOLDERS> \
-v <LOCAL_MEDIA_FOLDER>:<RADARR_ROOT_FOLDER> \
-v <LOCAL_MEDIA_FOLDER>:<SONARR_ROOT_FOLDER> \
--restart unless-stopped \
nandyalu/trailarr:latest
```
Expand Down
1 change: 1 addition & 0 deletions backend/api/v1/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Settings(BaseModel):
trailer_embed_metadata: bool
trailer_remove_sponsorblocks: bool
trailer_web_optimized: bool
wait_for_media: bool


class UpdateSetting(BaseModel):
Expand Down
33 changes: 25 additions & 8 deletions backend/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ def __init__(self):
"True",
).lower() in ["true", "1"]
self.monitor_interval = int(os.getenv("MONITOR_INTERVAL", 60))
self.wait_for_media = os.getenv(
"WAIT_FOR_MEDIA",
"False",
).lower() in ["true", "1"]
self.trailer_folder_movie = os.getenv(
"TRAILER_FOLDER_MOVIE",
"False",
Expand Down Expand Up @@ -109,25 +113,26 @@ def __init__(self):

def as_dict(self):
return {
"version": self.version,
"server_start_time": self.server_start_time,
"timezone": self.timezone,
"api_key": self.api_key,
"debug": self.debug,
"monitor_enabled": self.monitor_enabled,
"monitor_interval": self.monitor_interval,
"timezone": self.timezone,
"trailer_audio_format": self.trailer_audio_format,
"trailer_embed_metadata": self.trailer_embed_metadata,
"trailer_file_format": self.trailer_file_format,
"trailer_folder_movie": self.trailer_folder_movie,
"trailer_folder_series": self.trailer_folder_series,
"trailer_remove_sponsorblocks": self.trailer_remove_sponsorblocks,
"trailer_resolution": self.trailer_resolution,
"trailer_audio_format": self.trailer_audio_format,
"trailer_video_format": self.trailer_video_format,
"trailer_subtitles_enabled": self.trailer_subtitles_enabled,
"trailer_subtitles_format": self.trailer_subtitles_format,
"trailer_subtitles_language": self.trailer_subtitles_language,
"trailer_file_format": self.trailer_file_format,
"trailer_embed_metadata": self.trailer_embed_metadata,
"trailer_remove_sponsorblocks": self.trailer_remove_sponsorblocks,
"trailer_video_format": self.trailer_video_format,
"trailer_web_optimized": self.trailer_web_optimized,
"server_start_time": self.server_start_time,
"version": self.version,
"wait_for_media": self.wait_for_media,
}

@property
Expand Down Expand Up @@ -214,6 +219,18 @@ def monitor_interval(self, value: int):
self._monitor_interval = value
self._save_to_env("MONITOR_INTERVAL", self._monitor_interval)

@property
def wait_for_media(self):
"""Wait for media to be available. \n
Default is False. \n
Valid values are True/False."""
return self._wait_for_media

@wait_for_media.setter
def wait_for_media(self, value: bool):
self._wait_for_media = value
self._save_to_env("WAIT_FOR_MEDIA", self._wait_for_media)

@property
def trailer_folder_movie(self):
"""Trailer folder for movies. \n
Expand Down
50 changes: 23 additions & 27 deletions backend/core/download/trailer.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
# Extract youtube video id from url
from datetime import datetime, timezone
import logging
import os
import re
import shutil
from threading import Semaphore

from yt_dlp import YoutubeDL

from app_logger import ModuleLogger
from config.settings import app_settings
from core.base.database.models.helpers import MediaTrailer
from core.download.video import download_video

logger = ModuleLogger("TrailersDownloader")


def _get_youtube_id(url: str) -> str | None:
"""Extract youtube video id from url. \n
Expand Down Expand Up @@ -42,7 +44,7 @@ def _search_yt_for_trailer(
movie_year (str): Year of the movie or show. \n
Returns:
str | None: Youtube video id / None if not found."""
logging.debug(f"Searching youtube for trailer for '{movie_title}'...")
logger.debug(f"Searching youtube for trailer for '{movie_title}'...")
# Set options
options = {
"format": "bestvideo[height<=?1080]+bestaudio",
Expand Down Expand Up @@ -80,7 +82,7 @@ def _search_yt_for_trailer(
for result in search_results["entries"]:
if result["id"] in exclude:
continue
logging.debug(f"Found trailer for {movie_title}: {result['id']}")
logger.debug(f"Found trailer for {movie_title}: {result['id']}")
return str(result["id"])


Expand Down Expand Up @@ -111,11 +113,11 @@ def download_trailer(
return False
# Download the trailer
trailer_url = f"https://www.youtube.com/watch?v={video_id}"
logging.debug(f"Downloading trailer for {media.title} from {trailer_url}")
logger.debug(f"Downloading trailer for {media.title} from {trailer_url}")
output_file = download_video(trailer_url, f"/tmp/{media.id}-trailer.%(ext)s")
if not output_file:
if retry_count > 0:
logging.debug(
logger.debug(
f"Trailer download failed for {media.title} from {trailer_url}, "
f"trying again... [{3 - retry_count}/3]"
)
Expand All @@ -126,16 +128,20 @@ def download_trailer(
)

return False
logging.debug(f"Trailer downloaded for {media.title}, Moving to folder...")
logger.debug(f"Trailer downloaded for {media.title}, Moving to folder...")
media.yt_id = video_id
# Move the trailer to the specified folder
if trailer_folder:
trailer_path = os.path.join(media.folder_path, "Trailers")
else:
trailer_path = media.folder_path
if not os.path.exists(trailer_path):
os.makedirs(trailer_path)
return move_trailer_to_folder(output_file, trailer_path, media.title)
try:
if not os.path.exists(trailer_path):
os.makedirs(trailer_path)
return move_trailer_to_folder(output_file, trailer_path, media.title)
except Exception as e:
logger.error(f"Failed to move trailer to folder: {e}")
return False


def get_folder_permissions(path: str) -> int:
Expand Down Expand Up @@ -183,15 +189,15 @@ def get_trailer_path(
def move_trailer_to_folder(src_path: str, dst_folder_path: str, new_title: str) -> bool:
# Move the trailer file to the specified folder
if not os.path.exists(src_path):
logging.debug(f"Trailer file not found at: {src_path}")
logger.debug(f"Trailer file not found at: {src_path}")
return False

# Get destination permissions
dst_permissions = get_folder_permissions(dst_folder_path)

# Check if destination exists, else create it
if not os.path.exists(dst_folder_path):
logging.debug(f"Creating folder: {dst_folder_path}")
logger.debug(f"Creating folder: {dst_folder_path}")
os.makedirs(dst_folder_path, mode=dst_permissions)

# Construct the new filename and move the file
Expand All @@ -212,9 +218,8 @@ def download_trailers(
is_movie (bool): Whether the media type is movie or show. \n
Returns:
list[MediaTrailer]: List of media objects for which trailers are downloaded."""
logging.info(
f"Downloading trailers for {len(media_list)} {'movies' if is_movie else 'series'}"
)
media_type = "movies" if is_movie else "series"
logger.info(f"Downloading trailers for {len(media_list)} monitored {media_type}...")
trailer_folder = False
if is_movie:
if app_settings.trailer_folder_movie:
Expand All @@ -226,24 +231,15 @@ def download_trailers(
download_list = []
for media in media_list:
sem.acquire()
logging.info(f"Downloading trailer for '[{media.id}]{media.title}'...")
logger.info(f"Downloading trailer for '[{media.id}]{media.title}'...")
if download_trailer(media, trailer_folder, is_movie):
media.downloaded_at = datetime.now(timezone.utc)
download_list.append(media)
logging.info(
logger.info(
f"Trailer downloaded for '[{media.id}]{media.title}' from [{media.yt_id}]"
)
else:
logging.info(f"Trailer download failed for '[{media.id}]{media.title}'")
logger.info(f"Trailer download failed for '[{media.id}]{media.title}'")
sem.release()
logging.info(
f"Downloaded trailers for {len(download_list)} {'movies' if is_movie else 'series'}"
)
logger.info(f"Downloaded trailers for {len(download_list)} {media_type}")
return download_list


# if __name__ == "__main__":
# config_logging()
# trailer_url = "https://www.youtube.com/watch?v=6ZfuNTqbHE8"
# # download_video(trailer_url)
# print(_get_ytdl_options())
27 changes: 25 additions & 2 deletions backend/core/files_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,29 @@ async def get_folder_files(folder_path: str) -> FolderInfo | None:
),
)

@staticmethod
def check_media_exists(path: str) -> bool:
"""Check if a media file exists in the specified folder.\n
Media files are checked based on the following criteria:
- File size is greater than 100 MB
- File extension is one of: mp4, mkv, avi, webm\n
Args:
path (str): Folder path to check for a media file.\n
Returns:
bool: True if a media file exists in the folder, False otherwise."""
for entry in os.scandir(path):
if entry.is_dir():
if FilesHandler.check_media_exists(entry.path):
return True
if not entry.is_file():
continue
if not entry.name.endswith((".mp4", ".mkv", ".avi", ".webm")):
continue
if entry.stat().st_size < 100 * 1024 * 1024: # 100 MB
continue
return True
return False

@staticmethod
async def _check_trailer_as_folder(path: str) -> bool:
"""Check if a trailer exists in the 'trailers' folder.\n
Expand All @@ -155,7 +178,7 @@ async def _check_trailer_as_folder(path: str) -> bool:
for sub_entry in await aiofiles.os.scandir(entry.path):
if not sub_entry.is_file():
continue
if sub_entry.name.endswith((".mp4", ".mkv", ".avi")):
if sub_entry.name.endswith((".mp4", ".mkv", ".avi", ".webm")):
return True
return False

Expand All @@ -169,7 +192,7 @@ async def _check_trailer_as_file(path: str) -> bool:
for entry in await aiofiles.os.scandir(path):
if not entry.is_file():
continue
if not entry.name.endswith((".mp4", ".mkv", ".avi")):
if not entry.name.endswith((".mp4", ".mkv", ".avi", ".webm")):
continue
if "-trailer." not in entry.name:
continue
Expand Down
23 changes: 16 additions & 7 deletions backend/core/tasks/download_trailers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from datetime import datetime, timedelta, timezone

from app_logger import ModuleLogger
from config.settings import app_settings
from core.base.database.models.helpers import MediaTrailer, MediaUpdateDC
from core.download.trailer import download_trailers
from core.files_handler import FilesHandler
from core.radarr.database_manager import MovieDatabaseManager
from core.sonarr.database_manager import SeriesDatabaseManager
from core.tasks import scheduler
Expand All @@ -18,13 +20,13 @@ def _download_missing_media_trailers(is_movie: bool):
db_manager = MovieDatabaseManager()
else:
db_manager = SeriesDatabaseManager()
media_type = "movies" if is_movie else "series"
# Get all media from the database
db_media_list = db_manager.read_all()
media_trailer_list = []
logger.debug(
f"Checking trailers for {len(db_media_list)} {'movies' if is_movie else 'series'}"
)
logger.debug(f"Checking trailers for {len(db_media_list)} monitored {media_type}")
# Create MediaTrailer objects for each movie/series
skip_count = 0
for db_media in db_media_list:
if not db_media.monitor:
logger.debug(
Expand All @@ -41,6 +43,13 @@ def _download_missing_media_trailers(is_movie: bool):
f"Skipping {db_media.title} (id:{db_media.id}), trailer exists"
)
continue
if app_settings.wait_for_media:
if not FilesHandler.check_media_exists(db_media.folder_path):
skip_count += 1
logger.debug(
f"Skipping {db_media.title} (id:{db_media.id}), media file(s) not found"
)
continue
media_trailer = MediaTrailer(
id=db_media.id,
title=db_media.title,
Expand All @@ -49,17 +58,17 @@ def _download_missing_media_trailers(is_movie: bool):
yt_id=db_media.youtube_trailer_id,
)
media_trailer_list.append(media_trailer)
if skip_count:
logger.info(f"Skipping trailer download for {skip_count} {media_type}")

if not media_trailer_list:
logger.info(
f"No missing {'movie' if is_movie else 'series'} trailers to download"
)
logger.info(f"No missing {media_type} trailers to download")
return

# Download missing trailers
downloaded_media = download_trailers(media_trailer_list, is_movie)
if not downloaded_media:
logger.info(f"No {'movie' if is_movie else 'series'} trailers downloaded")
logger.info(f"No {media_type} trailers downloaded")
return
logger.debug("Updating trailer status in database")
media_update_list = []
Expand Down
Loading

0 comments on commit 70e7577

Please sign in to comment.