[FEATURE] square_thumbnail plugin (#1244)

Adds the ability to make thumbnails square, both file and embedded. Usage:

```
my_preset:
  square_thumbnail: True
```

Will soon enable this by default for all music-based presets with a toggle to disable.
Huge thanks to @Kentaro1043 for crafting the ffmpeg command for this!

Closes https://github.com/jmbannon/ytdl-sub/issues/383
This commit is contained in:
Jesse Bannon 2025-06-18 22:37:06 -07:00 committed by GitHub
parent f6bce88ebd
commit fc2da4d525
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 370 additions and 3 deletions

View file

@ -830,6 +830,19 @@ used with no modifications.
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
square_thumbnail
----------------
Whether to make thumbnails square. Supports both file and embedded-based thumbnails. Ideal
for representing audio albums.
:Usage:
.. code-block:: yaml
square_thumbnail: True
----------------------------------------------------------------------------------------------------
static_nfo_tags static_nfo_tags
--------------- ---------------
Adds an NFO file for every entry, but does not link it to an entry in the download archive. Adds an NFO file for every entry, but does not link it to an entry in the download archive.

View file

@ -24,6 +24,7 @@ from ytdl_sub.plugins.music_tags import MusicTagsPlugin
from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
from ytdl_sub.plugins.square_thumbnail import SquareThumbnailPlugin
from ytdl_sub.plugins.static_nfo_tags import StaticNfoTagsPlugin from ytdl_sub.plugins.static_nfo_tags import StaticNfoTagsPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin
@ -40,6 +41,7 @@ class PluginMapping:
"audio_extract": AudioExtractPlugin, "audio_extract": AudioExtractPlugin,
"date_range": DateRangePlugin, "date_range": DateRangePlugin,
"embed_thumbnail": EmbedThumbnailPlugin, "embed_thumbnail": EmbedThumbnailPlugin,
"square_thumbnail": SquareThumbnailPlugin,
"file_convert": FileConvertPlugin, "file_convert": FileConvertPlugin,
"format": FormatPlugin, "format": FormatPlugin,
"match_filters": MatchFiltersPlugin, "match_filters": MatchFiltersPlugin,
@ -86,6 +88,7 @@ class PluginMapping:
VideoTagsPlugin, VideoTagsPlugin,
NfoTagsPlugin, NfoTagsPlugin,
StaticNfoTagsPlugin, StaticNfoTagsPlugin,
SquareThumbnailPlugin,
EmbedThumbnailPlugin, EmbedThumbnailPlugin,
] ]

View file

@ -11,12 +11,12 @@ from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.validators import BoolValidator from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
logger = Logger.get("embed-thumbnail") logger = Logger.get("embed-thumbnail")
class EmbedThumbnailOptions(BoolValidator, OptionsValidator): class EmbedThumbnailOptions(OverridesBooleanFormatterValidator, OptionsValidator):
""" """
Whether to embed thumbnails to the audio/video file or not. Whether to embed thumbnails to the audio/video file or not.
@ -33,7 +33,7 @@ class EmbedThumbnailPlugin(Plugin[EmbedThumbnailOptions]):
@property @property
def _embed_thumbnail(self) -> bool: def _embed_thumbnail(self) -> bool:
return self.plugin_options.value return self.overrides.evaluate_boolean(self.plugin_options)
@classmethod @classmethod
def _embed_video_thumbnail(cls, entry: Entry) -> None: def _embed_video_thumbnail(cls, entry: Entry) -> None:

View file

@ -0,0 +1,77 @@
from typing import List
from typing import Optional
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
logger = Logger.get("square-thumbnail")
class SquareThumbnailOptions(OverridesBooleanFormatterValidator, OptionsValidator):
"""
Whether to make thumbnails square. Supports both file and embedded-based thumbnails. Ideal
for representing audio albums.
:Usage:
.. code-block:: yaml
square_thumbnail: True
"""
class SquareThumbnailPlugin(Plugin[SquareThumbnailOptions]):
plugin_options_type = SquareThumbnailOptions
@property
def _square_thumbnail(self) -> bool:
return self.overrides.evaluate_boolean(self.plugin_options)
@classmethod
def _convert_to_square_thumbnail(cls, entry: Entry) -> None:
thumbnail_path = entry.get_download_thumbnail_path()
tmp_file_path = FFMPEG.tmp_file_path(thumbnail_path)
try:
ffmpeg_args: List[str] = [
"-i",
thumbnail_path,
"-c:v",
"mjpeg",
"-qmin",
"1",
"-qscale:v",
"1",
"-vf",
"crop=min(iw\\,ih):min(iw\\,ih)",
"-bitexact", # for reproducibility
tmp_file_path,
]
FFMPEG.run(ffmpeg_args)
FileHandler.move(tmp_file_path, thumbnail_path)
finally:
FileHandler.delete(tmp_file_path)
def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]:
"""
Maybe make the thumbnail square
"""
if not self._square_thumbnail:
return None
if not self.is_dry_run:
if not entry.is_thumbnail_downloaded():
logger.warning(
"Cannot make a square thumbnail for '%s' because it is not available",
entry.title,
)
return None
self._convert_to_square_thumbnail(entry)
return FileMetadata("Square thumbnail")

