├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── feature_request.md │ └── help.yml ├── dependabot.yml ├── logo.png └── workflows │ ├── discord_release.yml │ ├── docker_build.yml │ ├── docker_develop.yml │ └── docker_master.yaml ├── .gitignore ├── .ruff.toml ├── Dockerfile ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── fixer.py ├── main.py ├── mini_maker.py ├── modules ├── AspectRatioFixer.py ├── BaseCardType.py ├── BaseSummary.py ├── CleanPath.py ├── CollectionPosterMaker.py ├── DataFileInterface.py ├── DatabaseInfoContainer.py ├── Debug.py ├── EmbyInterface.py ├── Episode.py ├── EpisodeDataSource.py ├── EpisodeInfo.py ├── EpisodeMap.py ├── Font.py ├── FontValidator.py ├── GenreMaker.py ├── ImageMagickInterface.py ├── ImageMaker.py ├── JellyfinInterface.py ├── Manager.py ├── MediaInfoSet.py ├── MediaServer.py ├── MoviePosterMaker.py ├── PersistentDatabase.py ├── PlexInterface.py ├── PreferenceParser.py ├── Profile.py ├── RemoteCardType.py ├── RemoteFile.py ├── SeasonPoster.py ├── SeasonPosterSet.py ├── SeriesInfo.py ├── SeriesYamlWriter.py ├── Show.py ├── ShowArchive.py ├── ShowRecordKeeper.py ├── SonarrInterface.py ├── StandardSummary.py ├── StyleSet.py ├── StylizedSummary.py ├── SyncInterface.py ├── TMDbInterface.py ├── TautulliInterface.py ├── Template.py ├── Title.py ├── TitleCard.py ├── Version.py ├── WebInterface.py ├── YamlReader.py ├── __init__.py ├── cards │ ├── AnimeTitleCard.py │ ├── BannerTitleCard.py │ ├── CalligraphyTitleCard.py │ ├── ComicBookTitleCard.py │ ├── CutoutTitleCard.py │ ├── DividerTitleCard.py │ ├── FadeTitleCard.py │ ├── FormulaOneTitleCard.py │ ├── FrameTitleCard.py │ ├── GraphTitleCard.py │ ├── InsetTitleCard.py │ ├── LandscapeTitleCard.py │ ├── LogoTitleCard.py │ ├── MarvelTitleCard.py │ ├── MusicTitleCard.py │ ├── NotificationTitleCard.py │ ├── OlivierTitleCard.py │ ├── OverlineTitleCard.py │ ├── PosterTitleCard.py │ ├── RomanNumeralTitleCard.py │ ├── ShapeTitleCard.py │ ├── StandardTitleCard.py │ ├── StarWarsTitleCard.py │ ├── StripedTitleCard.py │ ├── TextlessTitleCard.py │ ├── TintedFrameTitleCard.py │ ├── TintedGlassTitleCard.py │ └── WhiteBorderTitleCard.py ├── global_objects.py └── ref │ ├── GRADIENT.png │ ├── Proxima Nova Regular.otf │ ├── Proxima Nova Semibold.otf │ ├── Sequel-Neue.otf │ ├── anime │ ├── Avenir.ttc │ ├── Flanker Griffo.otf │ ├── GRADIENT.png │ └── hiragino-mincho-w3.ttc │ ├── banner │ └── Gill Sans Nova ExtraBold.ttf │ ├── calligraphy │ ├── SlashSignature.ttf │ └── texture.jpg │ ├── collection │ ├── HelveticaNeue-Thin-13.ttf │ └── NimbusSansNovusT_Bold.ttf │ ├── comic_book │ └── cc-wild-words-bold-italic.ttf │ ├── fade │ └── gradient_fade.png │ ├── formula │ ├── Formula1-Bold.otf │ ├── Formula1-Numbers.otf │ ├── australia.webp │ ├── austria.webp │ ├── azerbaijan.webp │ ├── bahrain.webp │ ├── belgium.webp │ ├── brazil.webp │ ├── british.webp │ ├── canada.webp │ ├── chinese.webp │ ├── dutch.webp │ ├── frame.png │ ├── generic.webp │ ├── hungarian.webp │ ├── italian.webp │ ├── japan.webp │ ├── mexico.webp │ ├── monaco.webp │ ├── qatar.webp │ ├── saudiarabia.webp │ ├── singapore.webp │ ├── spain.webp │ ├── uae.webp │ └── unitedstates.webp │ ├── frame │ ├── frame.png │ └── guess-sans-medium.otf │ ├── genre │ ├── MyriadRegular.ttf │ └── genre_gradient.png │ ├── inset │ └── HelveticaNeue-BoldItalic.ttf │ ├── landscape │ └── Geometos.ttf │ ├── marvel │ └── Qualion ExtraBold.ttf │ ├── movie │ ├── Arial Bold.ttf │ ├── frame.png │ └── gradient.png │ ├── music │ ├── Gotham-Bold.otf │ ├── Gotham-Light.otf │ └── Gotham-Medium.ttf │ ├── olivier │ └── Montserrat-Bold.ttf │ ├── overline │ ├── HelveticaNeueMedium.ttf │ └── small_gradient.png │ ├── policy.xml │ ├── poster_card │ ├── Amuro.otf │ └── stars-overlay.png │ ├── roman │ ├── flanker-griffo.otf │ └── sinete-regular.otf │ ├── season_poster │ ├── Proxima Nova Semibold.otf │ └── gradient.png │ ├── shape │ ├── Golca Bold Italic.ttf │ ├── Golca Bold.ttf │ └── Golca Extra Bold.ttf │ ├── star_wars │ ├── HelveticaNeue-Bold.ttf │ ├── HelveticaNeue.ttc │ ├── Monstice-Base.ttf │ └── star_gradient.png │ ├── summary │ ├── created_by.png │ └── logo.png │ ├── tinted_frame │ └── Galey Semi Bold.ttf │ ├── version │ └── white_border │ ├── Arial.ttf │ ├── Arial_Bold.ttf │ └── border.png └── start.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | modules/.objects/ 2 | config/ 3 | fonts/ 4 | logs/ 5 | source/ 6 | cards/ 7 | archives/ 8 | title_cards/ 9 | docs/ 10 | yml/ 11 | yaml/ 12 | app/title_cards/ 13 | .DS_Store 14 | .git 15 | .github 16 | .gitignore 17 | .dockerignore 18 | README.md 19 | LICENSE 20 | Dockerfile 21 | ._* -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: CollinHeist -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Submit a bug report for some broken feature 3 | title: 'BUG - ' 4 | labels: ['bug'] 5 | assignees: 'CollinHeist' 6 | 7 | body: 8 | - type: dropdown 9 | id: docker 10 | attributes: 11 | label: Installation 12 | description: Are you using Docker or Github; and which branch/tag? 13 | options: 14 | - Docker - master tag 15 | - Docker - develop tag 16 | - GitHub - master branch 17 | - GitHub - develop branch 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: description 22 | attributes: 23 | label: Describe the Bug 24 | description: A clear and concise description of the bug. 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: expected 29 | attributes: 30 | label: Expected Behavior 31 | description: A clear and concise description of what you expected to happen. 32 | - type: textarea 33 | id: reproduce 34 | attributes: 35 | label: Steps to reproduce the behavior 36 | description: If not present under all circumstances, give a step-by-step on how to reproduce the bug. 37 | value: | 38 | 1. 39 | 2. 40 | ... 41 | - type: textarea 42 | id: screenshots 43 | attributes: 44 | label: Screenshots 45 | description: Attach any applicable screenshots that illustrate your problem. 46 | - type: textarea 47 | id: preferences 48 | attributes: 49 | label: Preference File 50 | description: Paste your Preferences file (likely preferences.yml), with your API keys and URLs omitted 51 | render: yaml 52 | - type: textarea 53 | id: seriesyaml 54 | attributes: 55 | label: Series YAML 56 | description: Paste the YAML of the relevent series. 57 | render: yaml 58 | - type: textarea 59 | id: log 60 | attributes: 61 | label: Debug Log 62 | description: Attach the relevant log file(s) from the logs/ directory. 63 | validations: 64 | required: true 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discord 4 | url: https://discord.gg/bJ3bHtw8wH 5 | about: Please use Discord to ask for support. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help.yml: -------------------------------------------------------------------------------- 1 | name: Request help 2 | description: Ask for help regarding an issue with your setup 3 | title: 'HELP - ' 4 | labels: ['question'] 5 | assignees: 'CollinHeist' 6 | 7 | body: 8 | - type: dropdown 9 | id: docker 10 | attributes: 11 | label: Installation 12 | description: Are you using Docker or Github; and which branch/tag? 13 | options: 14 | - Docker - master tag 15 | - Docker - develop tag 16 | - GitHub - master branch 17 | - GitHub - develop branch 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: description 22 | attributes: 23 | label: Describe your Problem 24 | description: A clear and concise description of your issue. 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: screenshots 29 | attributes: 30 | label: Screenshots 31 | description: Attach any applicable screenshots that illustrate your problem. 32 | - type: textarea 33 | id: preferences 34 | attributes: 35 | label: Preference File 36 | description: > 37 | Paste your Preferences file (likely preferences.yml), with your API keys and URLs omitted. 38 | This will be automatically formatted as YAML, so no need for backticks. 39 | render: yaml 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: seriesyaml 44 | attributes: 45 | label: Series YAML 46 | description: > 47 | Paste the YAML of the relevent series. 48 | This will be automatically formatted as YAML, so no need for backticks. 49 | render: yaml 50 | - type: textarea 51 | id: log 52 | attributes: 53 | label: Debug Log 54 | description: Attach the relevant log file(s) from the logs/ directory. 55 | validations: 56 | required: true 57 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | target-branch: "develop" 13 | assignees: 14 | - "CollinHeist" 15 | 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | target-branch: "develop" 21 | assignees: 22 | - "CollinHeist" 23 | -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/.github/logo.png -------------------------------------------------------------------------------- /.github/workflows/discord_release.yml: -------------------------------------------------------------------------------- 1 | name: Discord Release 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | release-notification: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Send Discord Release Notification 12 | uses: meisnate12/discord-notifications@master 13 | with: 14 | webhook_id: ${{ secrets.RELEASE_WEBHOOK_ID }} 15 | webhook_token: ${{ secrets.RELEASE_WEBHOOK_TOKEN }} 16 | release: true 17 | title: TitleCardMaker VERSION 18 | message: "<@&1111000623261958234> - A new version of TitleCardMaker has been released." 19 | username: MakerBot 20 | avatar_url: https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/master/.github/logo.png 21 | -------------------------------------------------------------------------------- /.github/workflows/docker_build.yml: -------------------------------------------------------------------------------- 1 | name: multi-build-docker 2 | on: 3 | workflow_call: 4 | inputs: 5 | tag: 6 | required: true 7 | type: string 8 | secrets: 9 | DOCKER_HUB_USERNAME: 10 | required: true 11 | DOCKER_HUB_ACCESS_TOKEN: 12 | required: true 13 | GH_TOKEN: 14 | required: true 15 | 16 | jobs: 17 | docker: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Check Out Repo 21 | uses: actions/checkout@v3 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v2 25 | 26 | - name: Login to Docker Hub 27 | uses: docker/login-action@v2 28 | with: 29 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 30 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 31 | 32 | - name: Login to GHCR 33 | uses: docker/login-action@v2 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.repository_owner }} 37 | password: ${{ secrets.GH_TOKEN }} 38 | 39 | - name: Set up QEMU for ARM emulation 40 | uses: docker/setup-qemu-action@v2 41 | with: 42 | platforms: all 43 | 44 | - name: Build and Push 45 | uses: docker/build-push-action@v4 46 | with: 47 | platforms: linux/amd64,linux/arm64 48 | push: true 49 | tags: ${{ secrets.DOCKER_HUB_USERNAME }}/titlecardmaker:${{ inputs.tag }},ghcr.io/collinheist/titlecardmaker:${{ inputs.tag }} 50 | cache-from: type=gha 51 | cache-to: type=gha,mode=max 52 | -------------------------------------------------------------------------------- /.github/workflows/docker_develop.yml: -------------------------------------------------------------------------------- 1 | name: Docker Develop Release 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | 7 | jobs: 8 | docker-develop: 9 | uses: ./.github/workflows/docker_build.yml 10 | with: 11 | tag: develop 12 | secrets: 13 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} 15 | DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/docker_master.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Master Release 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | docker-master: 9 | uses: ./.github/workflows/docker_build.yml 10 | with: 11 | tag: master 12 | secrets: 13 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} 15 | DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 16 | 17 | docker-latest: 18 | uses: ./.github/workflows/docker_build.yml 19 | with: 20 | tag: latest 21 | secrets: 22 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} 24 | DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # pyenv 7 | .python-version 8 | 9 | # PEP 582 10 | __pypackages__/ 11 | 12 | config/ 13 | yaml/ 14 | yml/ 15 | source/ 16 | logs/ 17 | fonts/ 18 | modules/.objects/ 19 | *._* 20 | *.prof 21 | .DS_Store -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # Exclude a variety of commonly ignored directories. 2 | exclude = [ 3 | ".bzr", 4 | ".direnv", 5 | ".eggs", 6 | ".git", 7 | ".git-rewrite", 8 | ".hg", 9 | ".mypy_cache", 10 | ".nox", 11 | ".pants.d", 12 | ".pytype", 13 | ".ruff_cache", 14 | ".svn", 15 | ".tox", 16 | ".venv", 17 | "__pypackages__", 18 | "_build", 19 | "buck-out", 20 | "build", 21 | "dist", 22 | "node_modules", 23 | "venv", 24 | "config", 25 | "archives", 26 | "modules/.objects", 27 | "app/alembic/", 28 | "modules/global_objects.py", 29 | ] 30 | 31 | # Same as Black. 32 | line-length = 88 33 | indent-width = 4 34 | 35 | # Assume Python 3.9 36 | target-version = "py39" 37 | 38 | [lint] 39 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 40 | select = ["E4", "E7", "E9", "F"] 41 | ignore = ['F541', 'F403', 'F405'] 42 | 43 | # Allow fix for all enabled rules (when `--fix`) is provided. 44 | fixable = ["ALL"] 45 | unfixable = [] 46 | 47 | # Allow unused variables when underscore-prefixed. 48 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 49 | 50 | [format] 51 | # Like Black, use double quotes for strings. 52 | quote-style = "single" 53 | 54 | # Like Black, indent with spaces, rather than tabs. 55 | indent-style = "space" 56 | 57 | # Like Black, respect magic trailing commas. 58 | skip-magic-trailing-comma = false 59 | 60 | # Like Black, automatically detect the appropriate line ending. 61 | line-ending = "auto" 62 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYVERSION=3.11 2 | 3 | # Create pipenv image to convert Pipfile to requirements.txt 4 | FROM python:${PYVERSION}-slim as pipenv 5 | 6 | # Copy Pipfile and Pipfile.lock 7 | COPY Pipfile Pipfile.lock ./ 8 | 9 | # Install pipenv and convert to requirements.txt 10 | RUN pip3 install --no-cache-dir --upgrade pipenv; \ 11 | pipenv requirements > requirements.txt 12 | 13 | FROM python:${PYVERSION}-slim as python-reqs 14 | 15 | # Copy requirements.txt from pipenv stage 16 | COPY --from=pipenv /requirements.txt requirements.txt 17 | 18 | # Install gcc for building python dependencies; install TCM dependencies 19 | RUN apt-get update; \ 20 | apt-get install -y gcc; \ 21 | pip3 install --no-cache-dir -r requirements.txt 22 | 23 | # Set base image for running TCM 24 | FROM python:${PYVERSION}-slim 25 | LABEL maintainer="CollinHeist" \ 26 | description="Automated title card maker for Plex" 27 | 28 | # Set working directory, copy source into container 29 | WORKDIR /maker 30 | COPY . /maker 31 | 32 | # Copy python packages from python-reqs 33 | # update with python version 34 | COPY --from=python-reqs /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages 35 | 36 | # Script environment variables 37 | ENV TCM_PREFERENCES=/config/preferences.yml \ 38 | TCM_IS_DOCKER=TRUE 39 | 40 | # Delete setup files 41 | # Create user and group to run the container 42 | # Install imagemagick 43 | # Clean up apt cache 44 | # Override default ImageMagick policy XML file 45 | RUN set -eux; \ 46 | rm -f Pipfile Pipfile.lock; \ 47 | groupadd -g 99 titlecardmaker; \ 48 | useradd -u 100 -g 99 titlecardmaker; \ 49 | apt-get update; \ 50 | apt-get install -y --no-install-recommends imagemagick libmagickcore-6.q16-6-extra; \ 51 | rm -rf /var/lib/apt/lists/*; \ 52 | cp modules/ref/policy.xml /etc/ImageMagick-6/policy.xml 53 | 54 | VOLUME [ "/config" ] 55 | 56 | # Entrypoint 57 | CMD ["python3", "main.py", "--run", "--no-color"] 58 | ENTRYPOINT ["bash", "./start.sh"] 59 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | regex = "==2024.5.15" 8 | num2words = "==0.5.13" 9 | pyyaml = "==6.0.1" 10 | requests = "==2.32.3" 11 | titlecase = "==2.4.1" 12 | tqdm = "==4.66.4" 13 | fonttools = "==4.53.0" 14 | plexapi = "==4.15.13" 15 | tenacity = "==8.3.0" 16 | tinydb = "==4.8.0" 17 | schedule = "==1.2.2" 18 | tmdbapis = "==1.2.16" 19 | "ruamel.yaml" = "==0.17.40" 20 | imagesize = "==1.4.1" 21 | pillow = "==10.3.0" 22 | 23 | [dev-packages] 24 | ruff = "*" 25 | 26 | [requires] 27 | python_version = "3.11" 28 | -------------------------------------------------------------------------------- /modules/AspectRatioFixer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Literal 3 | 4 | from modules.Debug import log 5 | from modules.ImageMaker import ImageMaker 6 | 7 | 8 | class AspectRatioFixer(ImageMaker): 9 | """ 10 | This class describes a type of ImageMaker that corrects the aspect 11 | ratio of source images (usually 4x3) to the TitleCard aspect ratio 12 | of 16x9. 13 | """ 14 | 15 | """Valid styles for the fixer""" 16 | VALID_STYLES = ('copy', 'stretch') 17 | DEFAULT_STYLE = 'copy' 18 | 19 | """Temporary intermediate files""" 20 | __RESIZED_TEMP = ImageMaker.TEMP_DIR / 'ar_temp.png' 21 | 22 | __slots__ = ('source', 'destination', 'style') 23 | 24 | 25 | def __init__(self, 26 | source: Path, 27 | destination: Path, 28 | style: Literal['copy', 'stretch'] = DEFAULT_STYLE, 29 | ) -> None: 30 | """ 31 | Initialize this object. This stores attributes, and initialzies 32 | the parent ImageMaker object. 33 | 34 | Args: 35 | source: Path to the source image to use. 36 | destination: Path to the desination file to write the image 37 | at. 38 | style: Aspect ratio correction style. 39 | 40 | Raises: 41 | AssertionError: If 'style' is invalid. 42 | """ 43 | 44 | # Initialize parent object for the ImageMagickInterface 45 | super().__init__() 46 | 47 | # Store attributes 48 | self.source = source 49 | self.destination = destination 50 | self.style = style.lower() 51 | 52 | assert self.style in self.VALID_STYLES, 'Invalid style' 53 | 54 | 55 | def create(self) -> None: 56 | """ 57 | Create the aspect-ratio-corrected image for this object's source 58 | file. 59 | """ 60 | 61 | # If source DNE, exit 62 | if not self.source.exists(): 63 | log.error(f'Input file "{self.source.resolve()}" does not exist') 64 | return None 65 | 66 | # Copy style command 67 | if self.style == 'copy': 68 | command = ' '.join([ 69 | f'convert "{self.source.resolve()}"', 70 | # Force resize source to correct size 71 | f'-resize "3200x1800!"', 72 | # Blur source 73 | f'-blur 0x16', 74 | f'-gravity center', 75 | f'-append', 76 | # Add source image over blurred source 77 | f'"{self.source.resolve()}"', 78 | f'-resize "3200x1800"', 79 | f'-composite', 80 | f'"{self.destination.resolve()}"', 81 | ]) 82 | # Stretch style command 83 | elif self.style == 'stretch': 84 | # Resize source image to correct height 85 | resize_command = ' '.join([ 86 | f'convert', 87 | f'+profile "*"', 88 | f'"{self.source.resolve()}"', 89 | f'-resize x1800', 90 | f'"{self.__RESIZED_TEMP.resolve()}"', 91 | ]) 92 | self.image_magick.run(resize_command) 93 | 94 | # Get dimensions of resized image, exit if too narrow for stretching 95 | width, height = self.image_magick.get_image_dimensions( 96 | self.__RESIZED_TEMP 97 | ) 98 | if width < 400 or height < 1800: 99 | log.error(f'Image too narrow for correcting with "stretch" style') 100 | return None 101 | 102 | # Stretch sides to fit into 3200px wide 103 | side_width = (3200 - width + 100) // 2 104 | 105 | command = ' '.join([ 106 | f'convert', 107 | # Crop left 50px and stretch 108 | f'\( "{self.__RESIZED_TEMP.resolve()}"', 109 | f'-crop "50x1800+0+0"', 110 | f'-resize "{side_width}!" \)', 111 | # Crop middle section 112 | f'\( "{self.__RESIZED_TEMP.resolve()}"', 113 | f'-crop "{width-100}x1800+50+0" \)', 114 | # Crop right 50px and stretch 115 | f'\( "{self.__RESIZED_TEMP.resolve()}"', 116 | f'-crop "50x1800+{width-50}+0"', 117 | f'-resize "{side_width}!" \)', 118 | # Append like [LEFT 50][MIDDLE][RIGHT 50] left-to-right 119 | f'+append', 120 | f'"{self.destination.resolve()}"', 121 | ]) 122 | 123 | self.image_magick.run(command) 124 | 125 | # Delete temporary images 126 | if self.style == 'stretch': 127 | self.image_magick.delete_intermediate_images(self.__RESIZED_TEMP) 128 | 129 | log.debug(f'Created "{self.destination.resolve()}"') 130 | return None 131 | -------------------------------------------------------------------------------- /modules/BaseSummary.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from math import ceil 3 | from pathlib import Path 4 | from random import sample 5 | from typing import TYPE_CHECKING, Optional 6 | 7 | from modules.Debug import log 8 | from modules.ImageMaker import ImageMaker 9 | 10 | if TYPE_CHECKING: 11 | from modules.Show import Show 12 | 13 | 14 | class BaseSummary(ImageMaker): 15 | """ 16 | This class describes a type of ImageMaker that specializes in 17 | creating Show summaries. These are montage images that display a 18 | random selection of title cards for a given Show object in order to 19 | give a quick visual indicator as to the style of the cards. 20 | 21 | This object cannot be instantiated directly, and only provides very 22 | few methods that can/should be used by all Summary subclasses. 23 | """ 24 | 25 | """Directory where all reference files are stored""" 26 | REF_DIRECTORY = Path(__file__).parent / 'ref' / 'summary' 27 | 28 | BACKGROUND_COLOR = '#1A1A1A' 29 | 30 | """Path to the 'created by' image to add to all show summaries""" 31 | _CREATED_BY_PATH = REF_DIRECTORY / 'created_by.png' 32 | 33 | """Configuration for the created by image creation""" 34 | HEADER_FONT = REF_DIRECTORY.parent / 'Proxima Nova Regular.otf' 35 | __CREATED_BY_FONT = REF_DIRECTORY.parent / 'star_wars' / 'HelveticaNeue.ttc' 36 | __TCM_LOGO = REF_DIRECTORY / 'logo.png' 37 | __CREATED_BY_TEMPORARY_PATH = ImageMaker.TEMP_DIR / 'user_created_by.png' 38 | 39 | __slots__ = ('show', 'logo', 'created_by', 'output', 'inputs','number_rows') 40 | 41 | 42 | @abstractmethod 43 | def __init__(self, 44 | show: 'Show', 45 | created_by: Optional[str] = None, 46 | ) -> None: 47 | """ 48 | Initialize this object. 49 | 50 | Args: 51 | show: The Show object to create the Summary for. 52 | background: Background color or image to use for the 53 | summary. Can also be a "format string" that is 54 | "{series_background}" to use the given Show object's 55 | backdrop. 56 | created_by: Optional string to use in custom "Created by .." 57 | tag at the botom of this Summary. 58 | """ 59 | 60 | # Initialize parent ImageMaker 61 | super().__init__() 62 | 63 | # Store object attributes 64 | self.show = show 65 | self.logo = show.logo 66 | self.created_by = created_by 67 | 68 | # Summary output is just below show media directory 69 | self.output = show.media_directory / 'Summary.jpg' 70 | 71 | # Initialize variables that will be set upon image selection 72 | self.inputs = [] 73 | self.number_rows = 0 74 | 75 | 76 | def _select_images(self, maximum_images: int = 9) -> bool: 77 | """ 78 | Select the images that are to be incorporated into the show 79 | summary. This updates the object's inputs and number_rows 80 | attributes. 81 | 82 | Args: 83 | maximum_images: maximum number of images to select. 84 | 85 | Returns: 86 | Whether the ShowSummary should/can be created. 87 | """ 88 | 89 | # Filter out episodes that don't have an existing title card 90 | available_episodes = list(filter( 91 | lambda e: self.show.episodes[e].destination.exists(), 92 | self.show.episodes 93 | )) 94 | 95 | # Filter specials if indicated 96 | if self.preferences.summary_ignore_specials: 97 | available_episodes = list(filter( 98 | lambda e: self.show.episodes[e].episode_info.season_number != 0, 99 | available_episodes 100 | )) 101 | 102 | # Warn if this show has no episodes to work with 103 | if (episode_count := len(available_episodes)) == 0: 104 | return False 105 | 106 | # Skip if the number of available episodes is below the minimum 107 | minimum = self.preferences.summary_minimum_episode_count 108 | if episode_count < minimum: 109 | log.debug(f'Skipping Summary, {self.show} has {episode_count} ' 110 | f'episodes, minimum setting is {minimum}') 111 | return False 112 | 113 | # Get a random subset of images to create the summary with 114 | # Sort that subset my season/episode number so the montage is ordered 115 | episode_keys = sorted( 116 | sample(available_episodes, min(episode_count, maximum_images)), 117 | key=lambda k: int(k.split('-')[0])*1000+int(k.split('-')[1]) 118 | ) 119 | 120 | # Get the full filepath for each of the selected images 121 | self.inputs = [ 122 | str(self.show.episodes[e].destination.resolve()) 123 | for e in episode_keys 124 | ] 125 | 126 | # The number of rows is necessary to determine how to scale y-values 127 | self.number_rows = ceil(len(episode_keys) / 3) 128 | 129 | return True 130 | 131 | 132 | def _create_created_by(self, created_by: str) -> Path: 133 | """ 134 | Create a custom "Created by" tag image. This image is formatted 135 | like: "Created by {input} with {logo} TitleCardMaker". The image 136 | is exactly the correct size (i.e. fit to width of text). 137 | 138 | Returns: 139 | Path to the created image. 140 | """ 141 | 142 | command = ' '.join([ 143 | f'convert', 144 | # Create blank background 145 | f'-background transparent', 146 | # Create "Created by" image/text 147 | f'-font "{self.__CREATED_BY_FONT.resolve()}"', 148 | f'-pointsize 100', 149 | f'-fill "#CFCFCF"', 150 | f'label:"Created by"', 151 | # Create "{username}" image/text 152 | f'-fill "#DA7855"', 153 | f'label:"{created_by}"', 154 | # Create "with" image/text 155 | f'-fill "#CFCFCF"', 156 | f'label:"with"', 157 | # Resize TCM logo 158 | f'\( "{self.__TCM_LOGO.resolve()}"', 159 | f'-resize x100 \)', 160 | # Create "TitleCardMaker" image/text 161 | f'-fill "#5493D7"', 162 | f'label:"TitleCardMaker"', 163 | # Combine all text images with 30px padding 164 | f'+smush 30', 165 | f'"{self.__CREATED_BY_TEMPORARY_PATH.resolve()}"' 166 | ]) 167 | 168 | self.image_magick.run(command) 169 | 170 | return self.__CREATED_BY_TEMPORARY_PATH 171 | -------------------------------------------------------------------------------- /modules/CleanPath.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path as _Path_, _windows_flavour, _posix_flavour 2 | import os 3 | 4 | 5 | class CleanPath(_Path_): 6 | """ 7 | Subclass of Path that is more OS-agnostic and implements methods of 8 | cleaning directories and filenames of bad characters. For example: 9 | 10 | >>> p = CleanPath('./some_file: 123.jpg') 11 | >>> print(p) 12 | './some_file: 123.jpg' 13 | >>> print(p.sanitize()) 14 | >>> '{parent folders}/some_file - 123.jpg' 15 | """ 16 | 17 | """Mapping of illegal filename characters and their replacements""" 18 | ILLEGAL_FILE_CHARACTERS = { 19 | '?': '!', 20 | '<': '', 21 | '>': '', 22 | ':':' -', 23 | '"': '', 24 | '|': '', 25 | '*': '-', 26 | '/': '+', 27 | '\\': '+', 28 | } 29 | 30 | """Implement the correct 'flavour' depending on the host OS""" 31 | _flavour = _windows_flavour if os.name == 'nt' else _posix_flavour 32 | 33 | 34 | def finalize(self) -> 'CleanPath': 35 | """ 36 | Finalize this path by properly resolving if absolute or 37 | relative. 38 | 39 | Returns: 40 | This object as a fully resolved path. 41 | 42 | Raises: 43 | OSError if the resolution fails (likely due to an 44 | unresolvable filename). 45 | """ 46 | 47 | return (CleanPath.cwd() / self).resolve() 48 | 49 | 50 | @staticmethod 51 | def sanitize_name(filename: str) -> str: 52 | """ 53 | Sanitize the given filename to remove any illegal characters. 54 | 55 | Args: 56 | filename: Filename to remove illegal characters from. 57 | 58 | Returns: 59 | Sanitized filename. 60 | """ 61 | 62 | replacements = CleanPath.ILLEGAL_FILE_CHARACTERS 63 | 64 | return filename.translate(str.maketrans(replacements))[:254] 65 | 66 | 67 | @staticmethod 68 | def _sanitize_parts(path: 'CleanPath') -> 'CleanPath': 69 | """ 70 | Sanitize all parts of the given path based on the current OS. 71 | 72 | Args: 73 | path: Path to sanitize. 74 | 75 | Returns: 76 | Sanitized path. This is a reconstructed CleanPath object 77 | with each folder (or part), except the root/drive, 78 | sanitized. 79 | """ 80 | 81 | return CleanPath( 82 | path.parts[0], 83 | *[CleanPath.sanitize_name(name) for name in path.parts[1:]] 84 | ) 85 | 86 | 87 | def sanitize(self) -> 'CleanPath': 88 | """ 89 | Sanitize all parts (except the root) of this objects path. 90 | 91 | Returns: 92 | CleanPath object instantiated with sanitized names of each 93 | part of this object's path. 94 | """ 95 | 96 | # Attempt to resolve immediately 97 | try: 98 | finalized_path = self.finalize() 99 | # If path resolution raises an error, clean and then re-resolve 100 | except Exception: # pylint: disable=broad-except 101 | finalized_path =self._sanitize_parts(CleanPath.cwd()/self).resolve() 102 | 103 | return self._sanitize_parts(finalized_path) 104 | -------------------------------------------------------------------------------- /modules/CollectionPosterMaker.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from modules.Debug import log 4 | from modules.ImageMaker import ImageMaker 5 | 6 | 7 | class CollectionPosterMaker(ImageMaker): 8 | """ 9 | This class defines a type of maker that creates collection posters. 10 | """ 11 | 12 | """Directory where all reference files used by this maker are stored""" 13 | REF_DIRECTORY = Path(__file__).parent / 'ref' / 'collection' 14 | 15 | """Base font for collection text""" 16 | FONT = REF_DIRECTORY / 'NimbusSansNovusT_Bold.ttf' 17 | FONT_COLOR = 'white' 18 | __COLLECTION_FONT = REF_DIRECTORY / 'HelveticaNeue-Thin-13.ttf' 19 | 20 | """Base gradient image to overlay over source image""" 21 | __GRADIENT = REF_DIRECTORY.parent / 'genre' / 'genre_gradient.png' 22 | 23 | 24 | def __init__(self, 25 | source: Path, 26 | output: Path, 27 | title: str, 28 | font: Path = FONT, 29 | font_color: str = FONT_COLOR, 30 | font_size: float = 1.0, 31 | omit_collection: bool = False, 32 | borderless: bool = False, 33 | omit_gradient: bool = False) -> None: 34 | """ 35 | Construct a new instance of a CollectionPosterMaker. 36 | 37 | Args: 38 | source: The source image to use for the poster. 39 | output: The output path to write the poster to. 40 | title: String to use on the created poster. 41 | font: Path to the font file of the poster's title. 42 | font_color: Font color of the poster text. 43 | font_size: Scalar for the font size of the poster's title. 44 | omit_collection: Whether to omit "COLLECTION" from the 45 | poster. 46 | borderless: Whether to make the poster borderless. 47 | omit_gradient: Whether to make the poster with no gradient 48 | overlay. 49 | """ 50 | 51 | # Initialize parent object for the ImageMagickInterface 52 | super().__init__() 53 | 54 | # Store the arguments 55 | self.source = source 56 | self.output = output 57 | self.font = font 58 | self.font_color = font_color 59 | self.font_size = font_size 60 | self.omit_collection = omit_collection 61 | self.borderless = borderless 62 | self.omit_gradient = omit_gradient 63 | 64 | # Uppercase title if using default font 65 | if font == self.FONT: 66 | self.collection = title.upper() 67 | else: 68 | self.collection = title 69 | 70 | 71 | def create(self) -> None: 72 | """ 73 | Create this object's poster. This WILL overwrite the existing 74 | file if it already exists. Errors and returns if the source 75 | image does not exist. 76 | """ 77 | 78 | # If the source file doesn't exist, exit 79 | if not self.source.exists(): 80 | log.error(f'Cannot create genre card, "{self.source.resolve()}" ' 81 | f'does not exist.') 82 | return None 83 | 84 | # Gradient command to either add/omit gradient 85 | if self.omit_gradient: 86 | gradient_command = [] 87 | else: 88 | gradient_command = [ 89 | f'"{self.__GRADIENT.resolve()}"', 90 | f'-gravity south', 91 | f'-composite', 92 | ] 93 | 94 | # Command to create collection poster 95 | command = ' '.join([ 96 | f'convert', 97 | # Resize source 98 | f'"{self.source.resolve()}"', 99 | f'-background transparent', 100 | f'-resize "946x1446^"', 101 | f'-gravity center', 102 | f'-extent "946x1446"', 103 | # Optionally add gradient 104 | *gradient_command, 105 | # Add border 106 | f'-gravity center', 107 | f'-bordercolor white', 108 | f'-border 27x27' if not self.borderless else f'', 109 | # Add collection title 110 | f'-font "{self.font.resolve()}"', 111 | f'-interline-spacing -40', 112 | f'-fill "{self.font_color}"', 113 | f'-gravity south', 114 | f'-pointsize {125.0 * self.font_size}', 115 | f'-kerning 2.25', 116 | f'-annotate +0+200 "{self.collection}"', 117 | # Add "COLLECTION" text 118 | f'-pointsize 35', 119 | f'-kerning 15', 120 | f'-font "{self.__COLLECTION_FONT.resolve()}"', 121 | f'' if self.omit_collection else f'-annotate +0+150 "COLLECTION"', 122 | # Write output file 123 | f'"{self.output.resolve()}"' 124 | ]) 125 | 126 | self.image_magick.run(command) 127 | 128 | return None 129 | -------------------------------------------------------------------------------- /modules/DatabaseInfoContainer.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Callable, Optional 3 | 4 | from modules.Debug import log 5 | 6 | 7 | class DatabaseInfoContainer(ABC): 8 | """ 9 | This class describes an abstract base class for all Info objects 10 | containing database ID's. This provides common methods for checking 11 | whether an object has a specific ID, as well as updating an ID 12 | within an objct. 13 | """ 14 | 15 | __slots__ = () 16 | 17 | 18 | @abstractmethod 19 | def __repr__(self) -> str: 20 | raise NotImplementedError(f'All DatabaseInfoContainers must define this') 21 | 22 | 23 | def __eq__(self, other: 'DatabaseInfoContainer') -> bool: 24 | """ 25 | Compare the equality of two like objects. This compares all 26 | `_id` attributes of the objects. 27 | 28 | Args: 29 | other: Reference object to compare equality of. 30 | 31 | Returns: 32 | True if any of the `_id` attributes of these objects are 33 | equal (and not None). False otherwise. 34 | 35 | Raises: 36 | TypeError if `other` is not of the same class as `self`. 37 | """ 38 | 39 | # Verify class comparison 40 | if not isinstance(other, self.__class__): 41 | raise TypeError(f'Can only compare like DatabaseInfoContainers') 42 | 43 | # Compare each ID attribute in slots 44 | for attr in self.__slots__: 45 | if attr.endswith('_id'): 46 | # ID is defined, non-None, and matches 47 | if (getattr(self, attr, None) is not None 48 | and getattr(self, attr, None) == getattr(other, attr, None)): 49 | return True 50 | 51 | # No matches, inequality 52 | return False 53 | 54 | 55 | def _update_attribute(self, 56 | attribute: str, 57 | value: Any, 58 | type_: Optional[Callable] = None 59 | ) -> None: 60 | """ 61 | Set the given attribute to the given value with the given type. 62 | 63 | Args: 64 | attribute: Attribute (string) being set. 65 | value: Value to set the attribute to. 66 | type_: Optional callable to call on value before assignment. 67 | """ 68 | 69 | # Set attribute if current value is None and new value isn't 70 | if (value is not None 71 | and value != 0 72 | and getattr(self, attribute) is None 73 | and len(str(value)) > 0): 74 | # If a type is defined, use that 75 | if type_ is None: 76 | setattr(self, attribute, value) 77 | else: 78 | setattr(self, attribute, type_(value)) 79 | 80 | 81 | def has_id(self, id_: str) -> bool: 82 | """ 83 | Determine whether this object has defined the given ID. 84 | 85 | Args: 86 | id_: ID being checked 87 | 88 | Returns: 89 | True if the given ID is defined (i.e. not None) for this 90 | object. False otherwise. 91 | """ 92 | 93 | id_name = id_ if id_.endswith('_id') else f'{id_}_id' 94 | 95 | return getattr(self, id_name) is not None 96 | 97 | 98 | def has_ids(self, *ids: tuple[str]) -> bool: 99 | """ 100 | Determine whether this object has defined all the given ID's. 101 | 102 | Args: 103 | ids: Any ID's being checked for. 104 | 105 | Returns: 106 | True if all the given ID's are defined (i.e. not None) for 107 | this object. False otherwise. 108 | """ 109 | 110 | return all(getattr(self, id_) is not None for id_ in ids) 111 | 112 | 113 | def copy_ids(self, other: 'DatabaseInfoContainer') -> None: 114 | """ 115 | Copy the database ID's from another DatabaseInfoContainer into 116 | this object. Only updating the more precise ID's (e.g. this 117 | object's ID must be None and the other ID must be non-None). 118 | 119 | Args: 120 | other: Container whose ID's are being copied over. 121 | """ 122 | 123 | # Go through all attributes of this object 124 | for attr in self.__slots__: 125 | # Attribute is ID, this container doesn't have, other does 126 | if (attr.endswith('_id') 127 | and not getattr(self, attr) 128 | and getattr(other, attr)): 129 | # Transfer ID 130 | log.debug(f'Copied {attr}[{getattr(other, attr)}] into {self!r}') 131 | setattr(self, attr, getattr(other, attr)) 132 | -------------------------------------------------------------------------------- /modules/Debug.py: -------------------------------------------------------------------------------- 1 | from logging import Logger, Formatter, getLogger, setLoggerClass, StreamHandler 2 | from logging.handlers import TimedRotatingFileHandler 3 | from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL 4 | 5 | from pathlib import Path 6 | from tqdm import tqdm 7 | 8 | """Global tqdm arguments""" 9 | TQDM_KWARGS = { 10 | # Progress bar format string 11 | 'bar_format': ('{desc:.50s} {percentage:2.0f}%|{bar}| {n_fmt}/{total_fmt} ' 12 | '[{elapsed}]'), 13 | # Progress bars should disappear when finished 14 | 'leave': False, 15 | # Progress bars can not be used if no TTY is present 16 | 'disable': None, 17 | } 18 | 19 | """Log file""" 20 | LOG_FILE = Path(__file__).parent.parent / 'logs' / 'maker.log' 21 | LOG_FILE.parent.mkdir(parents=True, exist_ok=True) 22 | 23 | 24 | class BetterExceptionLogger(Logger): 25 | """ 26 | Logger class that overrides `Logger.exception` to log as 27 | `Logger.error`, and then print the traceback at the debug level. 28 | """ 29 | 30 | def exception(self, msg: object, *args, **kwargs) -> None: 31 | super().exception(msg, *args, exc_info=True, **kwargs) 32 | setLoggerClass(BetterExceptionLogger) 33 | 34 | 35 | class LogHandler(StreamHandler): 36 | """Handler to integrate log messages with tqdm.""" 37 | 38 | def emit(self, record): 39 | # Write after flushing buffer to integrate with tqdm 40 | try: 41 | tqdm.write(self.format(record)) 42 | self.flush() 43 | except Exception: 44 | self.handleError(record) 45 | 46 | 47 | # Formatter classes to handle exceptions 48 | class ErrorFormatterColor(Formatter): 49 | """ 50 | Formatter class to handle exception traceback printing with color. 51 | """ 52 | 53 | def formatException(self, ei) -> str: 54 | return f'\x1b[1;30m[TRACEBACK] {super().formatException(ei)}\x1b[0m' 55 | 56 | 57 | class ErrorFormatterNoColor(Formatter): 58 | """ 59 | Formatter class to handle exception traceback printing without 60 | color. 61 | """ 62 | 63 | def formatException(self, ei) -> str: 64 | return f'[TRACEBACK] {super().formatException(ei)}' 65 | 66 | 67 | class LogFormatterColor(Formatter): 68 | """ 69 | Formatter containing ErrorFormatterColor objects instantiated with 70 | different format strings for various colors depending on the log 71 | level. 72 | """ 73 | 74 | """Color codes""" 75 | GRAY = '\x1b[1;30m' 76 | CYAN = '\033[96m' 77 | YELLOW = '\x1b[33;20m' 78 | RED = '\x1b[31;20m' 79 | BOLD_RED = '\x1b[31;1m' 80 | RESET = '\x1b[0m' 81 | 82 | format_layout = '[%(levelname)s] %(message)s' 83 | 84 | LEVEL_FORMATS = { 85 | DEBUG: ErrorFormatterColor(f'{GRAY}{format_layout}{RESET}'), 86 | INFO: ErrorFormatterColor(f'{CYAN}{format_layout}{RESET}'), 87 | WARNING: ErrorFormatterColor(f'{YELLOW}{format_layout}{RESET}'), 88 | ERROR: ErrorFormatterColor(f'{RED}{format_layout}{RESET}'), 89 | CRITICAL: ErrorFormatterColor(f'{BOLD_RED}{format_layout}{RESET}'), 90 | } 91 | 92 | def format(self, record): 93 | return self.LEVEL_FORMATS[record.levelno].format(record) 94 | 95 | 96 | class LogFormatterNoColor(Formatter): 97 | """Colorless version of the `LogFormatterColor` class.""" 98 | 99 | FORMATTER = ErrorFormatterNoColor('[%(levelname)s] %(message)s') 100 | 101 | def format(self, record): 102 | return self.FORMATTER.format(record) 103 | 104 | # Create global logger 105 | log = getLogger('tcm') 106 | log.setLevel(DEBUG) 107 | 108 | # Add TQDM handler and color formatter to the logger 109 | handler = LogHandler() 110 | handler.setFormatter(LogFormatterColor()) 111 | handler.setLevel(DEBUG) 112 | log.addHandler(handler) 113 | 114 | # Add rotating file handler to the logger 115 | file_handler = TimedRotatingFileHandler( 116 | filename=LOG_FILE, when='midnight', backupCount=14, 117 | ) 118 | file_handler.setFormatter(ErrorFormatterNoColor( 119 | '[%(levelname)s] [%(asctime)s.%(msecs)03d] %(message)s', 120 | '%m-%d-%y %H:%M:%S' 121 | )) 122 | file_handler.setLevel(DEBUG) 123 | log.addHandler(file_handler) 124 | 125 | def apply_no_color_formatter() -> None: 126 | """ 127 | Modify the global logger object by replacing the colored Handler 128 | with an instance of the LogFormatterNoColor Handler class. Also set 129 | the log level to that of the removed handler. 130 | """ 131 | 132 | # Get existing handler's log level, then delete 133 | log_level = log.handlers[0].level 134 | log.removeHandler(log.handlers[0]) 135 | 136 | # Create colorless Handler with Colorless Formatter 137 | handler = LogHandler() 138 | handler.setFormatter(LogFormatterNoColor()) 139 | handler.setLevel(log_level) 140 | 141 | # Add colorless handler in place of deleted one 142 | log.addHandler(handler) 143 | -------------------------------------------------------------------------------- /modules/EpisodeDataSource.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | from modules.EpisodeInfo import EpisodeInfo 5 | from modules.SeriesInfo import SeriesInfo 6 | 7 | 8 | class EpisodeDataSource(ABC): 9 | """ 10 | This class describes an abstract episode data source. Classes of 11 | this type define sources of Episode data. 12 | """ 13 | 14 | 15 | @property 16 | @abstractmethod 17 | def SERIES_IDS(self) -> tuple[str]: 18 | """Valid SeriesInfo ID's that can be set by this data source.""" 19 | raise NotImplementedError 20 | 21 | 22 | @abstractmethod 23 | def set_series_ids(self, 24 | library_name: Optional[str], 25 | series_info: SeriesInfo, 26 | ) -> None: 27 | """Set the series ID's for the given SeriesInfo object.""" 28 | 29 | raise NotImplementedError 30 | 31 | 32 | @abstractmethod 33 | def set_episode_ids(self, 34 | library_name: Optional[str], 35 | series_info: SeriesInfo, 36 | episode_infos: list[EpisodeInfo], 37 | *, 38 | inplace: bool = False, 39 | ) -> None: 40 | """Set the episode ID's for the given EpisodeInfo objects.""" 41 | 42 | raise NotImplementedError 43 | 44 | 45 | @abstractmethod 46 | def get_all_episodes(self, 47 | library_name: str, 48 | series_info: SeriesInfo, 49 | episode_infos: Optional[list[EpisodeInfo]] = None, 50 | ) -> list[EpisodeInfo]: 51 | """Get all the EpisodeInfo objects associated with the given series.""" 52 | 53 | raise NotImplementedError 54 | -------------------------------------------------------------------------------- /modules/FontValidator.py: -------------------------------------------------------------------------------- 1 | from fontTools.ttLib import TTFont 2 | from tinydb import where 3 | 4 | from modules.Debug import log 5 | from modules.PersistentDatabase import PersistentDatabase 6 | 7 | 8 | class FontValidator: 9 | """ 10 | This class describes a font validator. A FontValidator takes font 11 | files and can indicate whether that font contains all the characters 12 | for some strings (titles). 13 | """ 14 | 15 | """File to the font character validation database""" 16 | CHARACTER_DATABASE = 'fvm.json' 17 | 18 | 19 | def __init__(self) -> None: 20 | """ 21 | Constructs a new instance. This reads the font validation map if 22 | it exists, and creates the file if it does not. 23 | """ 24 | 25 | # Create/read font validation database 26 | self.__db = PersistentDatabase(self.CHARACTER_DATABASE) 27 | 28 | 29 | def __has_character(self, font_filepath: str, character: str) -> bool: 30 | """ 31 | Determines whether the given character exists in the given Font. 32 | 33 | Args: 34 | font_filepath: Filepath to the font being validated against 35 | character: Character being checked. 36 | 37 | Returns: 38 | True if the given character exists in the given font, False 39 | otherwise. 40 | """ 41 | 42 | # All fonts have spaces 43 | if character == ' ': 44 | return True 45 | 46 | # If character has been checked, return status 47 | if self.__db.contains((where('file') == font_filepath) & 48 | (where('character') == character)): 49 | return self.__db.get((where('file') == font_filepath) & 50 | (where('character') == character))['status'] 51 | 52 | # Get the ordinal value of this character 53 | glyph = ord(character) 54 | 55 | # Go through each table in this font, return True if in a cmap 56 | for table in TTFont(font_filepath, fontNumber=0)['cmap'].tables: 57 | if glyph in table.cmap: 58 | # Update map for this character, return True 59 | self.__db.insert({ 60 | 'file': font_filepath, 61 | 'character': character, 62 | 'status': True 63 | }) 64 | 65 | return True 66 | 67 | # Update map for this character, return False 68 | self.__db.insert({ 69 | 'file': font_filepath, 'character': character, 'status': False 70 | }) 71 | 72 | return False 73 | 74 | 75 | def validate_title(self, font_filepath: str, title: str) -> bool: 76 | """ 77 | Validate the given Title, returning whether all characters are 78 | contained within the given Font. 79 | 80 | Args: 81 | font_filepath: Filepath to the font being validated against 82 | title: The title being validated. 83 | 84 | Returns: 85 | True if all characters in the title are found within the 86 | given font, False otherwise. 87 | """ 88 | 89 | # Map __has_character() to all characters in the title 90 | has_characters = tuple(map( 91 | lambda char: self.__has_character(font_filepath, char), 92 | (title := title.replace('\n', '')) 93 | )) 94 | 95 | # Log all missing characters 96 | for char, has_character in zip(title, has_characters): 97 | if not has_character: 98 | log.warning(f'Character "{char}" missing from "{font_filepath}"') 99 | 100 | return all(has_characters) 101 | 102 | 103 | def get_missing_characters(self, font_filepath: str) -> set[str]: 104 | """ 105 | Get a set of all (known) missing characters for the given font. 106 | 107 | Args: 108 | font_filepath: Filepath to the font being evaluated. 109 | 110 | Returns: 111 | Set of all characters present in this object's database that 112 | are marked as missing for the given font. 113 | """ 114 | 115 | # Get all missing entries 116 | missing = self.__db.search( 117 | (where('file') == font_filepath) 118 | & (where('status') == False) # pylint: disable=singleton-comparison 119 | ) 120 | 121 | # Return set of just characters from entries 122 | return {entry['character'] for entry in missing} 123 | -------------------------------------------------------------------------------- /modules/GenreMaker.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from modules.BaseCardType import ImageMagickCommands 4 | from modules.Debug import log 5 | from modules.ImageMaker import ImageMaker 6 | 7 | 8 | class GenreMaker(ImageMaker): 9 | """ 10 | This class defines a type of maker that creates genre cards. These 11 | are posters that have text (the genre) at the bottom of their image, 12 | and are outlined by a white border (in this implementation). 13 | """ 14 | 15 | """Directory where all reference files used by this maker are stored""" 16 | REF_DIRECTORY = Path(__file__).parent / 'ref' / 'genre' 17 | 18 | """Base font for genre text""" 19 | FONT = REF_DIRECTORY / 'MyriadRegular.ttf' 20 | 21 | """Base gradient image to overlay over source image""" 22 | __GENRE_GRADIENT = REF_DIRECTORY / 'genre_gradient.png' 23 | 24 | 25 | def __init__(self, 26 | source: Path, 27 | genre: str, 28 | output: Path, 29 | font_size: float = 1.0, 30 | borderless: bool = False, 31 | omit_gradient: bool = False, 32 | ) -> None: 33 | 34 | # Initialize parent object for the ImageMagickInterface 35 | super().__init__() 36 | 37 | # Store the arguments 38 | self.source = source 39 | self.genre = genre 40 | self.output = output 41 | self.font_size = font_size 42 | self.borderless = borderless 43 | self.omit_gradient = omit_gradient 44 | 45 | 46 | @property 47 | def gradient_commands(self) -> ImageMagickCommands: 48 | """ 49 | ImageMagick subcommands to add the gradient overlay to the image 50 | """ 51 | 52 | if self.omit_gradient: 53 | return [] 54 | 55 | return [ 56 | f'"{self.__GENRE_GRADIENT.resolve()}"', 57 | f'-gravity south', 58 | f'-composite', 59 | ] 60 | 61 | 62 | @property 63 | def border_commands(self) -> ImageMagickCommands: 64 | """ImageMagick subcommands to add the border around the image""" 65 | 66 | if self.borderless: 67 | return [] 68 | 69 | return [ 70 | f'-gravity center', 71 | f'-bordercolor white', 72 | f'-border 27x27', 73 | ] 74 | 75 | 76 | def create(self) -> None: 77 | """ 78 | Create the genre card. This WILL overwrite the existing file if 79 | it already exists. Errors and returns if the source image does 80 | not exist. 81 | """ 82 | 83 | # If the source file doesn't exist, exit 84 | if not self.source.exists(): 85 | log.error(f'Cannot create genre card - "{self.source.resolve()}" ' 86 | f'does not exist.') 87 | return None 88 | 89 | # Create the output directory and any necessary parents 90 | self.output.parent.mkdir(parents=True, exist_ok=True) 91 | 92 | # Command to create genre poster 93 | command = ' '.join([ 94 | # Resize source image 95 | f'convert "{self.source.resolve()}"', 96 | f'-background transparent', 97 | f'-resize "946x1446^"', 98 | f'-gravity center', 99 | f'-extent "946x1446"', 100 | # Optionally add gradient 101 | *self.gradient_commands, 102 | # Add border 103 | *self.border_commands, 104 | # Add genre text 105 | f'-font "{self.FONT.resolve()}"', 106 | f'-fill white', 107 | f'-pointsize {self.font_size * 159.0}', 108 | f'-kerning 2.25', 109 | f'-interline-spacing -40', 110 | f'-annotate +0+564 "{self.genre}"', 111 | f'"{self.output.resolve()}"', 112 | ]) 113 | 114 | self.image_magick.run(command) 115 | return None 116 | -------------------------------------------------------------------------------- /modules/ImageMaker.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from collections import namedtuple 3 | from pathlib import Path 4 | from re import findall 5 | from typing import TYPE_CHECKING, Iterable, Literal, Optional 6 | 7 | from modules import global_objects 8 | from modules.Debug import log 9 | from modules.ImageMagickInterface import ImageMagickInterface 10 | 11 | if TYPE_CHECKING: 12 | from modules.PreferenceParser import PreferenceParser 13 | 14 | 15 | Dimensions = namedtuple('Dimensions', ('width', 'height')) 16 | ImageMagickCommands = list[str] 17 | 18 | 19 | class ImageMaker(ABC): 20 | """ 21 | Abstract class that outlines the necessary attributes for any class 22 | that creates images. 23 | 24 | All instances of this class must implement `create()` as the main 25 | callable function to produce an image. The specifics of how that 26 | image is created are completely customizable. 27 | """ 28 | 29 | """Base reference directory for local assets""" 30 | BASE_REF_DIRECTORY = Path(__file__).parent / 'ref' 31 | 32 | """Directory for all temporary images created during image creation""" 33 | TEMP_DIR = Path(__file__).parent / '.objects' 34 | 35 | """Temporary file location for svg -> png conversion""" 36 | TEMPORARY_SVG_FILE = TEMP_DIR / 'temp_logo.svg' 37 | 38 | """Temporary file location for image filesize reduction""" 39 | TEMPORARY_COMPRESS_FILE = TEMP_DIR / 'temp_compress.jpg' 40 | 41 | """ 42 | Valid file extensions for input images - ImageMagick supports more 43 | than just these types, but these are the most common across all 44 | OS's. 45 | """ 46 | VALID_IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.tiff', '.gif', '.webp') 47 | 48 | __slots__ = ('preferences', 'image_magick') 49 | 50 | 51 | @abstractmethod 52 | def __init__(self, *, 53 | preferences: Optional['PreferenceParser'] = None, 54 | ) -> None: 55 | """ 56 | Initializes a new instance. This gives all subclasses access to 57 | an ImageMagickInterface via the image_magick attribute. 58 | """ 59 | 60 | # No Preferences object, use global 61 | if preferences is None: 62 | self.preferences = global_objects.pp 63 | self.image_magick = ImageMagickInterface( 64 | self.preferences.imagemagick_container, 65 | self.preferences.use_magick_prefix, 66 | self.preferences.imagemagick_timeout, 67 | ) 68 | # Preferences object provided, use directly 69 | else: 70 | self.preferences = preferences 71 | self.image_magick = ImageMagickInterface( 72 | **preferences.imagemagick_arguments, 73 | ) 74 | 75 | 76 | def get_text_dimensions(self, 77 | text_command: list[str], 78 | *, 79 | width: Literal['sum', 'max'] = 'max', 80 | height: Literal['sum', 'max'] = 'sum', 81 | ) -> Dimensions: 82 | """ 83 | Get the dimensions of the text produced by the given text 84 | command. For 'width' and 'height' arguments, if 'max' then the 85 | maximum value of the text is utilized, while 'sum' will add each 86 | value. For example, if the given text command produces text like: 87 | 88 | Top Line Text 89 | Bottom Text 90 | 91 | Specifying width='sum', will add the widths of the two lines 92 | (not very meaningful), width='max' will return the maximum width 93 | of the two lines. Specifying height='sum' will return the total 94 | height of the text, and height='max' will return the tallest 95 | single line of text. 96 | 97 | Args: 98 | text_command: ImageMagick commands that produce text(s) to 99 | measure. 100 | width: How to process the width of the produced text(s). 101 | height: How to process the height of the produced text(s). 102 | 103 | Returns: 104 | Dimensions namedtuple. 105 | """ 106 | 107 | text_command = ' '.join([ 108 | f'convert', 109 | f'-debug annotate', 110 | f'' if '-annotate ' in ' '.join(text_command) else f'xc: ', 111 | *text_command, 112 | f'null: 2>&1', 113 | ]) 114 | 115 | # Execute dimension command, parse output 116 | metrics = self.image_magick.run_get_output(text_command) 117 | widths = map(int, findall(r'Metrics:.*width:\s+(\d+)', metrics)) 118 | heights = map(int, findall(r'Metrics:.*height:\s+(\d+)', metrics)) 119 | 120 | try: 121 | # Label text produces duplicate Metrics 122 | def sum_(v: Iterable[float]) -> float: 123 | return sum(v) // (2 if ' label:"' in text_command else 1) 124 | 125 | # Process according to given methods 126 | return Dimensions( 127 | sum_(widths) if width == 'sum' else max(widths), 128 | sum_(heights) if height == 'sum' else max(heights), 129 | ) 130 | except ValueError as e: 131 | log.debug(f'Cannot identify text dimensions - {e}') 132 | return Dimensions(0, 0) 133 | 134 | 135 | @staticmethod 136 | def reduce_file_size(image: Path, quality: int = 90) -> Path: 137 | """ 138 | Reduce the file size of the given image. 139 | 140 | Args: 141 | image: Path to the image to reduce the file size of. 142 | quality: Quality of the reduction. 100 being no reduction, 0 143 | being complete reduction. Passed to ImageMagick -quality. 144 | 145 | Returns: 146 | Path to the created image. 147 | """ 148 | 149 | # Verify quality is 0-100 150 | if (quality := int(quality)) not in range(0, 100): 151 | return None 152 | 153 | # If image DNE, warn and return 154 | if not image.exists(): 155 | log.warning(f'Cannot reduce file size of non-existent image ' 156 | f'"{image.resolve()}"') 157 | return None 158 | 159 | # Create ImageMagickInterface for this command 160 | image_magick_interface = ImageMagickInterface( 161 | global_objects.pp.imagemagick_container, 162 | global_objects.pp.use_magick_prefix, 163 | global_objects.pp.imagemagick_timeout, 164 | ) 165 | 166 | # Downsample and reduce quality of source image 167 | command = ' '.join([ 168 | f'convert', 169 | f'"{image.resolve()}"', 170 | f'-sampling-factor 4:2:0', 171 | f'-quality {quality}%', 172 | f'"{ImageMaker.TEMPORARY_COMPRESS_FILE.resolve()}"', 173 | ]) 174 | 175 | image_magick_interface.run(command) 176 | 177 | return ImageMaker.TEMPORARY_COMPRESS_FILE 178 | 179 | 180 | @staticmethod 181 | def convert_svg_to_png( 182 | image: Path, 183 | destination: Path, 184 | min_dimension: int = 2500 185 | ) -> Optional[Path]: 186 | """ 187 | Convert the given SVG image to PNG format. 188 | 189 | Args: 190 | image: Path to the SVG image being converted. 191 | destination: Path to the output image. 192 | min_dimension: Minimum dimension of converted image. 193 | 194 | Returns: 195 | Path to the converted file. None if the conversion failed. 196 | """ 197 | 198 | # If the temp file doesn't exist, return 199 | if not image.exists(): 200 | return None 201 | 202 | # Create ImageMagickInterface for this command 203 | image_magick_interface = ImageMagickInterface( 204 | global_objects.pp.imagemagick_container, 205 | global_objects.pp.use_magick_prefix, 206 | global_objects.pp.imagemagick_timeout, 207 | ) 208 | 209 | # Command to convert file to PNG 210 | command = ' '.join([ 211 | f'convert', 212 | f'-density 512', 213 | f'-resize "{min_dimension}x{min_dimension}"', 214 | f'-background None', 215 | f'"{image.resolve()}"', 216 | f'"{destination.resolve()}"', 217 | ]) 218 | 219 | image_magick_interface.run(command) 220 | 221 | # Print command history if conversion failed 222 | if destination.exists(): 223 | return destination 224 | 225 | image_magick_interface.print_command_history() 226 | return None 227 | 228 | 229 | @abstractmethod 230 | def create(self) -> None: 231 | """ 232 | Abstract method for the creation of the image outlined by this 233 | maker. This method should delete any intermediate files, and 234 | should make ImageMagick calls through the parent class' 235 | ImageMagickInterface object. 236 | """ 237 | raise NotImplementedError(f'All ImageMaker objects must implement this') 238 | -------------------------------------------------------------------------------- /modules/MediaServer.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Optional, Union 3 | from pathlib import Path 4 | 5 | from tinydb import where, Query 6 | 7 | from modules.Debug import log 8 | from modules.Episode import Episode 9 | from modules.EpisodeInfo import EpisodeInfo 10 | from modules.ImageMaker import ImageMaker 11 | from modules.PersistentDatabase import PersistentDatabase 12 | from modules.SeasonPosterSet import SeasonPosterSet 13 | from modules.SeriesInfo import SeriesInfo 14 | from modules.StyleSet import StyleSet 15 | 16 | SourceImage = Union[str, bytes, None] 17 | 18 | class MediaServer(ABC): 19 | """ 20 | This class describes an abstract base class for all MediaServer 21 | classes. MediaServer objects are servers like Plex, Emby, and 22 | JellyFin that can have title cards loaded into them, as well as 23 | source images retrieved from them. 24 | """ 25 | 26 | """Maximum time allowed for a single GET request""" 27 | REQUEST_TIMEOUT = 30 28 | 29 | """Default filesize limit for all uploaded assets""" 30 | DEFAULT_FILESIZE_LIMIT = '10 MB' 31 | 32 | 33 | @property 34 | @abstractmethod 35 | def LOADED_DB(self) -> str: 36 | """ 37 | Filename of the PersistentDatabase of loaded assets within this 38 | MediaServer. 39 | """ 40 | raise NotImplementedError('All MediaServer objects must implement this') 41 | 42 | 43 | @abstractmethod 44 | def __init__(self, filesize_limit: int) -> None: 45 | """ 46 | Initialize an instance of this object. This stores creates an 47 | attribute loaded_db that is a PersistentDatabase of the 48 | LOADED_DB file. 49 | """ 50 | 51 | self.loaded_db = PersistentDatabase(self.LOADED_DB) 52 | self.filesize_limit = filesize_limit 53 | 54 | 55 | def __bool__(self) -> bool: 56 | return True 57 | 58 | 59 | def compress_image(self, image: Path) -> Optional[Path]: 60 | """ 61 | Compress the given image until below the filesize limit. 62 | 63 | Args: 64 | image: Path to the image to compress. 65 | 66 | Returns: 67 | Path to the compressed image, or None if the image could not 68 | be compressed. 69 | """ 70 | 71 | # Ensure image is Path object 72 | image = Path(image) 73 | 74 | # No compression necessary 75 | if (self.filesize_limit is None 76 | or image.stat().st_size < self.filesize_limit): 77 | return image 78 | 79 | # Start with a quality of 90%, decrement by 5% each time 80 | quality = 95 81 | small_image = image 82 | 83 | # Compress the given image until below the filesize limit 84 | while small_image.stat().st_size > self.filesize_limit: 85 | # Process image, exit if cannot be reduced 86 | quality -= 5 87 | small_image = ImageMaker.reduce_file_size(image, quality) 88 | if small_image is None: 89 | log.warning(f'Cannot reduce filesize of "{image.resolve()}" ' 90 | f'below limit') 91 | return None 92 | 93 | # Compression successful, log and return intermediate image 94 | log.debug(f'Compressed "{image.resolve()}" with {quality}% quality') 95 | return small_image 96 | 97 | 98 | def _get_condition(self, 99 | library_name: str, 100 | series_info: SeriesInfo, 101 | episode: Episode = None, 102 | ) -> Query: 103 | """ 104 | Get the tinydb Query condition for the given entry. 105 | 106 | Args: 107 | library_name: Library name containing the series to get the 108 | details of. 109 | series_info: Series to get the details of. 110 | episode: Optional Episode to get the series of. 111 | 112 | Returns: 113 | Query condition to filter a TinyDB database for the 114 | requested entry. 115 | """ 116 | 117 | # If no episode was given, get condition for entire series 118 | if episode is None: 119 | return ( 120 | (where('library') == library_name) & 121 | (where('series') == series_info.full_name) 122 | ) 123 | 124 | return ( 125 | (where('library') == library_name) & 126 | (where('series') == series_info.full_name) & 127 | (where('season') == episode.episode_info.season_number) & 128 | (where('episode') == episode.episode_info.episode_number) 129 | ) 130 | 131 | 132 | def _get_loaded_episode(self, 133 | loaded_series: list[dict[str, Any]], 134 | episode: Episode 135 | ) -> Optional[dict[str, Any]]: 136 | """ 137 | Get the loaded details of the given Episode from the given list 138 | of loaded series details. 139 | 140 | Args: 141 | loaded_series: Filtered List from the loaded database to 142 | search. 143 | episode: The Episode to get the details of. 144 | 145 | Returns: 146 | Loaded details for the specified episode. None if an episode 147 | of that index DNE in the given list. 148 | """ 149 | 150 | for entry in loaded_series: 151 | if (entry['season'] == episode.episode_info.season_number and 152 | entry['episode'] == episode.episode_info.episode_number): 153 | return entry 154 | 155 | return None 156 | 157 | 158 | def _filter_loaded_cards(self, 159 | library_name: str, 160 | series_info: SeriesInfo, 161 | episode_map: dict[str, Episode] 162 | ) -> dict[str, Episode]: 163 | """ 164 | Filter the given episode map and remove all Episode objects 165 | without created cards, or whose card's filesizes matches that of 166 | the already uploaded card. 167 | 168 | Args: 169 | library_name: Name of the library containing this series. 170 | series_info: SeriesInfo object for these episodes. 171 | episode_map: Dictionary of Episode objects to filter. 172 | 173 | Returns: 174 | Filtered episode map. Episodes without existing cards, or 175 | whose existing card filesizes' match those already loaded 176 | are removed. 177 | """ 178 | 179 | # Get all loaded details for this series 180 | series = self.loaded_db.search( 181 | self._get_condition(library_name, series_info) 182 | ) 183 | 184 | filtered = {} 185 | for key, episode in episode_map.items(): 186 | # Filter out episodes without cards 187 | if not episode.destination or not episode.destination.exists(): 188 | continue 189 | 190 | # If no cards have been loaded, add all episodes with cards 191 | if not series: 192 | filtered[key] = episode 193 | continue 194 | 195 | # Get current details of this episode 196 | found = False 197 | if (entry := self._get_loaded_episode(series, episode)): 198 | # Episode found, check filesize 199 | found = True 200 | if entry['filesize'] != episode.destination.stat().st_size: 201 | filtered[key] = episode 202 | 203 | # If this episode has never been loaded, add 204 | if not found: 205 | filtered[key] = episode 206 | 207 | return filtered 208 | 209 | 210 | def remove_records(self, library_name: str, series_info: SeriesInfo) ->None: 211 | """ 212 | Remove all records for the given library and series from the 213 | loaded database. 214 | 215 | Args: 216 | library_name: The name of the library containing the series 217 | whose records are being removed. 218 | series_info: SeriesInfo whose records are being removed. 219 | """ 220 | 221 | # Get condition to find records matching this library + series 222 | condition = self._get_condition(library_name, series_info) 223 | 224 | # Delete records matching this condition 225 | records = self.loaded_db.remove(condition) 226 | log.info(f'Deleted {len(records)} records') 227 | 228 | 229 | @abstractmethod 230 | def has_series(self, 231 | library_name: str, 232 | series_info: SeriesInfo, 233 | ) -> bool: 234 | """ 235 | Determine whether the given series is present within this 236 | MediaServer. 237 | """ 238 | raise NotImplementedError 239 | 240 | 241 | @abstractmethod 242 | def update_watched_statuses(self, 243 | library_name: str, 244 | series_info: SeriesInfo, 245 | episode_map: dict[str, Episode], 246 | style_set: StyleSet, 247 | ) -> None: 248 | """Abstract method to update watched statuses of Episode objects.""" 249 | raise NotImplementedError 250 | 251 | 252 | @abstractmethod 253 | def set_title_cards(self, 254 | library_name: str, 255 | series_info: SeriesInfo, 256 | episode_map: dict[str, Episode], 257 | ) -> None: 258 | """Abstract method to load title cards within this MediaServer.""" 259 | raise NotImplementedError 260 | 261 | 262 | @abstractmethod 263 | def set_season_posters(self, 264 | library_name: str, 265 | series_info: SeriesInfo, 266 | season_poster_set: SeasonPosterSet, 267 | ) -> None: 268 | """Abstract method to load title cards within this MediaServer.""" 269 | raise NotImplementedError 270 | 271 | 272 | @abstractmethod 273 | def get_source_image(self, 274 | library_name: str, 275 | series_info: SeriesInfo, 276 | episode_info: EpisodeInfo, 277 | ) -> SourceImage: 278 | """ 279 | Abstract method to get textless source images from this 280 | MediaServer. 281 | """ 282 | raise NotImplementedError 283 | 284 | 285 | @abstractmethod 286 | def get_libraries(self) -> list[str]: 287 | """ 288 | Abstract method to get all libraries from this MediaServer. 289 | """ 290 | raise NotImplementedError 291 | -------------------------------------------------------------------------------- /modules/PersistentDatabase.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from time import sleep 3 | from typing import Callable 4 | 5 | from json.decoder import JSONDecodeError 6 | from tinydb import TinyDB 7 | 8 | from modules.Debug import log 9 | from modules import global_objects 10 | 11 | class PersistentDatabase: 12 | """ 13 | This class describes some persistent storage and is a loose wrapper 14 | of a TinyDB object. The purpose of this class is to handle corrupted 15 | TinyDB databases without littering the code with try/except 16 | statements. Any function calls on this object are called on the 17 | underlying TinyDB object and any raised JSONDecodeError Exceptions 18 | are caught, the database is deleted, and the function is 19 | re-executed. 20 | """ 21 | 22 | MAX_DB_RETRY_COUNT: int = 5 23 | 24 | 25 | def __init__(self, filename: str) -> None: 26 | """ 27 | Initialize an instance of an object for the given TinyDB object 28 | with the given filename. 29 | 30 | Args: 31 | filename: Filename to the Database object. 32 | """ 33 | 34 | # Path to the file itself 35 | self.file: Path = global_objects.pp.database_directory / filename 36 | self.file.parent.mkdir(exist_ok=True, parents=True) 37 | 38 | # Initialize TinyDB from file 39 | try: 40 | self.db = TinyDB(self.file) 41 | except JSONDecodeError: 42 | log.exception(f'Database {self.file.resolve()} is corrupted') 43 | self.reset() 44 | except Exception: 45 | log.exception(f'Uncaught exception on Database initialization') 46 | self.reset() 47 | 48 | 49 | def __getattr__(self, database_func: str) -> Callable: 50 | """ 51 | Get an arbitrary function for this object. This returns a 52 | wrapped version of the accessed function that catches any 53 | uncaught JSONDecodeError exceptions (prompting a DB reset). 54 | 55 | Args: 56 | database_func: The function being called. 57 | 58 | Returns: 59 | Wrapped callable that is the indicated function with any 60 | uncaught JSONDecodeError exceptions caught, the database 61 | reset, and then the function recalled. 62 | """ 63 | 64 | # Define wrapper that calls given function with args, and then catches 65 | # any uncaught exceptions 66 | def wrapper(*args, __retries: int = 0, **kwargs) -> None: 67 | try: 68 | kwargs.pop('__retries', None) 69 | return getattr(self.db, database_func)(*args, **kwargs) 70 | except (ValueError, JSONDecodeError) as e: 71 | # If this function has been attempted too many times, just raise 72 | if __retries > self.MAX_DB_RETRY_COUNT: 73 | raise e 74 | 75 | # Log conflict, sleep, reset database, and try function again 76 | log.exception(f'Database {self.file.resolve()} has conflict') 77 | sleep(3) 78 | self.reset() 79 | return wrapper(*args, **kwargs, __retries=__retries+1) 80 | 81 | # Return "attribute" that is the wrapped function 82 | return wrapper 83 | 84 | 85 | def __len__(self) -> int: 86 | """Call len() on this object's underlying TinyDB object.""" 87 | 88 | return len(self.db) 89 | 90 | 91 | def reset(self) -> None: 92 | """ 93 | Reset this object's associated database. This deletes the file 94 | and recreates a new TinyDB. 95 | """ 96 | 97 | # Attempt to remove all records; if that fails delete and remake file 98 | try: 99 | self.db.truncate() 100 | except Exception: 101 | self.file.unlink(missing_ok=True) 102 | self.file.parent.mkdir(exist_ok=True, parents=True) 103 | self.db = TinyDB(self.file) 104 | -------------------------------------------------------------------------------- /modules/RemoteCardType.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib.util import spec_from_file_location, module_from_spec 3 | 4 | from pathlib import Path 5 | from typing import Optional 6 | from requests import get 7 | from tinydb import where 8 | from modules.BaseCardType import BaseCardType 9 | 10 | from modules.CleanPath import CleanPath 11 | from modules.Debug import log 12 | from modules.PersistentDatabase import PersistentDatabase 13 | from modules.RemoteFile import RemoteFile 14 | 15 | class RemoteCardType: 16 | """ 17 | This class defines a remote CardType. This is an encapsulation of a 18 | CardType class that, rather than being defined locally, queries the 19 | Maker GitHub for Python classes to dynamically inject in the modules 20 | namespace. 21 | """ 22 | 23 | """Base URL for all remote Card Type files to download""" 24 | URL_BASE = ( 25 | 'https://raw.githubusercontent.com/CollinHeist/' 26 | 'TitleCardMaker-CardTypes/master' 27 | ) 28 | 29 | """Temporary directory all card types are written to""" 30 | TEMP_DIR = Path(__file__).parent / '.objects' 31 | 32 | """Database of assets that have been loaded already this run""" 33 | LOADED = 'remote_assets.json' 34 | 35 | __slots__ = ('loaded', 'card_class', 'valid') 36 | 37 | 38 | def __init__(self, remote: str) -> None: 39 | """ 40 | Construct a new RemoteCardType. This downloads the source file 41 | at the specified location and loads it as a class in the global 42 | modules, under the interpreted class name. If the given remote 43 | specification is a file that exists, that file is loaded. 44 | 45 | Args: 46 | database_directory: Base Path to read/write any databases 47 | from. 48 | remote: URL to remote card to inject. Should omit repo base. 49 | Should be specified like {username}/{class_name}. Can 50 | also be a local filepath. 51 | """ 52 | 53 | # Get database of loaded assets/cards 54 | self.card_class: Optional[BaseCardType] = None 55 | self.loaded = PersistentDatabase(self.LOADED) 56 | self.valid = True 57 | 58 | # If local file has been specified.. 59 | if (file := CleanPath(remote).sanitize()).exists(): 60 | # Get class name from file 61 | class_name = file.stem 62 | file_name = str(file.resolve()) 63 | else: 64 | # Get username and class name from the remote specification 65 | username = remote.split('/', maxsplit=1)[0] 66 | class_name = remote.split('/')[-1] 67 | 68 | # Download and write the CardType class into a temporary file 69 | file_name = self.TEMP_DIR / f'{username}-{class_name}.py' 70 | url = f'{self.URL_BASE}/{remote}.py' 71 | 72 | # Only request and write file if not loaded this run 73 | if (not self.loaded.get(where('remote') == url) 74 | or not file_name.exists()): 75 | # Make GET request for the contents of the specified value 76 | if (response := get(url, timeout=30)).status_code >= 400: 77 | log.error(f'Cannot identify remote Card Type "{remote}"') 78 | self.valid = False 79 | return None 80 | 81 | # Write remote file contents to temporary class 82 | file_name.parent.mkdir(parents=True, exist_ok=True) 83 | with (file_name).open('wb') as fh: 84 | fh.write(response.content) 85 | 86 | # Import new file as module 87 | try: 88 | # Create module for newly loaded file 89 | spec = spec_from_file_location(class_name, file_name) 90 | module = module_from_spec(spec) 91 | sys.modules[class_name] = module 92 | spec.loader.exec_module(module) 93 | 94 | # Get class from module namespace 95 | self.card_class = module.__dict__[class_name] 96 | 97 | # Validate that each RemoteFile of this class loaded correctly 98 | for attribute_name in dir(self.card_class): 99 | attribute = getattr(self.card_class, attribute_name) 100 | if isinstance(attribute, RemoteFile): 101 | self.valid &= attribute.valid 102 | 103 | # Add this url to the loaded database 104 | try: 105 | self.loaded.insert({'remote': url}) 106 | log.debug(f'Loaded RemoteCardType "{remote}"') 107 | except Exception: 108 | pass 109 | except Exception as e: 110 | # Some error in loading, set object as invalid 111 | log.error(f'Cannot load CardType "{remote}", returned "{e}"') 112 | self.card_class = None 113 | self.valid = False 114 | return None 115 | -------------------------------------------------------------------------------- /modules/RemoteFile.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from requests import Response, get 4 | from tenacity import retry, stop_after_attempt, wait_fixed, wait_exponential 5 | from tinydb import where 6 | 7 | from modules.Debug import log 8 | from modules.PersistentDatabase import PersistentDatabase 9 | 10 | 11 | class RemoteFile: 12 | """ 13 | This class describes a RemoteFile. A RemoteFile is a file that is 14 | loaded from the TCM Card Types repository, and is necessary to allow 15 | card types to utilize non-standard files that can be downloaded at 16 | runtime alongside CardType classes. This class has no real 17 | executable methods, and upon initialization attempts to download the 18 | remote file if it DNE. 19 | """ 20 | 21 | """Base URL to look for remote content at""" 22 | BASE_URL = ( 23 | 'https://github.com/CollinHeist/TitleCardMaker-CardTypes/raw/master' 24 | ) 25 | 26 | """Temporary directory all files will be downloaded into""" 27 | TEMP_DIR = Path(__file__).parent / '.objects' 28 | 29 | """Database of assets that have been loaded already""" 30 | LOADED_FILE = 'remote_assets.json' 31 | 32 | __slots__ = ('loaded', 'remote_source', 'local_file', 'valid') 33 | 34 | 35 | def __init__(self, username: str, filename: str) -> None: 36 | """ 37 | Construct a new RemoteFile object. This downloads the file for 38 | the given user and file into the temporary directory of the 39 | Maker. 40 | 41 | Args: 42 | username: Username containing the file. 43 | filename: Filename of the file within the user's folder to 44 | download. 45 | """ 46 | 47 | # Object validity to be updated 48 | self.valid = True 49 | 50 | # Get database of loaded assets 51 | self.loaded = PersistentDatabase(self.LOADED_FILE) 52 | 53 | # Remote font will be stored at github/username/filename 54 | self.remote_source = f'{self.BASE_URL}/{username}/{filename}' 55 | 56 | # The font fill will be downloaded and exist in the temporary directory 57 | self.local_file = self.TEMP_DIR / username / filename.rsplit('/')[-1] 58 | 59 | # Create parent folder structure if necessary 60 | self.local_file.parent.mkdir(parents=True, exist_ok=True) 61 | 62 | # If file has already been loaded this run, skip 63 | if self.loaded.get(where('remote') == self.remote_source) is not None: 64 | return None 65 | 66 | # Download the remote file for local use 67 | try: 68 | self.download() 69 | log.debug(f'Downloaded RemoteFile "{username}/{filename}"') 70 | except Exception: 71 | self.valid = False 72 | log.exception(f'Could not download RemoteFile ' 73 | f'"{username}/{filename}"') 74 | return None 75 | 76 | try: 77 | self.loaded.insert({'remote': self.remote_source}) 78 | except Exception: 79 | pass 80 | 81 | return None 82 | 83 | 84 | def __str__(self) -> str: 85 | """ 86 | Returns a string representation of the object. This is just the 87 | complete filepath for the locally downloaded file. 88 | """ 89 | 90 | return str(self.local_file.resolve()) 91 | 92 | 93 | def __repr__(self) -> str: 94 | """Returns an unambiguous string representation of the object.""" 95 | 96 | return ( 97 | f'' 99 | ) 100 | 101 | 102 | def resolve(self) -> Path: 103 | """ 104 | Get the absolute path of the locally downloaded file. 105 | 106 | Returns: 107 | Path to the locally downloaded file. 108 | """ 109 | 110 | return self.local_file.resolve() 111 | 112 | 113 | @retry(stop=stop_after_attempt(3), 114 | wait=wait_fixed(3)+wait_exponential(min=1, max=16)) 115 | def __get_remote_content(self) -> Response: 116 | """ 117 | Get the content at the remote source. 118 | 119 | Returns: 120 | Response object from this object's remote source. 121 | """ 122 | 123 | return get(self.remote_source, timeout=10) 124 | 125 | 126 | def download(self) -> None: 127 | """ 128 | Download the specified remote file from the TCM CardTypes 129 | GitHub, and write it to a temporary local file. 130 | 131 | Raises: 132 | AssertionError if the Response is not OK; Exception if there 133 | is some uncaught error. 134 | """ 135 | 136 | # Download remote file 137 | content = self.__get_remote_content() 138 | 139 | # Verify content is valid 140 | assert content.ok, 'File does not exist' 141 | 142 | # Write content to file 143 | with self.local_file.open('wb') as file_handle: 144 | file_handle.write(content.content) 145 | 146 | 147 | @staticmethod 148 | def reset_loaded_database() -> None: 149 | """Reset (clear) this class's database of loaded remote files.""" 150 | 151 | PersistentDatabase(RemoteFile.LOADED_FILE).reset() 152 | log.debug(f'Reset PersistentDatabase[{RemoteFile.LOADED_FILE}]') 153 | -------------------------------------------------------------------------------- /modules/SeasonPoster.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Literal, Optional 3 | 4 | from modules.ImageMaker import ImageMagickCommands, ImageMaker 5 | 6 | 7 | _LogoPlacement = Literal['top', 'middle', 'bottom'] 8 | _TextPlacement = Literal['top', 'bottom'] 9 | 10 | 11 | class SeasonPoster(ImageMaker): 12 | """ 13 | This class describes a type of ImageMaker that creates season 14 | posters. Season posters take images, add a logo and season title. 15 | """ 16 | 17 | """Default size of all season posters""" 18 | POSTER_WIDTH = 2000 19 | POSTER_HEIGHT = 3000 20 | SEASON_POSTER_SIZE = f'{POSTER_WIDTH}x{POSTER_HEIGHT}' 21 | 22 | """Directory where all reference files used by this card are stored""" 23 | REF_DIRECTORY = Path(__file__).parent / 'ref' /'season_poster' 24 | 25 | """Default font values for the season text""" 26 | SEASON_TEXT_FONT = REF_DIRECTORY / 'Proxima Nova Semibold.otf' 27 | SEASON_TEXT_COLOR = '#CFCFCF' 28 | 29 | """Paths for the gradient overlay""" 30 | GRADIENT_OVERLAY = REF_DIRECTORY / 'gradient.png' 31 | 32 | __slots__ = ( 33 | 'source', 'destination', 'logo', 'season_text', 'font', 'font_color', 34 | 'font_size', 'font_kerning', 'logo_placement', 'omit_gradient', 35 | 'omit_logo', 'text_placement', 36 | ) 37 | 38 | 39 | def __init__(self, 40 | source: Path, 41 | destination: Path, 42 | logo: Optional[Path], 43 | season_text: str, 44 | font: Path = SEASON_TEXT_FONT, 45 | font_color: str = SEASON_TEXT_COLOR, 46 | font_size: float = 1.0, 47 | font_kerning: float = 1.0, 48 | logo_placement: _LogoPlacement = 'top', 49 | omit_gradient: bool = False, 50 | omit_logo: bool = False, 51 | text_placement: _TextPlacement = 'top', 52 | ) -> None: 53 | """ 54 | Initialize this SeasonPoster object. 55 | 56 | Args: 57 | source: Path to the source image to use for the poster. 58 | logo: Path to the logo file to use on the poster. 59 | destination: Path to the desination file to create. 60 | season_text: Season text to utilize on the poster. 61 | font: Path to the font file to use for the season text. 62 | font_color: Font color to use for the season text. 63 | font_size: Font size scalar to use for the season text. 64 | font_kerning: Font kerning scalar to use for the season text. 65 | logo_placement: Where to place the logo on the poster. 66 | omit_gradient: Whether to omit the gradient overlay. 67 | omit_logo: Whether to omit the logo overlay. 68 | text_placement: Where to place text. 69 | """ 70 | 71 | # Initialize parent object for the ImageMagickInterface 72 | super().__init__() 73 | 74 | # Store provided file attributes 75 | self.source = source 76 | self.destination = destination 77 | self.logo = None if omit_logo else logo 78 | 79 | # Store text attributes 80 | self.season_text = season_text.upper() 81 | 82 | # Store customized font attributes 83 | self.font = font 84 | self.font_color = font_color 85 | self.font_size = font_size 86 | self.font_kerning = font_kerning 87 | self.logo_placement: _LogoPlacement = logo_placement 88 | self.omit_gradient = omit_gradient 89 | self.text_placement: _TextPlacement = text_placement 90 | 91 | 92 | def __get_logo_height(self) -> int: 93 | """ 94 | Get the logo height of the logo after it will be resized. 95 | 96 | Returns: 97 | Integer height (in pixels) of the resized logo. 98 | """ 99 | 100 | # If omitting the logo, return 0 101 | if self.logo is None: 102 | return 0 103 | 104 | command = ' '.join([ 105 | f'convert', 106 | f'"{self.logo.resolve()}"', 107 | f'-resize 1460x', 108 | f'-resize x750\>', 109 | f'-format "%[h]"', 110 | f'info:', 111 | ]) 112 | 113 | return int(self.image_magick.run_get_output(command)) 114 | 115 | 116 | @property 117 | def gradient_commands(self) -> ImageMagickCommands: 118 | """Subcommands to overlay the gradient to the source image.""" 119 | 120 | # If omitting the gradient, return empty commands 121 | if self.omit_gradient: 122 | return [] 123 | 124 | # Top placement, rotate gradient 125 | if self.text_placement == 'top': 126 | return [ 127 | f'\( "{self.GRADIENT_OVERLAY.resolve()}"', 128 | f'-rotate 180 \)', 129 | f'-compose Darken', 130 | f'-composite', 131 | ] 132 | 133 | # Bottom placement, do not rotate 134 | return [ 135 | f'"{self.GRADIENT_OVERLAY.resolve()}"', 136 | f'-compose Darken', 137 | f'-composite', 138 | ] 139 | 140 | 141 | @property 142 | def logo_commands(self) -> ImageMagickCommands: 143 | """Subcommands to overlay the logo to the source image.""" 144 | 145 | # If omitting the logo, return empty commands 146 | if self.logo is None: 147 | return [] 148 | 149 | # Offset and gravity are determined by placement 150 | gravity = { 151 | 'top': 'north', 'middle': 'center', 'bottom': 'south' 152 | }[self.logo_placement] 153 | offset = {'top': 212, 'middle': 0, 'bottom': 356}[self.logo_placement] 154 | 155 | return [ 156 | # Overlay logo 157 | f'\( "{self.logo.resolve()}"', 158 | # Fit to 1460px wide 159 | f'-resize 1460x', 160 | # Limit to 750px tall 161 | f'-resize x750\> \)', 162 | # Begin logo merge 163 | f'-gravity {gravity}', 164 | f'-compose Atop', 165 | f'-geometry +0{offset:+}', 166 | # Merge logo and source 167 | f'-composite', 168 | ] 169 | 170 | 171 | @property 172 | def text_commands(self) -> ImageMagickCommands: 173 | """Subcommands to add the text to the image.""" 174 | 175 | font_size = 20.0 * self.font_size 176 | kerning = 30 * self.font_kerning 177 | 178 | # Determine season text offset depending on orientation 179 | if self.text_placement == 'top': 180 | if self.logo is None or self.logo_placement != 'top': 181 | text_offset = 212 182 | else: 183 | text_offset = 212 + self.__get_logo_height() + 60 184 | else: 185 | text_offset = self.POSTER_HEIGHT - 295 186 | 187 | return [ 188 | f'-gravity north', 189 | f'-font "{self.font.resolve()}"', 190 | f'-fill "{self.font_color}"', 191 | f'-pointsize {font_size}', 192 | f'-kerning {kerning}', 193 | f'-annotate +0+{text_offset} "{self.season_text}"', 194 | ] 195 | 196 | 197 | def create(self) -> None: 198 | """Create the season poster defined by this object.""" 199 | 200 | # Exit if source or logo DNE 201 | if (not self.source.exists() 202 | or (self.logo is not None and not self.logo.exists())): 203 | return None 204 | 205 | # Create parent directories 206 | self.destination.parent.mkdir(parents=True, exist_ok=True) 207 | 208 | # Create the command 209 | command = ' '.join([ 210 | f'convert', 211 | f'-density 300', 212 | # Resize input image 213 | f'"{self.source.resolve()}"', 214 | f'-gravity center', 215 | f'-resize "{self.SEASON_POSTER_SIZE}^"', 216 | f'-extent "{self.SEASON_POSTER_SIZE}"', 217 | # Apply gradient 218 | *self.gradient_commands, 219 | # Add logo 220 | *self.logo_commands, 221 | # Write season text 222 | *self.text_commands, 223 | f'"{self.destination.resolve()}"', 224 | ]) 225 | 226 | self.image_magick.run(command) 227 | return None 228 | -------------------------------------------------------------------------------- /modules/SeasonPosterSet.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import compile as re_compile 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | from num2words import num2words 6 | 7 | from modules.Debug import log 8 | from modules import global_objects 9 | from modules.SeasonPoster import SeasonPoster 10 | from modules.YamlReader import YamlReader 11 | 12 | if TYPE_CHECKING: 13 | from modules.EpisodeMap import EpisodeMap 14 | 15 | 16 | class SeasonPosterSet(YamlReader): 17 | """ 18 | This class defines a set of SeasonPoster objects for a single show. 19 | This class is initialized with via the season poster config, and 20 | mainly wraps the SeasonPoster object directly. 21 | """ 22 | 23 | """Compiled regex to identify percentage values""" 24 | __PERCENT_REGEX = re_compile(r'^-?\d+\.?\d*%$') 25 | __PERCENT_REGEX_POSITIVE = re_compile(r'^\d+\.?\d*%$') 26 | 27 | """Regex to identify season number from poster filenames""" 28 | __SEASON_NUMBER_REGEX = re_compile(r'^season(\d+).jpg$') 29 | 30 | __slots__ = ( 31 | 'font_file', 'font_color', 'font_kerning', 'posters', 'font_size', 32 | '__source_directory', '__logo', 'has_posters', '__media_directory', 33 | 'logo_is_optional', 34 | ) 35 | 36 | 37 | def __init__(self, 38 | episode_map: 'EpisodeMap', 39 | source_directory: Path, 40 | media_directory: Path, 41 | poster_config: Optional[dict] = None, 42 | ) -> None: 43 | """ 44 | Construct a new instance of the set. This parses all YAML 45 | attributes, and looks for input poster images within the given 46 | source directory. 47 | 48 | Args: 49 | episode_map: EpisodeMap containing season titles. 50 | source_directory: Base source directory to look for the 51 | logo and season files at. 52 | media_directory: Base media directory to create season 53 | posters within. 54 | poster_config: Config from the container series' YAML. 55 | """ 56 | 57 | # Initialize parent YamlReader 58 | poster_config = {} if poster_config is None else poster_config 59 | super().__init__(poster_config) 60 | 61 | # Assign default attributes 62 | self.font_color = SeasonPoster.SEASON_TEXT_COLOR 63 | self.font_file = SeasonPoster.SEASON_TEXT_FONT 64 | self.font_kerning = 1.0 65 | self.font_size = 1.0 66 | self.logo_is_optional = poster_config.get('omit_logo', False) 67 | 68 | # Future list of SeasonPoster objects 69 | self.posters: dict[int, SeasonPoster] = {} 70 | self.has_posters = False 71 | 72 | # Get all paths for this set 73 | self.__source_directory = source_directory 74 | self.__logo = source_directory / 'logo.png' 75 | self.__media_directory = media_directory 76 | 77 | # If posters aren't enabled, skip rest of parsing 78 | if media_directory is None or not poster_config.get('create', True): 79 | return None 80 | 81 | # Read the font specification 82 | self.__read_font() 83 | 84 | # Create SeasonPoster objects 85 | self.__prepare_posters(poster_config, episode_map) 86 | return None 87 | 88 | 89 | def __read_font(self) -> None: 90 | """ 91 | Read this object's font config for this poster set, updating 92 | attributes and validity for each element. 93 | """ 94 | 95 | # Exit if no config to parse 96 | if not self._is_specified('font'): 97 | return None 98 | 99 | if (file := self.get('font', 'file', type_=Path)) is not None: 100 | if file.exists(): 101 | self.font_file = file 102 | else: 103 | log.error(f'Font file "{file}" is invalid, no font file found.') 104 | self.valid = False 105 | 106 | if (color := self.get('font', 'color', type_=self.TYPE_LOWER_STR)) is not None: 107 | self.font_color = color 108 | 109 | if (kerning := self.get('font', 'kerning', type_=self.TYPE_LOWER_STR)) is not None: 110 | if bool(self.__PERCENT_REGEX.match(kerning)): 111 | self.font_kerning = float(kerning[:-1]) / 100.0 112 | else: 113 | log.error(f'Font kerning "{kerning}" is invalid, specify as "x%') 114 | self.valid = False 115 | 116 | if (size := self.get('font', 'size', type_=self.TYPE_LOWER_STR)) is not None: 117 | if bool(self.__PERCENT_REGEX_POSITIVE.match(size)): 118 | self.font_size = float(size[:-1]) / 100.0 119 | else: 120 | log.error(f'Font size "{size}" is invalid, specify as "x%"') 121 | self.valid = False 122 | 123 | return None 124 | 125 | 126 | def __prepare_posters(self, 127 | poster_config: dict, 128 | episode_map: 'EpisodeMap', 129 | ) -> None: 130 | """ 131 | Create SeasonPoster objects for all available season poster 132 | images, using the given config. 133 | 134 | Args: 135 | season_config: The YAML config for this PosterSet. 136 | episode_map: EpisodeMap object containing custom defined 137 | season titles. 138 | """ 139 | 140 | # Get all manually specified titles 141 | override_titles = poster_config.get('titles', {}) 142 | specified_titles = episode_map.get_all_season_titles() 143 | 144 | # Get whether to spell or use digits for season numbers (default spell) 145 | spell = poster_config.get('spell_numbers', True) 146 | 147 | # Get placement of text and logo 148 | text_placement = poster_config.get( 149 | 'text_placement', 150 | poster_config.get('placement', 'bottom') 151 | ) 152 | logo_placement = poster_config.get( 153 | 'logo_placement', 154 | poster_config.get('placement', 'bottom') 155 | ) 156 | 157 | # Get whether to omit gradient and logo 158 | omit_gradient = poster_config.get('omit_gradient', False) 159 | omit_logo = poster_config.get('omit_logo', False) 160 | 161 | # Get all the season posters that exist in the source directory 162 | for poster_file in self.__source_directory.glob('season*.jpg'): 163 | # Skip files named season*.jpg that aren't numbered 164 | if self.__SEASON_NUMBER_REGEX.match(poster_file.name) is None: 165 | log.debug(f'Not creating season poster for ' 166 | f'"{poster_file.resolve()}"') 167 | continue 168 | 169 | # Get season number from the file 170 | season_number = int(self.__SEASON_NUMBER_REGEX.match( 171 | poster_file.name 172 | ).group(1)) 173 | 174 | # Look for a per-season logo 175 | logo = self.__logo 176 | if (logos := list(logo.parent.glob(f'logo*season{season_number}.png'))): 177 | logo = logos[0] 178 | 179 | # Get destination file 180 | season_folder = global_objects.pp.get_season_folder(season_number) 181 | filename = f'Season{season_number}.jpg' 182 | destination = self.__media_directory / season_folder / filename 183 | 184 | # Get season title for this poster 185 | if (text := (specified_titles |override_titles).get(season_number)): 186 | season_text = text 187 | elif season_number == 0: 188 | season_text = 'Specials' 189 | elif spell: 190 | season_text = f'Season {num2words(season_number)}' 191 | else: 192 | season_text = f'Season {season_number}' 193 | 194 | # Create SeasonPoster list 195 | self.has_posters = True 196 | self.posters[season_number] = SeasonPoster( 197 | source=poster_file, 198 | destination=destination, 199 | logo=logo, 200 | season_text=season_text, 201 | font=self.font_file, 202 | font_color=self.font_color, 203 | font_size=self.font_size, 204 | font_kerning=self.font_kerning, 205 | logo_placement=logo_placement, 206 | omit_gradient=omit_gradient, 207 | omit_logo=omit_logo, 208 | text_placement=text_placement, 209 | ) 210 | 211 | 212 | def get_poster(self, season_number: int) -> Optional[Path]: 213 | """ 214 | Get the path to the Poster from this set for the given season 215 | number. 216 | 217 | Args: 218 | season_number: Season number to get the poster of. 219 | 220 | Returns: 221 | Path to this set's poster for the given season. None if that 222 | poster does not exist. 223 | """ 224 | 225 | # Return poster file if given season has poster that exists 226 | if ((poster := self.posters.get(season_number)) is not None 227 | and poster.destination.exists()): 228 | return poster.destination 229 | 230 | return None 231 | 232 | 233 | def create(self) -> None: 234 | """Create all season posters within this set.""" 235 | 236 | # Warn and exit if logo DNE 237 | if (self.has_posters 238 | and (not self.logo_is_optional and not self.__logo.exists())): 239 | log.error(f'Cannot create season posters, logo file ' 240 | f'"{self.__logo.resolve()}" does not exist') 241 | return None 242 | 243 | # Go through each season poster within this set 244 | for poster in self.posters.values(): 245 | # Skip if poster file already exists 246 | if poster.destination.exists(): 247 | continue 248 | 249 | # Create season poster 250 | poster.create() 251 | 252 | # Log results 253 | if poster.destination.exists(): 254 | log.debug(f'Created poster "{poster.destination.resolve()}"') 255 | else: 256 | log.debug(f'Could not create poster ' 257 | f'"{poster.destination.resolve()}"') 258 | poster.image_magick.print_command_history() 259 | 260 | return None 261 | -------------------------------------------------------------------------------- /modules/SeriesInfo.py: -------------------------------------------------------------------------------- 1 | from re import compile as re_compile, match, sub as re_sub, IGNORECASE 2 | from typing import Optional, Union 3 | 4 | from plexapi.video import Show as PlexShow 5 | 6 | from modules.CleanPath import CleanPath 7 | from modules.DatabaseInfoContainer import DatabaseInfoContainer 8 | 9 | 10 | class SeriesInfo(DatabaseInfoContainer): 11 | """ 12 | This class encapsulates static information that is tied to a single 13 | Series. 14 | """ 15 | 16 | """Regex to match name + year from given full name""" 17 | __FULL_NAME_REGEX = re_compile(r'^(.*?)\s+\((\d{4})\)$') 18 | 19 | __slots__ = ( 20 | 'name', 'year', 'emby_id', 'imdb_id', 'jellyfin_id', 'sonarr_id', 21 | 'tmdb_id', 'tvdb_id', 'tvrage_id', 'match_titles', 'full_name', 22 | 'match_name', 'full_match_name', 'clean_name', 'full_clean_name', 23 | ) 24 | 25 | 26 | def __init__(self, 27 | name: str, 28 | year: Optional[int] = None, 29 | *, 30 | emby_id: Optional[int] = None, 31 | imdb_id: Optional[str] = None, 32 | jellyfin_id: Optional[str] = None, 33 | sonarr_id: Optional[str] =None, 34 | tmdb_id: Optional[int] = None, 35 | tvdb_id: Optional[int] = None, 36 | tvrage_id: Optional[int] = None, 37 | match_titles: bool = True, 38 | ) -> None: 39 | """ 40 | Create a SeriesInfo object that defines a series described by 41 | all of these attributes. 42 | 43 | Args: 44 | name: Name of the series. Can be just the name, or a full 45 | name of the series and year like "name (year)". 46 | year: Year of the series. Can be omitted if a year is 47 | provided from the name. 48 | emby_id: Emby ID of the series. 49 | imdb_id: IMDb ID of the series. 50 | jellyfin_id: Jellyfin ID of the series. 51 | sonarr_id: Sonarr ID of the series. 52 | tmdb_id: TMDb ID of the series. 53 | tvdb_id: TVDb ID of the series. 54 | tvrage_id: TVRage ID of the series. 55 | match_titles: Whether to match titles when comparing 56 | episodes for this series. 57 | 58 | Raises: 59 | ValueError: If no year is provided or one cannot be 60 | determined. 61 | """ 62 | 63 | # Parse arguments into attributes 64 | self.name = name 65 | self.year = year 66 | self.emby_id = None 67 | self.imdb_id = None 68 | self.jellyfin_id = None 69 | self.sonarr_id = None 70 | self.tmdb_id = None 71 | self.tvdb_id = None 72 | self.tvrage_id = None 73 | self.match_titles = match_titles 74 | 75 | self.set_emby_id(emby_id) 76 | self.set_imdb_id(imdb_id) 77 | self.set_jellyfin_id(jellyfin_id) 78 | self.set_sonarr_id(sonarr_id) 79 | self.set_tmdb_id(tmdb_id) 80 | self.set_tvdb_id(tvdb_id) 81 | self.set_tvrage_id(tvrage_id) 82 | 83 | # If no year was specified, parse from name as "name (year)" 84 | if (self.year is None 85 | and (group := match(self.__FULL_NAME_REGEX,self.name)) is not None): 86 | self.name = group.group(1) 87 | self.year = int(group.group(2)) 88 | 89 | # If year still isn't specified, error 90 | if self.year is None: 91 | raise ValueError(f'Year not provided') 92 | 93 | self.year = int(self.year) 94 | self.update_name(self.name) 95 | 96 | 97 | def __repr__(self) -> str: 98 | """Returns an unambiguous string representation of the object.""" 99 | 100 | ret = f'' 110 | 111 | 112 | def __str__(self) -> str: 113 | """Returns a string representation of the object.""" 114 | 115 | return self.full_name 116 | 117 | 118 | @staticmethod 119 | def from_plex_show(plex_show: PlexShow) -> 'SeriesInfo': 120 | """ 121 | Create a SeriesInfo object from a plexapi Show object. 122 | 123 | Args: 124 | plex_show: Show to create an object from. Any available 125 | GUID's are utilized. 126 | 127 | Returns: 128 | SeriesInfo object encapsulating the given show. 129 | """ 130 | 131 | # Create SeriesInfo for this show 132 | series_info = SeriesInfo(plex_show.title, plex_show.year) 133 | 134 | # Add any GUIDs as database ID's 135 | for guid in plex_show.guids: 136 | if 'imdb://' in guid.id: 137 | series_info.set_imdb_id(guid.id[len('imdb://'):]) 138 | elif 'tmdb://' in guid.id: 139 | series_info.set_tmdb_id(int(guid.id[len('tmdb://'):])) 140 | elif 'tvdb://' in guid.id: 141 | series_info.set_tvdb_id(int(guid.id[len('tvdb://'):])) 142 | 143 | return series_info 144 | 145 | 146 | @property 147 | def characteristics(self) -> dict[str, Union[str, int]]: 148 | """Characteristics of this info to be used in Card creation.""" 149 | 150 | return { 151 | 'series_name': self.name, 152 | 'series_year': self.year, 153 | } 154 | 155 | 156 | @property 157 | def ids(self) -> dict[str, Union[str, int]]: 158 | """Dictionary of ID's for this object.""" 159 | 160 | return { 161 | 'emby_id': self.emby_id, 162 | 'imdb_id': self.imdb_id, 163 | 'jellyfin_id': self.jellyfin_id, 164 | 'sonarr_id': self.sonarr_id, 165 | 'tmdb_id': self.tmdb_id, 166 | 'tvdb_id': self.tvdb_id, 167 | 'tvrage_id': self.tvrage_id, 168 | } 169 | 170 | 171 | def update_name(self, name: str) -> None: 172 | """ 173 | Update all names for this series. 174 | 175 | Args: 176 | name: The new name of the series info. 177 | """ 178 | 179 | # If the given name already has the year, remove it 180 | name = str(name) 181 | if (group := match(rf'^(.*?)\s+\({self.year}\)$', name)) is not None: 182 | self.name = group.group(1) 183 | else: 184 | self.name = name 185 | 186 | # Set full name 187 | self.full_name = f'{self.name} ({self.year})' 188 | 189 | # Set match names 190 | self.match_name = self.get_matching_title(self.name) 191 | self.full_match_name = self.get_matching_title(self.full_name) 192 | 193 | # Set folder-safe name 194 | self.clean_name = CleanPath.sanitize_name(self.name) 195 | self.full_clean_name = CleanPath.sanitize_name(self.full_name) 196 | 197 | 198 | def set_emby_id(self, emby_id: int) -> None: 199 | """Set this object's Emby ID - see `_update_attribute()`.""" 200 | self._update_attribute('emby_id', emby_id, type_=int) 201 | 202 | def set_imdb_id(self, imdb_id: str) -> None: 203 | """Set this object's IMDb ID - see `_update_attribute()`.""" 204 | self._update_attribute('imdb_id', imdb_id, type_=str) 205 | 206 | def set_jellyfin_id(self, jellyfin_id: str) -> None: 207 | """Set this object's Jellyfin ID - see `_update_attribute()`.""" 208 | self._update_attribute('jellyfin_id', jellyfin_id, type_=str) 209 | 210 | def set_sonarr_id(self, sonarr_id: str) -> None: 211 | """Set this object's Sonarr ID - see `_update_attribute()`.""" 212 | self._update_attribute('sonarr_id', sonarr_id, str) 213 | 214 | def set_tmdb_id(self, tmdb_id: int) -> None: 215 | """Set this object's TMDb ID - see `_update_attribute()`.""" 216 | self._update_attribute('tmdb_id', tmdb_id, type_=int) 217 | 218 | def set_tvdb_id(self, tvdb_id: int) -> None: 219 | """Set this object's TVDb ID - see `_update_attribute()`.""" 220 | self._update_attribute('tvdb_id', tvdb_id, type_=int) 221 | 222 | def set_tvrage_id(self, tvrage_id: int) -> None: 223 | """Set this object's TVRage ID - see `_update_attribute()`.""" 224 | self._update_attribute('tvrage_id', tvrage_id, type_=int) 225 | 226 | 227 | @staticmethod 228 | def get_matching_title(text: str) -> str: 229 | """ 230 | Remove all non A-Z characters from the given title. 231 | 232 | Args: 233 | text: The title to strip of special characters. 234 | 235 | Returns: 236 | The input `text` with all non A-Z characters removed. 237 | """ 238 | 239 | return ''.join(filter(str.isalnum, text)).lower() 240 | 241 | 242 | @property 243 | def sort_name(self) -> str: 244 | """The sort-friendly name of this Series.""" 245 | 246 | return re_sub(r'^(a|an|the)(\s)', '', self.name.lower(), IGNORECASE) 247 | 248 | 249 | def matches(self, *names: tuple[str]) -> bool: 250 | """ 251 | Get whether any of the given names match this Series. 252 | 253 | Args: 254 | names: The names to check 255 | 256 | Returns: 257 | True if any of the given names match this series, False 258 | otherwise. 259 | """ 260 | 261 | matching_names = map(self.get_matching_title, names) 262 | 263 | return any(name == self.match_name for name in matching_names) 264 | -------------------------------------------------------------------------------- /modules/ShowArchive.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Callable 3 | 4 | from modules.Debug import log 5 | from modules import global_objects 6 | 7 | if TYPE_CHECKING: 8 | from modules.Show import Show 9 | 10 | 11 | class ShowArchive: 12 | """ 13 | This class describes a show archive. This is an object that contains 14 | modified Show objects, each of which is used to create an update an 15 | archive directory for a specific type of profile. Collectively every 16 | possible profile is maintained. 17 | 18 | The following profiles are created depending on whether this show 19 | has custom season titles, custom fonts, and hidden season titles: 20 | 21 | Custom | | Hidden | 22 | Season | Custom | Season | Output 23 | Title | Font | Titles | Directories 24 | -------|--------|--------|------------- 25 | 0 | 0 | 0 | Generic Season Titles, Generic Font 26 | 0 | 0 | 1 | 000 + No Season Titles, Generic Font 27 | 0 | 1 | 0 | 000 + Generic Season Titles, Custom Font 28 | 0 | 1 | 1 | 010 + No Season Titles, Custom Font 29 | 1 | 0 | 0 | 000 + Custom Season Titles, Generic Font 30 | 1 | 0 | 1 | 100 + No Season Titles, Generic Font 31 | 1 | 1 | 0 | 100 + Generic Season Titles, Custom Font 32 | 1 | 1 | 1 | 110 + No Season Titles, Custom Font 33 | """ 34 | 35 | """Map of season/font attributes and matching directory titles""" 36 | PROFILE_DIRECTORY_MAP: dict = { 37 | 'custom-custom': 'Custom Season Titles, Custom Font', 38 | 'custom-generic': 'Custom Season Titles, Generic Font', 39 | 'generic-custom': 'Generic Season Titles, Custom Font', 40 | 'generic-generic': 'Generic Season Titles, Generic Font', 41 | 'hidden-custom': 'No Season Titles, Custom Font', 42 | 'hidden-generic': 'No Season Titles, Generic Font', 43 | } 44 | 45 | __slots__ = ('series_info', 'shows', 'summaries') 46 | 47 | 48 | def __init__(self, 49 | archive_directory: Path, 50 | base_show: 'Show', 51 | ) -> None: 52 | """ 53 | Constructs a new instance of this class. Creates a list of all 54 | applicable Show objects for later us. 55 | 56 | Args: 57 | archive_directory: The base directory where this show should 58 | generate its archive. 59 | base_show: Base Show this Archive is based on. 60 | """ 61 | 62 | # If the base show for this object has archiving disabled, exit 63 | self.series_info = base_show.series_info 64 | 65 | # Empty lists to be populated with modified Show and Summary objects 66 | self.shows = [] 67 | self.summaries = [] 68 | 69 | # For each applicable sub-profile, create+modify new Show/Summary 70 | card_class = base_show.card_class 71 | valid_profiles = base_show.profile.get_valid_profiles( 72 | card_class, base_show.archive_all_variations, base_show.extras, 73 | ) 74 | 75 | # Go through each valid profile 76 | for attributes in valid_profiles: 77 | # Get directory name for this profile 78 | if base_show.archive_name is None: 79 | # Get directory base from the directory map 80 | key = f'{attributes["seasons"]}-{attributes["font"]}' 81 | profile_directory = self.PROFILE_DIRECTORY_MAP[key] 82 | 83 | # For non-standard card classes, modify profile directory name 84 | if base_show.card_class.ARCHIVE_NAME != 'standard': 85 | profile_directory+=f' - {base_show.card_class.ARCHIVE_NAME}' 86 | # Manually specified archive name 87 | else: 88 | profile_directory = base_show.archive_name 89 | 90 | # Get modified media directory within the archive directory 91 | temp_path = archive_directory /base_show.series_info.full_clean_name 92 | new_media_directory = temp_path / profile_directory 93 | 94 | # Create modified Show object for this profile 95 | new_show = base_show._make_archive(new_media_directory) 96 | 97 | # Convert this new show's profile if no manual archive name 98 | if base_show.archive_name is None: 99 | new_show.profile.convert_profile(**attributes) 100 | if not new_show.profile._Profile__use_custom_seasons: 101 | new_show.episode_text_format = \ 102 | new_show.card_class.EPISODE_TEXT_FORMAT 103 | 104 | # Override any extras 105 | new_show.profile.convert_extras(new_show.card_class,new_show.extras) 106 | 107 | # Store this new Show and associated Summary 108 | self.shows.append(new_show) 109 | self.summaries.append( 110 | global_objects.pp.summary_class( 111 | new_show, 112 | global_objects.pp.summary_background, 113 | global_objects.pp.summary_created_by, 114 | ) 115 | ) 116 | 117 | 118 | def __str__(self) -> str: 119 | """Returns a string representation of the object.""" 120 | 121 | return f'"{self.series_info.full_name}"' 122 | 123 | 124 | def __repr__(self) -> str: 125 | """Returns an unambiguous string representation of the object.""" 126 | 127 | return (f'') 129 | 130 | 131 | def __getattr__(self, show_function: Callable) -> Callable: 132 | """ 133 | Get an arbitrary function for this object. This returns a wrapped 134 | version of the given function that calls that function on all 135 | Show objects within this Archive. 136 | 137 | Args: 138 | show_function: The function to wrap. 139 | 140 | Returns: 141 | Wrapped callable that is the indicated function called on 142 | each Show object within this Archive. 143 | """ 144 | 145 | # Define wrapper that calls given function on all Shows of this object 146 | def wrapper(*args, **kwargs) -> None: 147 | # Iterate through each show and call the given function 148 | for show in self.shows: 149 | getattr(show, show_function)(*args, **kwargs) 150 | 151 | # Return "attribute" that is the wrapped function applied to all shows 152 | # within this archive 153 | return wrapper 154 | 155 | 156 | def create_summary(self) -> None: 157 | """Create the Summary image for each archive in this object.""" 158 | 159 | # Go through each Summary object within this Archive 160 | for summary in self.summaries: 161 | # If summary already exists, skip 162 | if summary.output.exists() or not summary.logo.exists(): 163 | continue 164 | 165 | # Create summary image 166 | summary.create() 167 | 168 | # If the summary exists, log that 169 | if summary.output.exists(): 170 | log.debug(f'Created Summary {summary.output.resolve()}') 171 | -------------------------------------------------------------------------------- /modules/ShowRecordKeeper.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from hashlib import sha256 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from tinydb import where 6 | 7 | from modules.BaseCardType import BaseCardType 8 | from modules.Debug import log 9 | from modules import global_objects 10 | from modules.PersistentDatabase import PersistentDatabase 11 | from modules.Version import Version 12 | 13 | if TYPE_CHECKING: 14 | from modules.Show import Show 15 | 16 | 17 | class ShowRecordKeeper: 18 | """ 19 | This class describes a show record keeper. A ShowRecordKeeper 20 | maintains a database of hashes of Show objects for comparison. 21 | Specific attributes of the Show objects are hashed such that changes 22 | to a Show object's underlying YAML between runs will be identified 23 | and result in a different hash. This can/should be used to detect 24 | when to delete and remake cards upon changes to the YAML. 25 | 26 | Hashes are stored by the series' full name and associated media 27 | directory, so changes to the media directory will result in a NEW 28 | hash, not a changed one. 29 | """ 30 | 31 | """Attributes of a Show object that should affect a shows record""" 32 | HASH_RELEVANT_ATTRIBUTES = ( 33 | 'card_class', 'episode_text_format', 'style_set.watched', 34 | '_Show__episode_map', 'title_languages', 'extras', 'font', 'profile', 35 | ) 36 | 37 | """Record database of hashes corresponding to specified shows""" 38 | RECORD_DATABASE = 'show_records.json' 39 | 40 | """Version of the existing record database""" 41 | DATABASE_VERSION = 'show_records_version.txt' 42 | 43 | 44 | def __init__(self, database_directory: Path) -> None: 45 | """ 46 | Construct a new instance. This reads the record database file if 47 | it exists, and creates if it does not. 48 | 49 | Args: 50 | database_directory: Path to read/write any databases from. 51 | """ 52 | 53 | # Read record database 54 | self.records = PersistentDatabase(self.RECORD_DATABASE) 55 | 56 | # Read version of record database 57 | version_file = database_directory / self.DATABASE_VERSION 58 | if version_file.exists(): 59 | self.version = Version(version_file.read_text()) 60 | else: 61 | self.version = global_objects.pp.version 62 | 63 | # Delete database if version does not match 64 | if self.version != global_objects.pp.version: 65 | self.records.reset() 66 | log.debug(f'Deleted show record database, was version {self.version}') 67 | 68 | # Write current version to file 69 | version_file.write_text(str(global_objects.pp.version)) 70 | 71 | # Read and log length 72 | log.info(f'Read {len(self.records)} show records') 73 | 74 | 75 | def __get_record_hash(self, hash_obj: Any, record: Any) -> None: 76 | """ 77 | Get the hash of the given record. 78 | 79 | Args: 80 | hash_obj: Initialized hash object to update with the record. 81 | record: Value to get the hash of. If this object is a 82 | subclass of the CardType abstract class, then the class 83 | name is hashed. If this object defines a custom_hash 84 | attribute, that value is hashed. Otherwise the UTF-8 85 | encoded string of this object is hashed. 86 | 87 | Returns: 88 | Nothing, the hash is applied to the given hash algorithm 89 | object. 90 | """ 91 | 92 | # For CardType classes use their class name as the hash value 93 | if isinstance(record, type) and issubclass(record, BaseCardType): 94 | record = record.__name__ 95 | # If the object defines a custom hash property/attribute, use that 96 | elif hasattr(record, 'custom_hash'): 97 | record = record.custom_hash 98 | 99 | # Hash this recrd (as a UTF-8 encoded string) 100 | hash_obj.update(str(record).encode('utf-8')) 101 | 102 | 103 | def __get_show_hash(self, show: 'Show') -> int: 104 | """ 105 | Get the hash of the given config. This hash is deterministic, 106 | and is based only on attributes of the config that visually 107 | affect a card. 108 | 109 | Args: 110 | show: Show object to hash. 111 | 112 | Returns: 113 | Integer of the (SHA256) hash of the given object. 114 | """ 115 | 116 | # Initialize the hash object to update with each attribute 117 | hash_obj = sha256() 118 | 119 | # Hash each relevant attribute of the Show object 120 | for attr in self.HASH_RELEVANT_ATTRIBUTES: 121 | # If a nested attribute, iterate through objects 122 | if '.' in attr: 123 | subs = attr.split('.') 124 | obj = getattr(show, subs[0]) 125 | for sub_attr in subs[1:]: 126 | obj = getattr(obj, sub_attr) 127 | self.__get_record_hash(hash_obj, obj) 128 | # Singular attribute, get directly from show object 129 | else: 130 | self.__get_record_hash(hash_obj, getattr(show, attr)) 131 | 132 | # Return the hash as an integer 133 | return int.from_bytes(hash_obj.digest(), 'big') 134 | 135 | 136 | def is_updated(self, show: 'Show') -> bool: 137 | """ 138 | Determine whether the given show is an update on the recorded 139 | config. 140 | 141 | Args: 142 | show: Show object being evaluated. 143 | 144 | Returns: 145 | True if the given show is different from the recorded show. 146 | False if the show is identical OR if there is no existing 147 | record. 148 | """ 149 | 150 | # Condition to get the hash of this show 151 | condition = ( 152 | (where('series') == show.series_info.full_name) & 153 | (where('directory') == str(show.media_directory.resolve())) 154 | ) 155 | 156 | # If this show has an existing hash, check for equality 157 | if self.records.contains(condition): 158 | existing_hash = self.records.get(condition)['hash'] 159 | new_hash = self.__get_show_hash(show) 160 | 161 | return existing_hash != new_hash 162 | 163 | # No existing hash 164 | return False 165 | 166 | 167 | def add_config(self, show: 'Show') -> None: 168 | """ 169 | Add the given show's hash to this object's record database. 170 | 171 | Args: 172 | show: Show object being evaluated. 173 | """ 174 | 175 | # Condition to get the hash of this show 176 | condition = ( 177 | (where('series') == show.series_info.full_name) & 178 | (where('directory') == str(show.media_directory.resolve())) 179 | ) 180 | 181 | # Either insert or update hash of this show 182 | self.records.upsert({ 183 | 'series': show.series_info.full_name, 184 | 'directory': str(show.media_directory.resolve()), 185 | 'hash': self.__get_show_hash(show), 186 | }, condition) 187 | -------------------------------------------------------------------------------- /modules/StyleSet.py: -------------------------------------------------------------------------------- 1 | from modules.Debug import log 2 | 3 | 4 | class StyleSet: 5 | """ 6 | Set of watched and unwatched styles. 7 | """ 8 | 9 | """Default spoil type for all episodes without explicit watch statuses""" 10 | DEFAULT_SPOIL_TYPE = 'spoiled' 11 | 12 | """Mapping of style values to spoil types for Episode objects""" 13 | SPOIL_TYPE_STYLE_MAP = { 14 | 'art': 'art', 15 | 'art blur': 'art blur', 16 | 'art blur grayscale': 'art blur grayscale', 17 | 'art grayscale': 'art grayscale', 18 | # 'art unique': # INVALID COMBINATION 19 | 'blur': 'blur', 20 | 'blur grayscale': 'blur grayscale', 21 | 'blur unique': 'blur', 22 | 'blur grayscale unique': 'blur grayscale', 23 | 'grayscale': 'grayscale', 24 | 'grayscale unique': 'grayscale', 25 | 'unique': 'spoiled', 26 | } 27 | 28 | __slots__ = ('__kwargs', 'valid', 'watched', 'unwatched') 29 | 30 | 31 | def __init__(self, 32 | watched: str = 'unique', 33 | unwatched: str = 'unique' 34 | ) -> None: 35 | """ 36 | Initialize this object with the given watched/unwatched styles. 37 | Also updates the validity of this object. 38 | 39 | Args: 40 | watched: Watched style. Defaults to 'unique'. 41 | unwatched: Unwatched style. Defaults to 'unique'. 42 | """ 43 | 44 | # Start as valid 45 | self.__kwargs = {'watched': watched, 'unwatched': unwatched} 46 | self.valid = True 47 | 48 | # Parse each style 49 | self.update_watched_style(watched) 50 | self.update_unwatched_style(unwatched) 51 | 52 | 53 | def __repr__(self) -> str: 54 | """Return an unambigious string representation of the object.""" 55 | 56 | return f'' 57 | 58 | 59 | def __copy__(self) -> 'StyleSet': 60 | """Copy this objects' styles into a new StyleSet object.""" 61 | 62 | return StyleSet(**self.__kwargs) 63 | 64 | 65 | @staticmethod 66 | def __standardize(style: str) -> str: 67 | """ 68 | Standardize the given style string so that "unique blur", "blur 69 | unique" evaluate to the same style. 70 | 71 | Args: 72 | style: Style string (from YAML) being standardized. 73 | 74 | Returns: 75 | Standardized value. This is lowercase with spaces removed, 76 | and sorted alphabetically. 77 | """ 78 | 79 | return ' '.join(sorted(str(style).lower().strip().split(' '))) 80 | 81 | 82 | def update_watched_style(self, style: str) -> None: 83 | """ 84 | Set the watched style for this set. 85 | 86 | Args: 87 | style: Style to set. 88 | """ 89 | 90 | if (value := self.__standardize(style)) in self.SPOIL_TYPE_STYLE_MAP: 91 | self.watched = value 92 | else: 93 | log.error(f'Invalid style "{style}"') 94 | self.valid = False 95 | 96 | 97 | def update_unwatched_style(self, style: str) -> None: 98 | """ 99 | Set the unwatched style for this set. 100 | 101 | Args: 102 | style: Style to set. 103 | """ 104 | 105 | if (value := self.__standardize(style)) in self.SPOIL_TYPE_STYLE_MAP: 106 | self.unwatched = value 107 | else: 108 | log.error(f'Invalid style "{style}"') 109 | self.valid = False 110 | 111 | 112 | @property 113 | def watched_style_is_art(self) -> bool: 114 | """Whether the watched style is an art style.""" 115 | 116 | return 'art' in self.watched 117 | 118 | @property 119 | def unwatched_style_is_art(self) -> bool: 120 | """Whether the unwatched style is an art style.""" 121 | 122 | return 'art' in self.unwatched 123 | 124 | 125 | # pylint: disable=missing-function-docstring 126 | def effective_style_is_art(self, watch_status: bool) -> bool: 127 | return 'art' in (self.watched if watch_status else self.unwatched) 128 | 129 | def effective_style_is_blur(self, watch_status: bool) -> bool: 130 | return 'blur' in (self.watched if watch_status else self.unwatched) 131 | 132 | def effective_style_is_grayscale(self, watch_status: bool) -> bool: 133 | return 'grayscale' in (self.watched if watch_status else self.unwatched) 134 | 135 | def effective_style_is_unique(self, watch_status: bool) -> bool: 136 | return 'unqiue' == (self.watched if watch_status else self.unwatched) 137 | 138 | def effective_spoil_type(self, watch_status: bool) -> str: 139 | return self.SPOIL_TYPE_STYLE_MAP[ 140 | self.watched if watch_status else self.unwatched 141 | ] 142 | -------------------------------------------------------------------------------- /modules/StylizedSummary.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from modules.Debug import log 5 | from modules.BaseSummary import BaseSummary 6 | 7 | if TYPE_CHECKING: 8 | from modules.Show import Show 9 | 10 | 11 | class StylizedSummary(BaseSummary): 12 | """ 13 | This class describes a type of Summary image maker. This is a more 14 | stylized summary (compared to the StandardSummary) that uses a (max) 15 | 4x3 grid of images, and creates a reflection of that grid. There is 16 | a logo at the top as well. 17 | 18 | This type of Summary does not support any background aside from 19 | black. 20 | """ 21 | 22 | """Default (and only allowed) background color for this Summary""" 23 | BACKGROUND_COLOR = 'black' 24 | 25 | """Paths to intermediate images created by this object""" 26 | __MONTAGE_PATH = BaseSummary.TEMP_DIR / 'montage.png' 27 | __RESIZED_LOGO_PATH = BaseSummary.TEMP_DIR / 'resized_logo.png' 28 | 29 | 30 | def __init__(self, 31 | show: 'Show', 32 | background: str = BACKGROUND_COLOR, # pylint: disable=unused-argument 33 | created_by: Optional[str] = None 34 | ) -> None: 35 | """ 36 | Construct a new instance of this object. 37 | 38 | Args: 39 | show: Show object to create the Summary for. 40 | background: Background color or image to use for the 41 | summary. This is ignored and 'black' is always used. 42 | created_by: Optional string to use in custom "Created by .." 43 | tag at the botom of this Summary. Defaults to None. 44 | """ 45 | 46 | # Initialize parent BaseSummary object 47 | super().__init__(show, created_by) 48 | 49 | 50 | def __create_montage(self) -> Path: 51 | """ 52 | Create a montage of input images. 53 | 54 | Returns: 55 | Path to the created image. 56 | """ 57 | 58 | command = ' '.join([ 59 | f'montage', 60 | f'-set colorspace sRGB', 61 | f'-background "{self.BACKGROUND_COLOR}"', 62 | f'-tile 3x{self.number_rows}', 63 | f'-geometry 800x450\>+5+5', 64 | f'"'+'" "'.join(self.inputs)+'"', 65 | f'"{self.__MONTAGE_PATH.resolve()}"', 66 | ]) 67 | 68 | self.image_magick.run(command) 69 | 70 | return self.__MONTAGE_PATH 71 | 72 | 73 | def __resize_logo(self, max_width: int) -> Path: 74 | """ 75 | Resize this associated show's logo to fit into at least a 350 76 | pixel high space. If the resulting logo is wider than the given 77 | width, it is scaled. 78 | 79 | Returns: 80 | Path to the resized logo. 81 | """ 82 | 83 | command = ' '.join([ 84 | f'convert', 85 | f'"{self.logo.resolve()}"', 86 | f'-resize x350', 87 | f'-resize {max_width}x350\>', 88 | f'"{self.__RESIZED_LOGO_PATH.resolve()}"', 89 | ]) 90 | 91 | self.image_magick.run(command) 92 | 93 | return self.__RESIZED_LOGO_PATH 94 | 95 | 96 | def create(self) -> None: 97 | """ 98 | Create the Summary defined by this object. Image selection is 99 | done at the start of this function. 100 | """ 101 | 102 | # Exit if a logo does not exist 103 | if not self.logo.exists(): 104 | log.warning('Cannot create Summary - no logo found') 105 | return None 106 | 107 | # Select images for montaging 108 | if not self._select_images(12) or len(self.inputs) == 0: 109 | return None 110 | 111 | # Create montage 112 | montage = self.__create_montage() 113 | 114 | # Get dimensions of montage 115 | width, height = self.image_magick.get_image_dimensions(montage) 116 | 117 | # Resize logo 118 | resized_logo = self.__resize_logo(width) 119 | 120 | # Get dimension of logo 121 | _, logo_height = self.image_magick.get_image_dimensions(resized_logo) 122 | 123 | # Get/create created by tag 124 | if self.created_by is None: 125 | created_by = self._CREATED_BY_PATH 126 | else: 127 | created_by = self._create_created_by(self.created_by) 128 | 129 | command = ' '.join([ 130 | f'convert "{montage.resolve()}"', 131 | # Create reflection of montage 132 | f'\( +clone', 133 | f'-flip', 134 | # Blur reflection 135 | f'-blur 0x8', 136 | # Darken reflection 137 | f'-fill black', 138 | f'-colorize 75% \)', 139 | f'-append', 140 | # Create colored background 141 | f'-size {width+200}x{height+700}', 142 | f'xc:"{self.BACKGROUND_COLOR}"', 143 | # Reverse reflection/montage(s) 144 | f'+swap', 145 | # Put montage+reflection on background 146 | f'-gravity north', 147 | f'-geometry +0+400', 148 | f'-composite', 149 | # Overlay created by image 150 | f'\( "{created_by.resolve()}"', 151 | f'-resize x75', 152 | # Create reflection of created by image 153 | f'\( +clone', 154 | f'-flip', 155 | f'-blur 0x2', 156 | # Darken reflection of created by image 157 | f'-fill black', 158 | f'-colorize 75% \)', 159 | f'-append \)', 160 | f'-gravity south', 161 | f'-geometry +0+50', 162 | f'-composite', 163 | # Overlay resized logo 164 | f'-gravity north', 165 | f'"{resized_logo.resolve()}"', 166 | f'-geometry +0+{400//2-logo_height//2}', 167 | f'-composite', 168 | f'"{self.output.resolve()}"', 169 | ]) 170 | 171 | self.image_magick.run(command) 172 | 173 | # Delete intermediate images 174 | if self.created_by is None: 175 | images = [montage] 176 | else: 177 | images = [montage, created_by] 178 | self.image_magick.delete_intermediate_images(*images) 179 | return None 180 | -------------------------------------------------------------------------------- /modules/SyncInterface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class SyncInterface(ABC): 6 | """ 7 | This class describes an abstract SyncInterface. This is some 8 | Interface which can be synced (e.g. series can be grabbed) from. 9 | """ 10 | 11 | 12 | def get_library_paths(self, 13 | filter_libraries: list[str] = [], # pylint: disable=unused-argument 14 | ) -> dict[str, list[str]]: 15 | """ 16 | Get all libraries and their associated base directories. 17 | 18 | Args: 19 | filter_libraries: List of library names to filter the return 20 | by. 21 | 22 | Returns: 23 | Dictionary whose keys are the library names, and whose 24 | values are the list of paths to that library's base 25 | directories. 26 | """ 27 | 28 | return {} 29 | 30 | 31 | @abstractmethod 32 | def get_all_series(self) -> Any: 33 | """Abstract method to get all series within this Interface.""" 34 | 35 | raise NotImplementedError('All SyncInterfaces must implement this') 36 | -------------------------------------------------------------------------------- /modules/TautulliInterface.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | from pathlib import Path 3 | from sys import exit as sys_exit 4 | from typing import Optional 5 | 6 | from modules.Debug import log 7 | from modules.WebInterface import WebInterface 8 | 9 | 10 | class TautulliInterface(WebInterface): 11 | """ 12 | This class describes an interface to Tautulli. This interface can 13 | configure notification agents within Tautulli to enable fast card 14 | updating/creation. 15 | """ 16 | 17 | """Default configurations for the notification agent(s)""" 18 | DEFAULT_AGENT_NAME = 'Update TitleCardMaker' 19 | DEFAULT_SCRIPT_TIMEOUT = 30 20 | 21 | """Agent ID for a custom Script""" 22 | AGENT_ID = 15 23 | 24 | 25 | def __init__(self, 26 | url: str, 27 | api_key: str, 28 | verify_ssl: bool, 29 | update_script: Path, 30 | agent_name: str = DEFAULT_AGENT_NAME, 31 | script_timeout: int = DEFAULT_SCRIPT_TIMEOUT, 32 | username: Optional[str] = None 33 | ) -> None: 34 | """ 35 | Construct a new instance of an interface to Sonarr. 36 | 37 | Args: 38 | url: The API url communicating with Tautulli. 39 | api_key: The API key for API requests. 40 | verify_ssl: Whether to verify SSL requests. 41 | 42 | Raises: 43 | SystemExit: Invalid Sonarr URL/API key provided. 44 | """ 45 | 46 | # Initialize parent WebInterface 47 | super().__init__('Tautulli', verify_ssl, cache=False) 48 | 49 | # Get correct URL 50 | url = url if url.endswith('/') else f'{url}/' 51 | if url.endswith('/api/v2/'): 52 | self.url = url 53 | elif (re_match := self._URL_REGEX.match(url)) is None: 54 | log.critical(f'Invalid Tautulli URL "{url}"') 55 | sys_exit(1) 56 | else: 57 | self.url = f'{re_match.group(1)}/api/v2/' 58 | 59 | # Base parameters for sending requests to Sonarr 60 | self.__params = {'apikey': api_key} 61 | 62 | # Query system status to verify connection to Tautulli 63 | try: 64 | status = self.get(self.url, self.__params | {'cmd': 'status'}) 65 | if status.get('response', {}).get('result') != 'success': 66 | log.critical(f'Cannot get Tautulli status - invalid URL/API key') 67 | sys_exit(1) 68 | except Exception as e: 69 | log.critical(f'Cannot connect to Tautulli - returned error: "{e}"') 70 | sys_exit(1) 71 | 72 | # Store attributes 73 | self.update_script = update_script 74 | self.agent_name = agent_name 75 | self.script_timeout = script_timeout 76 | self.username = username 77 | 78 | # Warn if invalid timeout was provided 79 | if self.script_timeout < 0: 80 | log.error(f'Script timeout must be >= 0 (seconds) - using 0') 81 | self.script_timeout = 0 82 | 83 | 84 | def is_integrated(self) -> tuple[bool, bool]: 85 | """ 86 | Check if this interface's Tautulli instance already has 87 | integration set up. 88 | 89 | Returns: 90 | Tuple of booleans. First value is True if the watched agent 91 | is already integrated (False otherwise); second value is 92 | True if the newly added agent is already integrated (False 93 | otherwise). 94 | """ 95 | 96 | # Get all notifiers 97 | response = self.get(self.url, self.__params | {'cmd': 'get_notifiers'}) 98 | notifiers = response['response']['data'] 99 | 100 | # Check each agent's name 101 | watched_integrated, created_integrated = False, False 102 | for agent in notifiers: 103 | # Exit loop if both agents found 104 | if watched_integrated and created_integrated: 105 | break 106 | 107 | # If agent is a Script with the right name.. 108 | if (agent['agent_label'] == 'Script' 109 | and agent['friendly_name'].startswith(self.agent_name)): 110 | # Get the config of this agent, check action flags 111 | params = self.__params | {'cmd': 'get_notifier_config', 112 | 'notifier_id': agent['id']} 113 | response = self.get(self.url, params)['response']['data'] 114 | if response['actions']['on_watched'] == 1: 115 | watched_integrated = True 116 | if response['actions']['on_created'] == 1: 117 | created_integrated = True 118 | 119 | return watched_integrated, created_integrated 120 | 121 | 122 | def __create_agent(self) -> Optional[int]: 123 | """ 124 | Create a new Notification Agent. 125 | 126 | Returns: 127 | Notifier ID of created agent, None if agent was not created. 128 | """ 129 | 130 | # Get all existing notifier ID's 131 | response = self.get(self.url, self.__params | {'cmd': 'get_notifiers'}) 132 | existing_ids = {agent['id'] for agent in response['response']['data']} 133 | 134 | # Create new notifier 135 | params = {'cmd': 'add_notifier_config', 'agent_id': self.AGENT_ID} 136 | self.get(self.url, self.__params | params) 137 | 138 | # Get notifier ID's after adding new one 139 | response = self.get(self.url, self.__params | {'cmd': 'get_notifiers'}) 140 | new_ids = {agent['id'] for agent in response['response']['data']} 141 | 142 | # If no new ID's are returned 143 | if len(new_ids - existing_ids) == 0: 144 | log.error(f'Failed to create new notification agent on Tautulli') 145 | return None 146 | 147 | # Get ID of created notifier 148 | return list(new_ids - existing_ids)[0] 149 | 150 | 151 | def integrate(self) -> None: 152 | """ 153 | Integrate this interface's instance of Tautulli with TCM. This 154 | configures a new notification agent if a valid one does not 155 | exist or cannot be identified. 156 | """ 157 | 158 | # If already integrated, skip 159 | watched_integrated, created_integrated = self.is_integrated() 160 | if watched_integrated and created_integrated: 161 | log.debug('Tautulli integrated detected') 162 | return None 163 | 164 | # Integrate watched agent if required 165 | if (not watched_integrated 166 | and (watched_id := self.__create_agent()) is not None): 167 | # Conditions for watched agent 168 | # Always add condition for the episode 169 | conditions = [{ 170 | 'parameter': 'media_type', 171 | 'operator': 'is', 172 | 'value': ['episode'], 173 | 'type': 'str', 174 | }] 175 | # If provided, add condition for username 176 | if self.username is not None: 177 | conditions.append({ 178 | 'parameter': 'username', 179 | 'operator': 'is', 180 | 'value': [self.username], 181 | 'type': 'str', 182 | }) 183 | 184 | # Configure this agent 185 | friendly_name = f'{self.agent_name} - Watched' 186 | params = self.__params | { 187 | # API arguments 188 | 'cmd': 'set_notifier_config', 189 | 'notifier_id': watched_id, 190 | 'agent_id': self.AGENT_ID, 191 | # Configuration 192 | 'friendly_name': friendly_name, 193 | 'scripts_script_folder': str(self.update_script.parent.resolve()), 194 | 'scripts_script': str(self.update_script.resolve()), 195 | 'scripts_timeout': self.script_timeout, 196 | # Triggers 197 | 'on_watched': 1, 198 | # Conditions 199 | 'custom_conditions': dumps(conditions), 200 | # Arguments 201 | 'on_watched_subject': '{rating_key}', 202 | } 203 | self.get(self.url, params) 204 | log.info(f'Creatd and configured Tautulli notification agent ' 205 | f'{watched_id} ("{friendly_name}")') 206 | 207 | # Integrate created agent if required 208 | if (not created_integrated 209 | and (created_id := self.__create_agent()) is not None): 210 | # Conditions for new content is just a show/season/episode 211 | conditions = [{ 212 | 'parameter': 'media_type', 213 | 'operator': 'is', 214 | 'value': ['show', 'season', 'episode'], 215 | 'type': 'str', 216 | }] 217 | 218 | # Configure this agent 219 | friendly_name = f'{self.agent_name} - Recently Added' 220 | params = self.__params | { 221 | # API arguments 222 | 'cmd': 'set_notifier_config', 223 | 'notifier_id': created_id, 224 | 'agent_id': self.AGENT_ID, 225 | # Configuration 226 | 'friendly_name': friendly_name, 227 | 'scripts_script_folder': str(self.update_script.parent.resolve()), 228 | 'scripts_script': str(self.update_script.resolve()), 229 | 'scripts_timeout': self.script_timeout, 230 | # Triggers 231 | 'on_created': 1, 232 | # Conditions 233 | 'custom_conditions': dumps(conditions), 234 | # Arguments 235 | 'on_created_subject': '{rating_key}', 236 | } 237 | self.get(self.url, params) 238 | log.info(f'Created and configured Tautulli notification agent ' 239 | f'{created_id} ("{friendly_name}")') 240 | 241 | return None 242 | -------------------------------------------------------------------------------- /modules/Version.py: -------------------------------------------------------------------------------- 1 | from re import compile as re_compile 2 | 3 | 4 | class Version: 5 | """ 6 | A class describing some semantic version number. This class parses 7 | version strings particular to TCM and stores the parsed branch and 8 | version number(s) as attributes. Allows for comparison of Versions. 9 | 10 | For example: 11 | >>> v1 = Version('v1.14.1') 12 | >>> v2 = Version('v1.14.2') 13 | >>> v1 < v2, v1 == v2, v1 > v2 14 | (True, False, False) 15 | 16 | This also works for development-branch tagged versions, such as: 17 | >>> v3 = Version('v2.0-alpha3.0-webui10') 18 | >>> v4 = Version('v2.0-alpha3.0-webui3') 19 | >>> v3 < v4, v3 == v4, v3 > v4 20 | (False, False, True) 21 | """ 22 | 23 | PRIMARY_REGEX = re_compile( 24 | r'^v(?P\d+)\.(?P\d+)\.(?P\d+)' 25 | r'(?:-(?P\D+)(?P\d+))?$' 26 | ) 27 | PRIMARY_DEFAULTS = {'branch': 'master', 'branch_iteration': 0} 28 | 29 | WEB_UI_REGEX = re_compile( 30 | r'^.*-alpha\.(?P\d+)\.(?P\d+)' 31 | r'(\.(?P\d+))?(?:-(?P\D+)' 32 | r'(?P\d+))?$' 33 | ) 34 | WEB_UI_DEFAULTS = { 35 | 'branch': 'master', 'sub_sub_version': 0, 'branch_iteration': 0 36 | } 37 | 38 | 39 | def __init__(self, /, version_str: str) -> None: 40 | """ 41 | Initialize a Version object with the given version string. 42 | 43 | Args: 44 | version_str: Version string to parse for Version info. 45 | 46 | Raises: 47 | ValueError: If the given `version_str` cannot be parsed. 48 | """ 49 | 50 | # Store raw version string 51 | self.version_str = version_str 52 | 53 | # Extract version data from regex, merging defaults 54 | if (data_match := self.PRIMARY_REGEX.match(version_str)) is not None: 55 | version_data = self.PRIMARY_DEFAULTS | data_match.groupdict() 56 | elif (data_match := self.WEB_UI_REGEX.match(version_str)) is not None: 57 | version_data = self.WEB_UI_DEFAULTS | data_match.groupdict() 58 | if version_data['sub_sub_version'] is None: 59 | version_data['sub_sub_version'] = 0 60 | else: 61 | raise ValueError(f'Cannot identify version from {version_str}') 62 | 63 | # Initialize unparsed attributes 64 | version_data['branch'] = version_data['branch'] or 'master' 65 | if version_data['branch_iteration'] is None: 66 | version_data['branch_iteration'] = 0 67 | 68 | # Store branch and version(s) 69 | self.branch: str = version_data['branch'] 70 | self.version: tuple[int] = ( 71 | int(version_data['version']), 72 | int(version_data['sub_version']), 73 | int(version_data['sub_sub_version']), 74 | int(version_data['branch_iteration']), 75 | ) 76 | 77 | 78 | def __repr__(self) -> str: 79 | """Get an unambigious string representation of the object.""" 80 | 81 | return f'' 82 | 83 | 84 | def __str__(self) -> str: 85 | """Get a printable string representation of this object.""" 86 | 87 | # Master branch, omit branch and iteration 88 | if self.branch == 'master': 89 | return ( 90 | f'v{self.version[0]}.{self.sub_version}.{self.sub_sub_version}' 91 | ) 92 | 93 | return ( 94 | f'v{self.version[0]}.{self.sub_version}.{self.sub_sub_version}' 95 | f'-{self.branch}{self.branch_iteration}' 96 | ) 97 | 98 | 99 | def __eq__(self, other: 'Version') -> bool: 100 | """ 101 | Determine whether two Version objects are identical. 102 | 103 | Args: 104 | other: Version object to compare against. 105 | 106 | Returns: 107 | True if the two objects have the same branch and version 108 | data. 109 | """ 110 | 111 | if not isinstance(other, Version): 112 | raise TypeError(f'Can only compare Version objects') 113 | 114 | return self.version == other.version and self.branch == other.branch 115 | 116 | 117 | def __gt__(self, other: 'Version') -> bool: 118 | """ 119 | Determine whether this object is a newer version than the other. 120 | 121 | Args: 122 | other: Version object to compare against. 123 | 124 | Returns: 125 | True if this object represents a newer version than the 126 | other object. Otherwise False. 127 | """ 128 | 129 | if not isinstance(other, Version): 130 | raise TypeError(f'Can only compare Version objects') 131 | 132 | # Compare each like-version 133 | for this_v, other_v in zip(self.version, other.version): 134 | # Equal, skip 135 | if this_v == other_v: 136 | continue 137 | 138 | # This version is higher than other, always gt 139 | if this_v > other_v: 140 | return True 141 | 142 | # This version is lower than other, always lt 143 | if this_v < other_v: 144 | return False 145 | 146 | # Equal, not gt 147 | return False 148 | 149 | 150 | def __lt__(self, other: 'Version') -> bool: 151 | """ 152 | Determine whether this object is an older version than the 153 | other. 154 | 155 | Args: 156 | other: Version object to compare against. 157 | 158 | Returns: 159 | True if this object represents an older version than the 160 | other object. Otherwise False. 161 | 162 | Raises: 163 | TypeError if `other` is not a `Version` object. 164 | """ 165 | 166 | if not isinstance(other, Version): 167 | raise TypeError(f'Can only compare Version objects') 168 | 169 | # Compare each like-version 170 | for this_v, other_v in zip(self.version, other.version): 171 | # Equal, skip 172 | if this_v == other_v: 173 | continue 174 | 175 | # This version is lower than other, always lt 176 | if this_v < other_v: 177 | return True 178 | 179 | # This version is higher than other, always gt 180 | if this_v > other_v: 181 | return False 182 | 183 | # Equal, not lt 184 | return False 185 | 186 | 187 | @property 188 | def sub_version(self) -> int: 189 | """Subversion of this object - i.e. 1.{x}.3""" 190 | 191 | return self.version[1] 192 | 193 | 194 | @property 195 | def sub_sub_version(self) -> int: 196 | """Subversion of this object - i.e. 1.2.{x}""" 197 | 198 | return self.version[2] 199 | 200 | 201 | @property 202 | def branch_iteration(self) -> int: 203 | """Subversion of this object - i.e. 1.2.3-branch{x}""" 204 | 205 | return self.version[3] 206 | -------------------------------------------------------------------------------- /modules/WebInterface.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Union 3 | 4 | from re import IGNORECASE, compile as re_compile 5 | from requests import get, Session 6 | from tenacity import retry, stop_after_attempt, wait_fixed, wait_exponential 7 | import urllib3 8 | 9 | from modules.Debug import log 10 | 11 | 12 | class WebInterface: 13 | """ 14 | This class defines a WebInterface, which is a type of interface that 15 | makes requests using some persistent session and returns JSON 16 | results. This object caches requests/results for better performance. 17 | """ 18 | 19 | """Maximum time allowed for a single GET request""" 20 | REQUEST_TIMEOUT = 15 21 | 22 | """How many requests to cache""" 23 | CACHE_LENGTH = 10 24 | 25 | """Regex to match URL's""" 26 | _URL_REGEX = re_compile(r'^((?:https?:\/\/)?.+)(?=\/)', IGNORECASE) 27 | 28 | """Content to ignore if returned by any GET request""" 29 | BAD_CONTENT = ( 30 | b'', 31 | b'<Code>AccessDenied</Code>', 32 | ) 33 | 34 | 35 | def __init__(self, 36 | name: str, 37 | verify_ssl: bool = True, 38 | *, 39 | cache: bool = True, 40 | ) -> None: 41 | """ 42 | Construct a new instance of a WebInterface. This creates creates 43 | cached request and results lists, and establishes a session for 44 | future use. 45 | 46 | Args: 47 | name: Name (for logging) of this interface. 48 | verify_ssl: Whether to verify SSL requests with this 49 | interface. 50 | cache: Whether to cache requests with this interface. 51 | log: Logger for all log messages. 52 | """ 53 | 54 | # Store name of this interface 55 | self.name = name 56 | 57 | # Create session for persistent requests 58 | self.session = Session() 59 | 60 | # Whether to verify SSL 61 | self.session.verify = verify_ssl 62 | if not self.session.verify: 63 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 64 | log.debug(f'Not verifying SSL connections for {name}') 65 | 66 | # Cache of the last requests to speed up identical sequential requests 67 | self.__do_cache = cache 68 | self.__cache = [] 69 | self.__cached_results = [] 70 | 71 | 72 | def __repr__(self) -> str: 73 | """Returns an unambiguous string representation of the object.""" 74 | 75 | return f'<WebInterface to {self.name}>' 76 | 77 | 78 | @retry(stop=stop_after_attempt(5), 79 | wait=wait_fixed(5)+wait_exponential(min=1, max=16), 80 | before_sleep=lambda _:log.warning('Failed to submit GET request, retrying..'), 81 | reraise=True) 82 | def __retry_get(self, url: str, params: dict) -> dict: 83 | """ 84 | Retry the given GET request until successful (or really fails). 85 | 86 | Args: 87 | url: The URL of the GET request. 88 | params: The params of the GET request. 89 | 90 | Returns: 91 | Dict made from the JSON return of the specified GET request. 92 | """ 93 | 94 | return self.session.get( 95 | url=url, 96 | params=params, 97 | timeout=self.REQUEST_TIMEOUT 98 | ).json() 99 | 100 | 101 | def get(self, url: str, params: dict, *, cache: bool = True) -> Any: 102 | """ 103 | Wrapper for getting the JSON return of the specified GET 104 | request. If the provided URL and parameters are identical to the 105 | previous request, then a cached result is returned instead (if 106 | enabled). 107 | 108 | Args: 109 | url: URL to pass to GET. 110 | Parameters to pass to GET. 111 | 112 | Returns: 113 | Parsed JSON return of the specified GET request. 114 | """ 115 | 116 | # If not caching, just query and return 117 | if not self.__do_cache: 118 | return self.__retry_get(url=url, params=params) 119 | 120 | # Look through all cached results for this exact URL+params; if found, 121 | # skip the request and return that result 122 | for cache, result in zip(self.__cache, self.__cached_results): 123 | if cache['url'] == url and cache['params'] == str(params): 124 | return result 125 | 126 | # Make new request, add to cache 127 | self.__cached_results.append(self.__retry_get(url=url, params=params)) 128 | self.__cache.append({'url': url, 'params': str(params)}) 129 | 130 | # Delete element from cache if length has been exceeded 131 | if len(self.__cache) > self.CACHE_LENGTH: 132 | self.__cache.pop(0) 133 | self.__cached_results.pop(0) 134 | 135 | # Return latest result 136 | return self.__cached_results[-1] 137 | 138 | 139 | @staticmethod 140 | def download_image(image: Union[str, bytes], destination: Path) -> bool: 141 | """ 142 | Download the provided image to the destination filepath. 143 | 144 | Args: 145 | image: URL to the image to download, or bytes of the image 146 | to write. 147 | destination: Destination path to download the image to. 148 | 149 | Returns: 150 | Whether the image was successfully downloaded. 151 | """ 152 | 153 | # Make parent folder structure 154 | destination.parent.mkdir(parents=True, exist_ok=True) 155 | 156 | # If content of image, just write directly to file 157 | if isinstance(image, bytes): 158 | destination.write_bytes(image) 159 | return True 160 | 161 | # Attempt to download the image, if an error happens log to user 162 | try: 163 | # Get content from URL 164 | image = get(image, timeout=30).content 165 | if len(image) == 0: 166 | raise ValueError(f'URL {image} returned no content error') 167 | if any(bad_content in image for bad_content in WebInterface.BAD_CONTENT): 168 | raise ValueError(f'URL {image} returned malformed content') 169 | 170 | # Write content to file, return success 171 | destination.write_bytes(image) 172 | return True 173 | except Exception: # pylint: disable=broad-except 174 | log.exception(f'Cannot download image, returned error') 175 | return False 176 | -------------------------------------------------------------------------------- /modules/YamlReader.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from sys import exit as sys_exit 3 | from typing import Any, Callable, Optional, TypeVar 4 | 5 | from yaml import safe_load 6 | from modules.BaseCardType import BaseCardType 7 | 8 | from modules.Debug import log 9 | from modules.RemoteCardType import RemoteCardType 10 | from modules.TitleCard import TitleCard 11 | 12 | 13 | _AttributeType = TypeVar('_AttributeType') 14 | 15 | 16 | class YamlReader: 17 | """ 18 | This class describes an object capable of reading and parsing YAML. 19 | """ 20 | 21 | __slots__ = ('card_class', '_base_yaml', 'valid', '__log') 22 | 23 | 24 | def __init__(self, 25 | yaml: dict = {}, 26 | *, 27 | log_function: Callable[[str], None] = log.error 28 | ) -> None: 29 | """ 30 | Initialize this object. 31 | 32 | Args: 33 | yaml: Base YAML to read. 34 | log_function: Function to call and log with for any YAML 35 | read failures. Defaults to log.error. 36 | """ 37 | 38 | self._base_yaml = yaml 39 | self.valid = True 40 | self.__log = log_function 41 | 42 | # Verify base YAML is a dictionary 43 | if not isinstance(yaml, dict): 44 | self.__log(f'Specified YAML is invalid') 45 | self.valid = False 46 | 47 | 48 | @staticmethod 49 | def TYPE_LOWER_STR(value: Any) -> str: 50 | """ 51 | Function for getting the lowercase, stripped equivalent of a 52 | string. 53 | """ 54 | 55 | return str(value).lower().strip() 56 | 57 | 58 | def get(self, 59 | *attributes: str, 60 | type_: Optional[Callable[[str], _AttributeType]] = None, 61 | default: Any = None, 62 | ) -> Optional[_AttributeType]: 63 | """ 64 | Get the value specified by the given attributes/sub-attributes 65 | of YAML, optionally converting to the given type. Log invalidity 66 | and return None if value is either unspecified or cannot be 67 | converted to the type. 68 | 69 | Args: 70 | attributes: Any number of nested attributes to get value of. 71 | type_: Optional function to call on specified value before 72 | returning. 73 | default: Default value to return if unspecified. 74 | 75 | Returns: 76 | Value located at the given attribute specification, value of 77 | default if DNE or cannot be converted to given type. 78 | """ 79 | 80 | # If the value is specified 81 | if self._is_specified(*attributes): 82 | value = self._base_yaml 83 | for attrib in attributes: 84 | value = value[attrib] 85 | 86 | # If no type conversion is indicated, just return value 87 | if type_ is None: 88 | return value 89 | 90 | try: 91 | # Attempt type conversion 92 | return type_(value) 93 | except Exception as e: 94 | # Type conversion failed, log, set invalid, return None 95 | attrib_string = '", "'.join(attributes) 96 | self.__log(f'Value of "{attrib_string}" is invalid - {e}') 97 | self.valid = False 98 | 99 | return default 100 | else: 101 | # No value specified, return None 102 | return default 103 | 104 | 105 | def _is_specified(self, *attributes: str) -> bool: 106 | """ 107 | Determines whether the given attribute/sub-attribute has been 108 | manually specified in the show's YAML. 109 | 110 | Args: 111 | attributes: Any number of attributes to check for. Each 112 | subsequent argument is checked for as a sub-attribute of 113 | the prior one. 114 | 115 | Returns: 116 | True if ALL attributes are specified, False otherwise. 117 | """ 118 | 119 | # Start on the top-level YAML 120 | current = self._base_yaml 121 | 122 | for attribute in attributes: 123 | # If this level isn't even a dictionary or the attribute DNE - False 124 | if not isinstance(current, dict) or attribute not in current: 125 | return False 126 | 127 | # If this level has sub-attributes, but is blank (None) - False 128 | if current[attribute] is None: 129 | return False 130 | 131 | # Move to the next level 132 | current = current[attribute] 133 | 134 | # All given attributes have been checked without exit, must be specified 135 | return True 136 | 137 | 138 | def _parse_card_type(self, card_type: str, /) -> Optional[BaseCardType]: 139 | """ 140 | Read the card_type specification for this object. This first 141 | looks at the locally implemented types in the TitleCard class, 142 | then attempts to create a RemoteCardType from the specification. 143 | This can be either a local file to inject, or a GitHub-hosted 144 | remote file to download and inject. This updates this object's 145 | `valid` attribute (if invalid). 146 | 147 | Args: 148 | card_type: The value of card_type to read/parse. 149 | 150 | Returns: 151 | Subclass of `BaseCardType` which is indicated by the given 152 | card type identifier string. 153 | """ 154 | 155 | # If known card type, use class from hard-coded dict 156 | if card_type in TitleCard.CARD_TYPES: 157 | return TitleCard.CARD_TYPES[card_type] 158 | # Try as RemoteCardtype 159 | if (remote_card_type := RemoteCardType(card_type)).valid: 160 | return remote_card_type.card_class 161 | 162 | log.error(f'Invalid card type "{card_type}"') 163 | self.valid = False 164 | return None 165 | 166 | 167 | @staticmethod 168 | def _read_file(file: Path, *, critical: bool = False) -> dict: 169 | """ 170 | Read the given file and return the contained YAML. 171 | 172 | Args: 173 | file: Path to the file to read. 174 | critical: Whether YAML read errors should result in a 175 | critical error and exit. 176 | 177 | Returns: 178 | Empty dictionary if the file DNE, otherwise the content of 179 | the file. 180 | """ 181 | 182 | # If file does not exist, return blank dictionary 183 | if not file.exists(): 184 | return {} 185 | 186 | # Open file and return contents 187 | with file.open('r', encoding='utf-8') as file_handle: 188 | try: 189 | return safe_load(file_handle) 190 | except Exception: 191 | # Log error, if critical then exit with error code 192 | if critical: 193 | log.exception(f'Error encountered while reading file') 194 | log.critical(f'Error reading "{file.resolve()}"') 195 | sys_exit(1) 196 | else: 197 | log.exception(f'Error reading "{file.resolve()}"') 198 | 199 | return {} 200 | -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/__init__.py -------------------------------------------------------------------------------- /modules/cards/PosterTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from modules.BaseCardType import BaseCardType 5 | from modules.CleanPath import CleanPath 6 | from modules.Debug import log 7 | 8 | if TYPE_CHECKING: 9 | from modules.PreferenceParser import PreferenceParser 10 | from modules.Font import Font 11 | 12 | SeriesExtra = Optional 13 | 14 | class PosterTitleCard(BaseCardType): 15 | """ 16 | This class describes a type of CardType that produces title cards in 17 | the style of the Gundam series of cards produced by Reddit user 18 | /u/battleoflight. 19 | """ 20 | 21 | """Directory where all reference files used by this card are stored""" 22 | REF_DIRECTORY = BaseCardType.BASE_REF_DIRECTORY / 'poster_card' 23 | 24 | """Characteristics for title splitting by this class""" 25 | TITLE_CHARACTERISTICS = { 26 | 'max_line_width': 16, # Character count to begin splitting titles 27 | 'max_line_count': 5, # Maximum number of lines a title can take up 28 | 'top_heavy': True, # This class uses top heavy titling 29 | } 30 | 31 | """Characteristics of the default title font""" 32 | TITLE_FONT = str((REF_DIRECTORY / 'Amuro.otf').resolve()) 33 | TITLE_COLOR = '#FFFFFF' 34 | FONT_REPLACEMENTS = {} 35 | 36 | """Characteristics of the episode text""" 37 | EPISODE_TEXT_FORMAT = 'Ep. {episode_number}' 38 | EPISODE_TEXT_COLOR = '#FFFFFF' 39 | EPISODE_TEXT_FONT = REF_DIRECTORY / 'Amuro.otf' 40 | 41 | """Whether this class uses season titles for the purpose of archives""" 42 | USES_SEASON_TITLE = False 43 | 44 | """How to name archive directories for this type of card""" 45 | ARCHIVE_NAME = 'Poster Style' 46 | 47 | """Custom blur profile for the poster""" 48 | BLUR_PROFILE = '0x30' 49 | 50 | """Path to the reference star image to overlay on all source images""" 51 | __GRADIENT_OVERLAY = REF_DIRECTORY / 'stars-overlay.png' 52 | 53 | __slots__ = ( 54 | 'source_file', 'output_file', 'logo', 'title_text', 'episode_text', 55 | 'font_color', 'font_file', 'font_interline_spacing', 56 | 'font_interword_spacing', 'font_size', 'episode_text_color', 57 | ) 58 | 59 | 60 | def __init__(self, 61 | source_file: Path, 62 | card_file: Path, 63 | title_text: str, 64 | episode_text: str, 65 | font_color: str = TITLE_COLOR, 66 | font_file: str = TITLE_FONT, 67 | font_interline_spacing: int = 0, 68 | font_interword_spacing: int = 0, 69 | font_size: float = 1.0, 70 | blur: bool = False, 71 | grayscale: bool = False, 72 | season_number: int = 1, 73 | episode_number: int = 1, 74 | logo: SeriesExtra[str] = None, 75 | episode_text_color: Optional[str] = None, 76 | preferences: Optional['PreferenceParser'] = None, 77 | **unused, 78 | ) -> None: 79 | """ 80 | Construct a new instance of this card. 81 | """ 82 | 83 | # Initialize the parent class - this sets up an ImageMagickInterface 84 | super().__init__(blur, grayscale, preferences=preferences) 85 | 86 | # Store indicated files 87 | self.source_file = source_file 88 | self.output_file = card_file 89 | 90 | # No logo file specified 91 | if logo is None: 92 | self.logo = None 93 | # Attempt to modify as if it's a format string 94 | else: 95 | try: 96 | logo = logo.format(season_number=season_number, 97 | episode_number=episode_number) 98 | logo = Path(CleanPath(logo).sanitize()) 99 | except Exception as e: 100 | # Bad format strings will be caught during card creation 101 | self.valid = False 102 | log.exception(f'Invalid logo file "{logo}"', e) 103 | 104 | # Explicitly specicifed logo 105 | if logo.exists(): 106 | self.logo = logo 107 | # Try to find logo alongside source image 108 | elif (source_file.parent / logo.name).exists(): 109 | self.logo = source_file.parent / logo.name 110 | # Assume non-existent explicitly specified filename 111 | else: 112 | self.logo = logo 113 | 114 | # Store text 115 | self.title_text = self.image_magick.escape_chars(title_text) 116 | self.episode_text = self.image_magick.escape_chars(episode_text) 117 | 118 | # Font characteristics 119 | self.font_color = font_color 120 | self.font_file = font_file 121 | self.font_interline_spacing = font_interline_spacing 122 | self.font_interword_spacing = font_interword_spacing 123 | self.font_size = font_size 124 | 125 | # Extras 126 | if episode_text_color is None: 127 | self.episode_text_color = font_color 128 | else: 129 | self.episode_text_color = episode_text_color 130 | 131 | 132 | @staticmethod 133 | def modify_extras( 134 | extras: dict, 135 | custom_font: bool, 136 | custom_season_titles: bool, 137 | ) -> None: 138 | """ 139 | Modify the given extras based on whether font or season titles 140 | are custom. 141 | 142 | Args: 143 | extras: Dictionary to modify. 144 | custom_font: Whether the font are custom. 145 | custom_season_titles: Whether the season titles are custom. 146 | """ 147 | 148 | # Generic font, reset custom episode text color 149 | if not custom_font: 150 | if 'episode_text_color' in extras: 151 | extras['episode_text_color'] =\ 152 | PosterTitleCard.EPISODE_TEXT_COLOR 153 | 154 | 155 | @staticmethod 156 | def is_custom_font(font: 'Font', extras: dict) -> bool: 157 | """ 158 | Determines whether the given arguments represent a custom font 159 | for this card. This CardType does not use custom fonts, so this 160 | is always False. 161 | 162 | Args: 163 | font: The Font being evaluated. 164 | extras: Dictionary of extras for evaluation. 165 | 166 | returns: 167 | False, as fonts are not customizable with this card. 168 | """ 169 | 170 | custom_extras = ( 171 | ('episode_text_color' in extras 172 | and extras['episode_text_color'] != PosterTitleCard.EPISODE_TEXT_COLOR) 173 | ) 174 | 175 | return (custom_extras 176 | or ((font.color != PosterTitleCard.TITLE_COLOR) 177 | or (font.file != PosterTitleCard.TITLE_FONT) 178 | or (font.interline_spacing != 0) 179 | or (font.interword_spacing != 0) 180 | or (font.size != 1.0)) 181 | ) 182 | 183 | 184 | @staticmethod 185 | def is_custom_season_titles( 186 | custom_episode_map: bool, 187 | episode_text_format: str, 188 | ) -> bool: 189 | """ 190 | Determines whether the given attributes constitute custom or 191 | generic season titles. 192 | 193 | Args: 194 | episode_text_format: The episode text format in use. 195 | args and kwargs: Generic arguments to permit generalized 196 | function calls for any CardType. 197 | 198 | Returns: 199 | True if custom season titles are indicated, False otherwise. 200 | """ 201 | 202 | return episode_text_format != PosterTitleCard.EPISODE_TEXT_FORMAT 203 | 204 | 205 | def create(self) -> None: 206 | """Create the title card as defined by this object.""" 207 | 208 | # Source DNE, error and exit 209 | if not self.source_file.exists(): 210 | log.error(f'Poster "{self.source_file.resolve()}" does not exist') 211 | return None 212 | 213 | # If no logo is specified, create empty logo command 214 | if self.logo is None: 215 | title_offset = 0 216 | logo_command = '' 217 | # Logo specified but does not exist - error and exit 218 | elif not self.logo.exists(): 219 | log.error(f'Logo file "{self.logo.resolve()}" does not exist') 220 | return None 221 | # Logo specified and exists, create command to resize and add image 222 | else: 223 | logo_command = [ 224 | f'-gravity north', 225 | f'\( "{self.logo.resolve()}"', 226 | f'-resize x450', 227 | f'-resize 1775x450\> \)', 228 | f'-geometry +649+50', 229 | f'-composite', 230 | ] 231 | 232 | # Adjust title offset to center in smaller space (due to logo) 233 | title_offset = (450 / 2) - (50 / 2) 234 | 235 | # Single command to create card 236 | command = ' '.join([ 237 | f'convert', 238 | # Resize poster 239 | f'"{self.source_file.resolve()}"', 240 | f'-resize "x1800"', 241 | # Extend image canvas to full size 242 | f'-extent "{self.TITLE_CARD_SIZE}"', 243 | # Apply style modifiers 244 | *self.style, 245 | # Add gradient overlay 246 | f'"{self.__GRADIENT_OVERLAY.resolve()}"', 247 | f'-flatten', 248 | # Optionally add logo 249 | *logo_command, 250 | # Add episode text 251 | f'-gravity south', 252 | f'-font "{self.font_file}"', 253 | f'-pointsize {75 * self.font_size}', 254 | f'-fill "{self.episode_text_color}"', 255 | f'-annotate +649+50 "{self.episode_text}"', 256 | # Add title text 257 | f'-gravity center', 258 | f'-pointsize {165 * self.font_size}', 259 | f'-interline-spacing {-40 + self.font_interline_spacing}', 260 | f'-interword-spacing {self.font_interword_spacing}', 261 | f'-fill "{self.font_color}"', 262 | f'-annotate +649+{title_offset} "{self.title_text}"', 263 | # Create card 264 | *self.resize_output, 265 | f'"{self.output_file.resolve()}"', 266 | ]) 267 | 268 | self.image_magick.run(command) 269 | -------------------------------------------------------------------------------- /modules/cards/StarWarsTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from modules.BaseCardType import BaseCardType, ImageMagickCommands 5 | 6 | if TYPE_CHECKING: 7 | from modules.Font import Font 8 | 9 | 10 | class StarWarsTitleCard(BaseCardType): 11 | """ 12 | This class describes a type of ImageMaker that produces title cards 13 | in the theme of Star Wars cards as designed by Reddit user 14 | /u/Olivier_286. 15 | """ 16 | 17 | """Directory where all reference files used by this card are stored""" 18 | REF_DIRECTORY = BaseCardType.BASE_REF_DIRECTORY / 'star_wars' 19 | 20 | """Characteristics for title splitting by this class""" 21 | TITLE_CHARACTERISTICS = { 22 | 'max_line_width': 16, # Character count to begin splitting titles 23 | 'max_line_count': 5, # Maximum number of lines a title can take up 24 | 'top_heavy': True, # This class uses top heavy titling 25 | } 26 | 27 | """Characteristics of the default title font""" 28 | TITLE_FONT = str((REF_DIRECTORY / 'Monstice-Base.ttf').resolve()) 29 | TITLE_COLOR = '#DAC960' 30 | FONT_REPLACEMENTS = {'Ō': 'O', 'ō': 'o'} 31 | 32 | """Characteristics of the episode text""" 33 | EPISODE_TEXT_FORMAT = 'EPISODE {episode_number_cardinal}' 34 | EPISODE_TEXT_COLOR = '#AB8630' 35 | EPISODE_TEXT_FONT = REF_DIRECTORY / 'HelveticaNeue.ttc' 36 | EPISODE_NUMBER_FONT = REF_DIRECTORY / 'HelveticaNeue-Bold.ttf' 37 | 38 | """Whether this class uses season titles for the purpose of archives""" 39 | USES_SEASON_TITLE = False 40 | 41 | """How to name archive directories for this type of card""" 42 | ARCHIVE_NAME = 'Star Wars Style' 43 | 44 | """Path to the reference star image to overlay on all source images""" 45 | __STAR_GRADIENT_IMAGE = REF_DIRECTORY / 'star_gradient.png' 46 | 47 | __slots__ = ( 48 | 'source_file', 'output_file', 'title_text', 'episode_text', 49 | 'hide_episode_text', 'font_color', 'font_file', 50 | 'font_interline_spacing', 'font_interword_spacing', 'font_size', 51 | 'font_vertical_shift', 'episode_text_color', 'episode_prefix', 52 | ) 53 | 54 | def __init__(self, 55 | source_file: Path, 56 | card_file: Path, 57 | title_text: str, 58 | episode_text: str, 59 | hide_episode_text: bool = False, 60 | font_color: str = TITLE_COLOR, 61 | font_file: str = TITLE_FONT, 62 | font_interline_spacing: int = 0, 63 | font_interword_spacing: int = 0, 64 | font_size: float = 1.0, 65 | font_vertical_shift: int = 0, 66 | blur: bool = False, 67 | grayscale: bool = False, 68 | episode_text_color: str = EPISODE_TEXT_COLOR, 69 | preferences: Optional['Preferences'] = None, 70 | **unused, 71 | ) -> None: 72 | """ 73 | Initialize the CardType object. 74 | """ 75 | 76 | # Initialize the parent class - this sets up an ImageMagickInterface 77 | super().__init__(blur, grayscale, preferences=preferences) 78 | 79 | # Store source and output file 80 | self.source_file = source_file 81 | self.output_file = card_file 82 | 83 | # Store episode title 84 | self.title_text = self.image_magick.escape_chars(title_text.upper()) 85 | 86 | # Font customizations 87 | self.font_color = font_color 88 | self.font_file = font_file 89 | self.font_interline_spacing = font_interline_spacing 90 | self.font_interword_spacing = font_interword_spacing 91 | self.font_size = font_size 92 | self.font_vertical_shift = font_vertical_shift 93 | 94 | # Attempt to detect prefix text 95 | self.hide_episode_text = hide_episode_text or len(episode_text) == 0 96 | if self.hide_episode_text: 97 | self.episode_prefix, self.episode_text = None, None 98 | else: 99 | if ' ' in episode_text: 100 | prefix, text = episode_text.upper().split(' ', 1) 101 | self.episode_prefix, self.episode_text = map( 102 | self.image_magick.escape_chars, 103 | (prefix, text) 104 | ) 105 | else: 106 | self.episode_text = None 107 | self.episode_prefix = self.image_magick.escape_chars( 108 | episode_text 109 | ) 110 | 111 | # Extras 112 | self.episode_text_color = episode_text_color 113 | 114 | 115 | @property 116 | def title_text_command(self) -> ImageMagickCommands: 117 | """Subcommands to add the episode title text to an image.""" 118 | 119 | size = 124 * self.font_size 120 | interline_spacing = 20 + self.font_interline_spacing 121 | vertical_shift = 829 + self.font_vertical_shift 122 | 123 | return [ 124 | f'-font "{self.font_file}"', 125 | f'-gravity northwest', 126 | f'-pointsize {size}', 127 | f'-kerning 0.5', 128 | f'-interline-spacing {interline_spacing}', 129 | f'-interword-spacing {self.font_interword_spacing}', 130 | f'-fill "{self.font_color}"', 131 | f'-annotate +320{vertical_shift:+} "{self.title_text}"', 132 | ] 133 | 134 | 135 | @property 136 | def episode_text_command(self) -> ImageMagickCommands: 137 | """Subcommands to add the episode text to an image.""" 138 | 139 | # Hiding episode text, return blank command 140 | if self.hide_episode_text: 141 | return [] 142 | 143 | return [ 144 | # Global font options 145 | f'-gravity west', 146 | f'-pointsize 53', 147 | f'-kerning 19', 148 | f'+interword-spacing', 149 | f'-fill "{self.episode_text_color}"', 150 | f'-background transparent', 151 | # Create prefix text 152 | f'\( -font "{self.EPISODE_TEXT_FONT.resolve()}"', 153 | f'label:"{self.episode_prefix}"', 154 | # Create actual episode text 155 | f'-font "{self.EPISODE_NUMBER_FONT.resolve()}"', 156 | f'label:"{self.episode_text}"', 157 | # Combine prefix and episode text 158 | f'+smush 65 \)', 159 | # Add combined text to image 160 | f'-geometry +325-140', 161 | f'-composite', 162 | ] 163 | 164 | 165 | @staticmethod 166 | def modify_extras( 167 | extras: dict, 168 | custom_font: bool, 169 | custom_season_titles: bool, 170 | ) -> None: 171 | """ 172 | Modify the given extras based on whether font or season titles 173 | are custom. 174 | 175 | Args: 176 | extras: Dictionary to modify. 177 | custom_font: Whether the font are custom. 178 | custom_season_titles: Whether the season titles are custom. 179 | """ 180 | 181 | # Generic font, reset episode text and box colors 182 | if not custom_font: 183 | if 'episode_text_color' in extras: 184 | extras['episode_text_color'] =\ 185 | StarWarsTitleCard.EPISODE_TEXT_COLOR 186 | 187 | 188 | @staticmethod 189 | def is_custom_font(font: 'Font', extras: dict) -> bool: 190 | """ 191 | Determines whether the given arguments represent a custom font 192 | for this card. 193 | 194 | Args: 195 | font: The Font being evaluated. 196 | extras: Dictionary of extras for evaluation. 197 | 198 | Returns: 199 | True if a custom font is indicated, False otherwise. 200 | """ 201 | 202 | custom_extras = ( 203 | ('episode_text_color' in extras 204 | and extras['episode_text_color'] != StarWarsTitleCard.EPISODE_TEXT_COLOR) 205 | ) 206 | 207 | return (custom_extras 208 | or ((font.color != StarWarsTitleCard.TITLE_COLOR) 209 | or (font.file != StarWarsTitleCard.TITLE_FONT) 210 | or (font.interline_spacing != 0) 211 | or (font.interword_spacing != 0) 212 | or (font.size != 1.0) 213 | or (font.vertical_shift != 0)) 214 | ) 215 | 216 | 217 | @staticmethod 218 | def is_custom_season_titles( 219 | custom_episode_map: bool, 220 | episode_text_format: str, 221 | ) -> bool: 222 | """ 223 | Determines whether the given attributes constitute custom or 224 | generic season titles. 225 | 226 | Args: 227 | custom_episode_map: Whether the EpisodeMap was customized. 228 | episode_text_format: The episode text format in use. 229 | 230 | Returns: 231 | True if custom season titles are indicated, False otherwise. 232 | """ 233 | 234 | standard_etf = StarWarsTitleCard.EPISODE_TEXT_FORMAT.upper() 235 | 236 | return episode_text_format.upper() != standard_etf 237 | 238 | 239 | def create(self) -> None: 240 | """Create the title card as defined by this object.""" 241 | 242 | command = ' '.join([ 243 | f'convert "{self.source_file.resolve()}"', 244 | # Resize and apply styles 245 | *self.resize_and_style, 246 | # Overlay star gradient 247 | f'"{self.__STAR_GRADIENT_IMAGE.resolve()}"', 248 | f'-composite', 249 | # Add title text 250 | *self.title_text_command, 251 | # Add episode text 252 | *self.episode_text_command, 253 | # Attempt to overlay mask 254 | *self.add_overlay_mask(self.source_file), 255 | # Create card 256 | *self.resize_output, 257 | f'"{self.output_file.resolve()}"', 258 | ]) 259 | 260 | self.image_magick.run(command) 261 | -------------------------------------------------------------------------------- /modules/cards/TextlessTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Literal, Optional 3 | 4 | from modules.BaseCardType import BaseCardType 5 | 6 | if TYPE_CHECKING: 7 | from modules.Font import Font 8 | 9 | 10 | class TextlessTitleCard(BaseCardType): 11 | """ 12 | This class describes a type of CardType that does not modify the 13 | source image in anyway, only optionally blurring it. No text of any 14 | kind is added. 15 | """ 16 | 17 | """Characteristics for title splitting by this class""" 18 | TITLE_CHARACTERISTICS = { 19 | 'max_line_width': 999, # Character count to begin splitting titles 20 | 'max_line_count': 1, # Maximum number of lines a title can take up 21 | 'top_heavy': False, # This class uses bottom heavy titling 22 | } 23 | 24 | """Font case for this card is entirely blank""" 25 | DEFAULT_FONT_CASE = 'blank' 26 | 27 | """Default episode text format string, can be overwritten by each class""" 28 | EPISODE_TEXT_FORMAT = '' 29 | 30 | """Characteristics of the default title font""" 31 | TITLE_FONT = '' 32 | TITLE_COLOR = '' 33 | FONT_REPLACEMENTS = {} 34 | 35 | """Whether this CardType uses season titles for archival purposes""" 36 | USES_SEASON_TITLE = False 37 | 38 | """Don't require source images to work w/ importing""" 39 | USES_SOURCE_IMAGES = False # Set as False; if required then caught by model 40 | 41 | """Label to archive cards under""" 42 | ARCHIVE_NAME = 'Textless Version' 43 | 44 | __slots__ = ('source_file', 'output_file') 45 | 46 | 47 | def __init__(self, 48 | source_file: Path, 49 | card_file: Path, 50 | blur: bool = False, 51 | grayscale: bool = False, 52 | preferences: Optional['Preferences'] = None, # type: ignore 53 | **unused, 54 | ) -> None: 55 | """ 56 | Construct a new instance of this card. 57 | """ 58 | 59 | # Initialize the parent class - this sets up an ImageMagickInterface 60 | super().__init__(blur, grayscale, preferences=preferences) 61 | 62 | # Store input/output files 63 | self.source_file = source_file 64 | self.output_file = card_file 65 | 66 | 67 | @staticmethod 68 | def is_custom_font(font: 'Font', extras: dict) -> Literal[False]: 69 | """ 70 | Determines whether the given font characteristics constitute a 71 | default or custom font. 72 | 73 | Args: 74 | font: The Font being evaluated. 75 | extras: Dictionary of extras for evaluation. 76 | 77 | Returns: 78 | False, as fonts are not customizable with this card. 79 | """ 80 | 81 | return False 82 | 83 | 84 | @staticmethod 85 | def is_custom_season_titles( 86 | custom_episode_map: bool, 87 | episode_text_format: str, 88 | ) -> Literal[False]: 89 | """ 90 | Determines whether the given attributes constitute custom or 91 | generic season titles. 92 | 93 | Args: 94 | custom_episode_map: Whether the EpisodeMap was customized. 95 | episode_text_format: The episode text format in use. 96 | 97 | Returns: 98 | False, as season titles are not customizable with this card. 99 | """ 100 | 101 | return False 102 | 103 | 104 | def create(self) -> None: 105 | """ 106 | Make the necessary ImageMagick and system calls to create this 107 | object's defined title card. 108 | """ 109 | 110 | if (self.source_file and isinstance(self.source_file, Path) 111 | and self.source_file.exists()): 112 | add_source = [f'"{self.source_file.resolve()}"'] 113 | else: 114 | add_source = [ 115 | f'-size {self.TITLE_CARD_SIZE}', 116 | f'xc:None', 117 | ] 118 | 119 | command = ' '.join([ 120 | f'convert', 121 | *add_source, 122 | *self.resize_and_style, 123 | # Create card 124 | *self.resize_output, 125 | f'"{self.output_file.resolve()}"', 126 | ]) 127 | 128 | self.image_magick.run(command) 129 | -------------------------------------------------------------------------------- /modules/global_objects.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | if TYPE_CHECKING: 5 | from modules.FontValidator import FontValidator 6 | from modules.MediaInfoSet import MediaInfoSet 7 | from modules.PreferenceParser import PreferenceParser 8 | from modules.ShowRecordKeeper import ShowRecordKeeper 9 | 10 | 11 | class TemporaryPreferenceParser: 12 | """ 13 | Pseudo PreferenceParser object for providing when TCM is not fully 14 | initialized. 15 | """ 16 | 17 | DEFAULT_TEMP_DIR = Path(__file__).parent / '.objects' 18 | 19 | def __init__(self, database_directory): 20 | """Fake initialize this object""" 21 | 22 | self.card_dimensions = '3200x1800' 23 | self.card_quality = 95 24 | self.database_directory = Path(database_directory) 25 | self.imagemagick_container = None 26 | self.use_magick_prefix = False 27 | 28 | # pylint: disable=global-statement 29 | pp = TemporaryPreferenceParser(Path(__file__).parent / '.objects') 30 | def set_preference_parser(to: 'PreferenceParser') -> None: # type: ignore 31 | """Update the global PreferenceParser `pp` object""" 32 | 33 | global pp 34 | pp = to 35 | 36 | fv = None 37 | def set_font_validator(to: 'FontValidator') -> None: # type: ignore 38 | """Update the global FontValidator `fv` object.""" 39 | 40 | global fv 41 | fv = to 42 | 43 | info_set: Optional['MediaInfoSet'] = None 44 | def set_media_info_set(to: 'MediaInfoSet') -> None: # type: ignore 45 | """Update the global MediaInfoSet `info_set` object.""" 46 | 47 | global info_set 48 | info_set = to 49 | 50 | show_record_keeper = None 51 | def set_show_record_keeper(to: 'ShowRecordKeeper') -> None: # type: ignore 52 | """Update the global ShowRecordKeeper `show_record_keeper` object.""" 53 | 54 | global show_record_keeper 55 | show_record_keeper = to 56 | -------------------------------------------------------------------------------- /modules/ref/GRADIENT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/GRADIENT.png -------------------------------------------------------------------------------- /modules/ref/Proxima Nova Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/Proxima Nova Regular.otf -------------------------------------------------------------------------------- /modules/ref/Proxima Nova Semibold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/Proxima Nova Semibold.otf -------------------------------------------------------------------------------- /modules/ref/Sequel-Neue.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/Sequel-Neue.otf -------------------------------------------------------------------------------- /modules/ref/anime/Avenir.ttc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/anime/Avenir.ttc -------------------------------------------------------------------------------- /modules/ref/anime/Flanker Griffo.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/anime/Flanker Griffo.otf -------------------------------------------------------------------------------- /modules/ref/anime/GRADIENT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/anime/GRADIENT.png -------------------------------------------------------------------------------- /modules/ref/anime/hiragino-mincho-w3.ttc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/anime/hiragino-mincho-w3.ttc -------------------------------------------------------------------------------- /modules/ref/banner/Gill Sans Nova ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/banner/Gill Sans Nova ExtraBold.ttf -------------------------------------------------------------------------------- /modules/ref/calligraphy/SlashSignature.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/calligraphy/SlashSignature.ttf -------------------------------------------------------------------------------- /modules/ref/calligraphy/texture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/calligraphy/texture.jpg -------------------------------------------------------------------------------- /modules/ref/collection/HelveticaNeue-Thin-13.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/collection/HelveticaNeue-Thin-13.ttf -------------------------------------------------------------------------------- /modules/ref/collection/NimbusSansNovusT_Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/collection/NimbusSansNovusT_Bold.ttf -------------------------------------------------------------------------------- /modules/ref/comic_book/cc-wild-words-bold-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/comic_book/cc-wild-words-bold-italic.ttf -------------------------------------------------------------------------------- /modules/ref/fade/gradient_fade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/fade/gradient_fade.png -------------------------------------------------------------------------------- /modules/ref/formula/Formula1-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/Formula1-Bold.otf -------------------------------------------------------------------------------- /modules/ref/formula/Formula1-Numbers.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/Formula1-Numbers.otf -------------------------------------------------------------------------------- /modules/ref/formula/australia.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/australia.webp -------------------------------------------------------------------------------- /modules/ref/formula/austria.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/austria.webp -------------------------------------------------------------------------------- /modules/ref/formula/azerbaijan.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/azerbaijan.webp -------------------------------------------------------------------------------- /modules/ref/formula/bahrain.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/bahrain.webp -------------------------------------------------------------------------------- /modules/ref/formula/belgium.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/belgium.webp -------------------------------------------------------------------------------- /modules/ref/formula/brazil.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/brazil.webp -------------------------------------------------------------------------------- /modules/ref/formula/british.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/british.webp -------------------------------------------------------------------------------- /modules/ref/formula/canada.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/canada.webp -------------------------------------------------------------------------------- /modules/ref/formula/chinese.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/chinese.webp -------------------------------------------------------------------------------- /modules/ref/formula/dutch.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/dutch.webp -------------------------------------------------------------------------------- /modules/ref/formula/frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/frame.png -------------------------------------------------------------------------------- /modules/ref/formula/generic.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/generic.webp -------------------------------------------------------------------------------- /modules/ref/formula/hungarian.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/hungarian.webp -------------------------------------------------------------------------------- /modules/ref/formula/italian.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/italian.webp -------------------------------------------------------------------------------- /modules/ref/formula/japan.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/japan.webp -------------------------------------------------------------------------------- /modules/ref/formula/mexico.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/mexico.webp -------------------------------------------------------------------------------- /modules/ref/formula/monaco.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/monaco.webp -------------------------------------------------------------------------------- /modules/ref/formula/qatar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/qatar.webp -------------------------------------------------------------------------------- /modules/ref/formula/saudiarabia.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/saudiarabia.webp -------------------------------------------------------------------------------- /modules/ref/formula/singapore.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/singapore.webp -------------------------------------------------------------------------------- /modules/ref/formula/spain.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/spain.webp -------------------------------------------------------------------------------- /modules/ref/formula/uae.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/uae.webp -------------------------------------------------------------------------------- /modules/ref/formula/unitedstates.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/formula/unitedstates.webp -------------------------------------------------------------------------------- /modules/ref/frame/frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/frame/frame.png -------------------------------------------------------------------------------- /modules/ref/frame/guess-sans-medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/frame/guess-sans-medium.otf -------------------------------------------------------------------------------- /modules/ref/genre/MyriadRegular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/genre/MyriadRegular.ttf -------------------------------------------------------------------------------- /modules/ref/genre/genre_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/genre/genre_gradient.png -------------------------------------------------------------------------------- /modules/ref/inset/HelveticaNeue-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/inset/HelveticaNeue-BoldItalic.ttf -------------------------------------------------------------------------------- /modules/ref/landscape/Geometos.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/landscape/Geometos.ttf -------------------------------------------------------------------------------- /modules/ref/marvel/Qualion ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/marvel/Qualion ExtraBold.ttf -------------------------------------------------------------------------------- /modules/ref/movie/Arial Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/movie/Arial Bold.ttf -------------------------------------------------------------------------------- /modules/ref/movie/frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/movie/frame.png -------------------------------------------------------------------------------- /modules/ref/movie/gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/movie/gradient.png -------------------------------------------------------------------------------- /modules/ref/music/Gotham-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/music/Gotham-Bold.otf -------------------------------------------------------------------------------- /modules/ref/music/Gotham-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/music/Gotham-Light.otf -------------------------------------------------------------------------------- /modules/ref/music/Gotham-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/music/Gotham-Medium.ttf -------------------------------------------------------------------------------- /modules/ref/olivier/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/olivier/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /modules/ref/overline/HelveticaNeueMedium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/overline/HelveticaNeueMedium.ttf -------------------------------------------------------------------------------- /modules/ref/overline/small_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/overline/small_gradient.png -------------------------------------------------------------------------------- /modules/ref/policy.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE policymap [ 3 | <!ELEMENT policymap (policy)*> 4 | <!ATTLIST policymap xmlns CDATA #FIXED ''> 5 | <!ELEMENT policy EMPTY> 6 | <!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED 7 | name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED 8 | stealth NMTOKEN #IMPLIED value CDATA #IMPLIED> 9 | ]> 10 | <!-- 11 | Configure ImageMagick policies. 12 | 13 | Domains include system, delegate, coder, filter, path, or resource. 14 | 15 | Rights include none, read, write, execute and all. Use | to combine them, 16 | for example: "read | write" to permit read from, or write to, a path. 17 | 18 | Use a glob expression as a pattern. 19 | 20 | Suppose we do not want users to process MPEG video images: 21 | 22 | <policy domain="delegate" rights="none" pattern="mpeg:decode" /> 23 | 24 | Here we do not want users reading images from HTTP: 25 | 26 | <policy domain="coder" rights="none" pattern="HTTP" /> 27 | 28 | The /repository file system is restricted to read only. We use a glob 29 | expression to match all paths that start with /repository: 30 | 31 | <policy domain="path" rights="read" pattern="/repository/*" /> 32 | 33 | Lets prevent users from executing any image filters: 34 | 35 | <policy domain="filter" rights="none" pattern="*" /> 36 | 37 | Any large image is cached to disk rather than memory: 38 | 39 | <policy domain="resource" name="area" value="1GP"/> 40 | 41 | Use the default system font unless overwridden by the application: 42 | 43 | <policy domain="system" name="font" value="/usr/share/fonts/favorite.ttf"/> 44 | 45 | Define arguments for the memory, map, area, width, height and disk resources 46 | with SI prefixes (.e.g 100MB). In addition, resource policies are maximums 47 | for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB 48 | exceeds policy maximum so memory limit is 1GB). 49 | 50 | Rules are processed in order. Here we want to restrict ImageMagick to only 51 | read or write a small subset of proven web-safe image types: 52 | 53 | <policy domain="delegate" rights="none" pattern="*" /> 54 | <policy domain="filter" rights="none" pattern="*" /> 55 | <policy domain="coder" rights="none" pattern="*" /> 56 | <policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" /> 57 | --> 58 | <policymap> 59 | <!-- <policy domain="resource" name="temporary-path" value="/tmp"/> --> 60 | <policy domain="resource" name="memory" value="2GiB"/> 61 | <policy domain="resource" name="map" value="4GiB"/> 62 | <policy domain="resource" name="width" value="128KP"/> 63 | <policy domain="resource" name="height" value="128KP"/> 64 | <!-- <policy domain="resource" name="list-length" value="128"/> --> 65 | <policy domain="resource" name="area" value="1.0737GP"/> 66 | <policy domain="resource" name="disk" value="8GiB"/> 67 | <policy domain="resource" name="file" value="768"/> 68 | <policy domain="resource" name="thread" value="12"/> 69 | <policy domain="resource" name="throttle" value="0"/> 70 | <policy domain="resource" name="time" value="3600"/> 71 | <!-- <policy domain="coder" rights="none" pattern="MVG" /> --> 72 | <!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> --> 73 | <!-- <policy domain="path" rights="none" pattern="@*" /> --> 74 | <!-- <policy domain="cache" name="memory-map" value="anonymous"/> --> 75 | <!-- <policy domain="cache" name="synchronize" value="True"/> --> 76 | <!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/> --> 77 | <!-- <policy domain="system" name="max-memory-request" value="256MiB"/> --> 78 | <!-- <policy domain="system" name="shred" value="2"/> --> 79 | <!-- <policy domain="system" name="precision" value="6"/> --> 80 | <!-- <policy domain="system" name="font" value="/path/to/font.ttf"/> --> 81 | <!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> --> 82 | <!-- <policy domain="system" name="shred" value="2"/> --> 83 | <!-- <policy domain="system" name="precision" value="6"/> --> 84 | <!-- not needed due to the need to use explicitly by mvg: --> 85 | <!-- <policy domain="delegate" rights="none" pattern="MVG" /> --> 86 | <!-- use curl --> 87 | <policy domain="delegate" rights="none" pattern="URL" /> 88 | <policy domain="delegate" rights="none" pattern="HTTPS" /> 89 | <policy domain="delegate" rights="none" pattern="HTTP" /> 90 | <!-- in order to avoid to get image with password text --> 91 | <policy domain="path" rights="none" pattern="@*"/> 92 | <!-- disable ghostscript format types --> 93 | <policy domain="coder" rights="none" pattern="PS" /> 94 | <policy domain="coder" rights="none" pattern="PS2" /> 95 | <policy domain="coder" rights="none" pattern="PS3" /> 96 | <policy domain="coder" rights="none" pattern="EPS" /> 97 | <policy domain="coder" rights="none" pattern="PDF" /> 98 | <policy domain="coder" rights="none" pattern="XPS" /> 99 | </policymap> 100 | -------------------------------------------------------------------------------- /modules/ref/poster_card/Amuro.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/poster_card/Amuro.otf -------------------------------------------------------------------------------- /modules/ref/poster_card/stars-overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/poster_card/stars-overlay.png -------------------------------------------------------------------------------- /modules/ref/roman/flanker-griffo.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/roman/flanker-griffo.otf -------------------------------------------------------------------------------- /modules/ref/roman/sinete-regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/roman/sinete-regular.otf -------------------------------------------------------------------------------- /modules/ref/season_poster/Proxima Nova Semibold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/season_poster/Proxima Nova Semibold.otf -------------------------------------------------------------------------------- /modules/ref/season_poster/gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/season_poster/gradient.png -------------------------------------------------------------------------------- /modules/ref/shape/Golca Bold Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/shape/Golca Bold Italic.ttf -------------------------------------------------------------------------------- /modules/ref/shape/Golca Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/shape/Golca Bold.ttf -------------------------------------------------------------------------------- /modules/ref/shape/Golca Extra Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/shape/Golca Extra Bold.ttf -------------------------------------------------------------------------------- /modules/ref/star_wars/HelveticaNeue-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/star_wars/HelveticaNeue-Bold.ttf -------------------------------------------------------------------------------- /modules/ref/star_wars/HelveticaNeue.ttc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/star_wars/HelveticaNeue.ttc -------------------------------------------------------------------------------- /modules/ref/star_wars/Monstice-Base.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/star_wars/Monstice-Base.ttf -------------------------------------------------------------------------------- /modules/ref/star_wars/star_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/star_wars/star_gradient.png -------------------------------------------------------------------------------- /modules/ref/summary/created_by.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/summary/created_by.png -------------------------------------------------------------------------------- /modules/ref/summary/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/summary/logo.png -------------------------------------------------------------------------------- /modules/ref/tinted_frame/Galey Semi Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/tinted_frame/Galey Semi Bold.ttf -------------------------------------------------------------------------------- /modules/ref/version: -------------------------------------------------------------------------------- 1 | v1.16.0 -------------------------------------------------------------------------------- /modules/ref/white_border/Arial.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/white_border/Arial.ttf -------------------------------------------------------------------------------- /modules/ref/white_border/Arial_Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/white_border/Arial_Bold.ttf -------------------------------------------------------------------------------- /modules/ref/white_border/border.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker/1d682fdeed0ebf61314e0c6c7908e4abf0de7b4a/modules/ref/white_border/border.png -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PUID=${PUID:-99} 4 | PGID=${PGID:-100} 5 | UMASK=${UMASK:-002} 6 | 7 | umask $UMASK 8 | groupmod -o -g "$PGID" titlecardmaker 9 | usermod -o -u "$PUID" titlecardmaker 10 | 11 | chown -R titlecardmaker:titlecardmaker /maker /config 12 | 13 | exec runuser -u titlecardmaker -g titlecardmaker -- "$@" --------------------------------------------------------------------------------