View file

@ -0,0 +1,68 @@
from typing import Dict
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.subscriptions.subscription import Subscription
@pytest.fixture
def square_thumbnail_subscription_dict(output_directory) -> Dict:
return {
"preset": "Jellyfin Music Videos",
"output_options": {"output_directory": output_directory},
"square_thumbnail": "{perform_square_thumbnail}",
"embed_thumbnail": "{perform_embed_thumbnail}",
"overrides": {
"music_video_artist": "JMC",
"url": "https://your.name.here",
"perform_square_thumbnail": True,
"perform_embed_thumbnail": False,
},
}
class TestThumbnailPlugins:
@pytest.mark.parametrize("dry_run", [True, False])
@pytest.mark.parametrize("embed_thumbnail", [True, False])
def test_thumbnail(
self,
config,
subscription_name,
square_thumbnail_subscription_dict,
output_directory,
mock_download_collection_entries,
dry_run,
embed_thumbnail,
):
square_thumbnail_subscription_dict["overrides"]["perform_embed_thumbnail"] = embed_thumbnail
subscription = Subscription.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=square_thumbnail_subscription_dict,
)
with mock_download_collection_entries(
is_youtube_channel=False,
num_urls=1,
is_extracted_audio=False,
is_dry_run=dry_run,
):
transaction_log = subscription.download(dry_run=dry_run)
expected_filename = "square_thumbnail"
if embed_thumbnail:
expected_filename = "embedded_square_thumbnail"
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name=f"plugins/thumbnail/{expected_filename}.txt",
)
assert_expected_downloads(
output_directory=output_directory,
dry_run=dry_run,
expected_download_summary_file_name=f"plugins/thumbnail/{expected_filename}.json",
)

View file

@ -0,0 +1,19 @@
{
".ytdl-sub-subscription_test-download-archive.json": "76e202bd03ceef93daaffffee2cfa193",
"JMC/Mock Entry 20-1.info.json": "INFO_JSON",
"JMC/Mock Entry 20-1.jpg": "e691c0591065c89e21fd552672a206af",
"JMC/Mock Entry 20-1.mp4": "071b3d0747705613c0ec0cba71a045fa",
"JMC/Mock Entry 20-1.nfo": "fefcf0b3e4f4ff80ad636584d50dadec",
"JMC/Mock Entry 20-2.info.json": "INFO_JSON",
"JMC/Mock Entry 20-2.jpg": "e691c0591065c89e21fd552672a206af",
"JMC/Mock Entry 20-2.mp4": "fce852b2c7567b917a7d02b1216215a0",
"JMC/Mock Entry 20-2.nfo": "025c0b631da5ff5470382b38fce78d2d",
"JMC/Mock Entry 20-3.info.json": "INFO_JSON",
"JMC/Mock Entry 20-3.jpg": "e691c0591065c89e21fd552672a206af",
"JMC/Mock Entry 20-3.mp4": "b2edb14a985b95fb4c530f7e868ed66c",
"JMC/Mock Entry 20-3.nfo": "618b0ff948d9de2e10cf1da8c0dd6615",
"JMC/Mock Entry 21-1.info.json": "INFO_JSON",
"JMC/Mock Entry 21-1.jpg": "e691c0591065c89e21fd552672a206af",
"JMC/Mock Entry 21-1.mp4": "3b50ad36e92501fef9d18dcd7532a921",
"JMC/Mock Entry 21-1.nfo": "e5c715749efc1603a6e2f59244d87aba"
}

View file

@ -0,0 +1,19 @@
{
".ytdl-sub-subscription_test-download-archive.json": "76e202bd03ceef93daaffffee2cfa193",
"JMC/Mock Entry 20-1.info.json": "INFO_JSON",
"JMC/Mock Entry 20-1.jpg": "e691c0591065c89e21fd552672a206af",
"JMC/Mock Entry 20-1.mp4": "d8dc9918d1646c92b4a4bd246291d849",
"JMC/Mock Entry 20-1.nfo": "fefcf0b3e4f4ff80ad636584d50dadec",
"JMC/Mock Entry 20-2.info.json": "INFO_JSON",
"JMC/Mock Entry 20-2.jpg": "e691c0591065c89e21fd552672a206af",
"JMC/Mock Entry 20-2.mp4": "6f925179a5b6bf7ffcaf2f70ad585296",
"JMC/Mock Entry 20-2.nfo": "025c0b631da5ff5470382b38fce78d2d",
"JMC/Mock Entry 20-3.info.json": "INFO_JSON",
"JMC/Mock Entry 20-3.jpg": "e691c0591065c89e21fd552672a206af",
"JMC/Mock Entry 20-3.mp4": "b48f1803f14be665677e9ac9da787593",
"JMC/Mock Entry 20-3.nfo": "618b0ff948d9de2e10cf1da8c0dd6615",
"JMC/Mock Entry 21-1.info.json": "INFO_JSON",
"JMC/Mock Entry 21-1.jpg": "e691c0591065c89e21fd552672a206af",
"JMC/Mock Entry 21-1.mp4": "5fb865f27ee1cac381597d1e182dfa48",
"JMC/Mock Entry 21-1.nfo": "e5c715749efc1603a6e2f59244d87aba"
}

View file

@ -0,0 +1,85 @@
Files created:
----------------------------------------
{output_directory}
.ytdl-sub-subscription_test-download-archive.json
{output_directory}/JMC
Mock Entry 20-1.info.json
Mock Entry 20-1.jpg
Mock Entry 20-1.mp4
Video Tags:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-08
title: Mock Entry 20-1
year: 2020
Embedded thumbnail
Square thumbnail
Mock Entry 20-1.nfo
NFO tags:
musicvideo:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-08
title: Mock Entry 20-1
Mock Entry 20-2.info.json
Mock Entry 20-2.jpg
Mock Entry 20-2.mp4
Video Tags:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-08
title: Mock Entry 20-2
year: 2020
Embedded thumbnail
Square thumbnail
Mock Entry 20-2.nfo
NFO tags:
musicvideo:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-08
title: Mock Entry 20-2
Mock Entry 20-3.info.json
Mock Entry 20-3.jpg
Mock Entry 20-3.mp4
Video Tags:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-07
title: Mock Entry 20-3
year: 2020
Embedded thumbnail
Square thumbnail
Mock Entry 20-3.nfo
NFO tags:
musicvideo:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-07
title: Mock Entry 20-3
Mock Entry 21-1.info.json
Mock Entry 21-1.jpg
Mock Entry 21-1.mp4
Video Tags:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2021-08-08
title: Mock Entry 21-1
year: 2021
Embedded thumbnail
Square thumbnail
Mock Entry 21-1.nfo
NFO tags:
musicvideo:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2021-08-08
title: Mock Entry 21-1

View file

@ -0,0 +1,81 @@
Files created:
----------------------------------------
{output_directory}
.ytdl-sub-subscription_test-download-archive.json
{output_directory}/JMC
Mock Entry 20-1.info.json
Mock Entry 20-1.jpg
Mock Entry 20-1.mp4
Video Tags:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-08
title: Mock Entry 20-1
year: 2020
Square thumbnail
Mock Entry 20-1.nfo
NFO tags:
musicvideo:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-08
title: Mock Entry 20-1
Mock Entry 20-2.info.json
Mock Entry 20-2.jpg
Mock Entry 20-2.mp4
Video Tags:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-08
title: Mock Entry 20-2
year: 2020
Square thumbnail
Mock Entry 20-2.nfo
NFO tags:
musicvideo:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-08
title: Mock Entry 20-2
Mock Entry 20-3.info.json
Mock Entry 20-3.jpg
Mock Entry 20-3.mp4
Video Tags:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-07
title: Mock Entry 20-3
year: 2020
Square thumbnail
Mock Entry 20-3.nfo
NFO tags:
musicvideo:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2020-08-07
title: Mock Entry 20-3
Mock Entry 21-1.info.json
Mock Entry 21-1.jpg
Mock Entry 21-1.mp4
Video Tags:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2021-08-08
title: Mock Entry 21-1
year: 2021
Square thumbnail
Mock Entry 21-1.nfo
NFO tags:
musicvideo:
album: Music Videos
artist: JMC
genre: ytdl-sub
premiered: 2021-08-08
title: Mock Entry 21-1

View file

@ -55,6 +55,7 @@ class TestConfigFilePartiallyValidatesPresets:
def test_success__empty_plugins(self, plugin: str): def test_success__empty_plugins(self, plugin: str):
excluded_plugins = [ excluded_plugins = [
"embed_thumbnail", # value is bool, not dict "embed_thumbnail", # value is bool, not dict
"square_thumbnail",
"format", # value is string, not dict "format", # value is string, not dict
"filter_include", # is list "filter_include", # is list
"filter_exclude", # is list "filter_exclude", # is list

View file

@ -25,6 +25,7 @@ def should_filter_all_properties(plugin_name: str) -> bool:
"filter_include", "filter_include",
"filter_exclude", "filter_exclude",
"embed_thumbnail", "embed_thumbnail",
"square_thumbnail",
"video_tags", "video_tags",
"download", "download",
) )