diff --git a/lib/pinchflat/filesystem/filesystem_helpers.ex b/lib/pinchflat/filesystem/filesystem_helpers.ex index 895a8a6..a66bf23 100644 --- a/lib/pinchflat/filesystem/filesystem_helpers.ex +++ b/lib/pinchflat/filesystem/filesystem_helpers.ex @@ -34,6 +34,19 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do File.write!(filepath, content, modes) end + @doc """ + Copies a file from source to destination, creating directories as needed. + + Returns :ok | raises on error + """ + def cp_p!(source, destination) do + destination + |> Path.dirname() + |> File.mkdir_p!() + + File.cp!(source, destination) + end + @doc """ Fetches the file size of a media item and saves it to the database. diff --git a/lib/pinchflat/metadata/source_image_parser.ex b/lib/pinchflat/metadata/source_image_parser.ex new file mode 100644 index 0000000..a207462 --- /dev/null +++ b/lib/pinchflat/metadata/source_image_parser.ex @@ -0,0 +1,69 @@ +defmodule Pinchflat.Metadata.SourceImageParser do + @moduledoc """ + Functions for parsing and storing source images. + """ + alias Pinchflat.Filesystem.FilesystemHelpers + + @doc """ + Given a base directory and source metadata, look for the appropriate images + and move them to the base directory. Returns a map of image types and their + filepaths (specifically for a source). If a given image type is not found, + the key will not be present in the map. + + The metadata is expected to contain the `filepath` key for images, implying + that the images have already been downloaded by yt-dlp. This method will NOT + download images from the internet - it just copys them around. + + Returns a map with the possible keys :poster_filepath, :fanart_filepath, and + :banner_filepath. + """ + def store_source_images(base_directory, source_metadata) do + (source_metadata["thumbnails"] || []) + |> Enum.filter(&(&1["filepath"] != nil)) + |> select_useful_images() + |> Enum.map(&move_image(&1, base_directory)) + |> Enum.into(%{}) + end + + defp select_useful_images(images) do + labelled_images = + Enum.reduce(images, [], fn image_map, acc -> + case image_map do + %{"id" => "avatar_uncropped"} -> + acc ++ [{:poster, :poster_filepath, image_map["filepath"]}] + + %{"id" => "banner_uncropped"} -> + acc ++ [{:fanart, :fanart_filepath, image_map["filepath"]}] + + _ -> + acc + end + end) + + labelled_images + |> Enum.concat([{:banner, :banner_filepath, determine_best_banner(images)}]) + |> Enum.filter(fn {_, _, tmp_filepath} -> tmp_filepath end) + end + + defp determine_best_banner(images) do + best_candidate = + images + # Filter out images that don't have a width and height attribute + |> Enum.filter(&(&1["width"] && &1["height"])) + # Sort images with the highest width first + |> Enum.sort(&(&1["width"] >= &2["width"])) + # Find the first image where the ratio of width to height is greater than 3 + |> Enum.find(&(&1["width"] / &1["height"] > 3)) + + Map.get(best_candidate || %{}, "filepath") + end + + defp move_image({filename, source_attr_name, tmp_filepath}, base_directory) do + extension = Path.extname(tmp_filepath) + final_filepath = Path.join([base_directory, "#{filename}#{extension}"]) + + FilesystemHelpers.cp_p!(tmp_filepath, final_filepath) + + {source_attr_name, final_filepath} + end +end diff --git a/lib/pinchflat/metadata/source_metadata_storage_worker.ex b/lib/pinchflat/metadata/source_metadata_storage_worker.ex index 3380ea3..d131f2b 100644 --- a/lib/pinchflat/metadata/source_metadata_storage_worker.ex +++ b/lib/pinchflat/metadata/source_metadata_storage_worker.ex @@ -12,8 +12,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do alias Pinchflat.Repo alias Pinchflat.Tasks alias Pinchflat.Sources + alias Pinchflat.Utils.StringUtils alias Pinchflat.Metadata.NfoBuilder alias Pinchflat.YtDlp.MediaCollection + alias Pinchflat.Metadata.SourceImageParser alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Downloading.DownloadOptionBuilder @@ -43,19 +45,22 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do @impl Oban.Worker def perform(%Oban.Job{args: %{"id" => source_id}}) do source = Repo.preload(Sources.get_source!(source_id), [:metadata, :media_profile]) - source_metadata = fetch_source_metadata(source) series_directory = determine_series_directory(source) + {source_metadata, image_filepath_attrs} = fetch_source_metadata_and_images(series_directory, source) # `run_post_commit_tasks: false` prevents this from running in an infinite loop Sources.update_source( source, - %{ - series_directory: series_directory, - nfo_filepath: store_source_nfo(source, series_directory, source_metadata), - metadata: %{ - metadata_filepath: store_source_metadata(source, source_metadata) - } - }, + Map.merge( + %{ + series_directory: series_directory, + nfo_filepath: store_source_nfo(source, series_directory, source_metadata), + metadata: %{ + metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, source_metadata) + } + }, + image_filepath_attrs + ), run_post_commit_tasks: false ) @@ -65,14 +70,20 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale") end - defp fetch_source_metadata(source) do - {:ok, metadata} = MediaCollection.get_source_metadata(source.original_url) + defp fetch_source_metadata_and_images(series_directory, source) do + # This is a proxy for checking whether we should download images + if :rand.uniform() > 0 do + output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S" + opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: output_path] + {:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts) + image_attrs = SourceImageParser.store_source_images(series_directory, metadata) - metadata - end + {metadata, image_attrs} + else + {:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, []) - defp store_source_metadata(source, metadata) do - MetadataFileHelpers.compress_and_store_metadata_for(source, metadata) + {metadata, %{}} + end end defp determine_series_directory(source) do @@ -92,4 +103,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do NfoBuilder.build_and_store_for_source(nfo_filepath, metadata) end end + + defp tmp_directory do + Application.get_env(:pinchflat, :tmpfile_directory) + end end diff --git a/lib/pinchflat/yt_dlp/media_collection.ex b/lib/pinchflat/yt_dlp/media_collection.ex index da86600..249a703 100644 --- a/lib/pinchflat/yt_dlp/media_collection.ex +++ b/lib/pinchflat/yt_dlp/media_collection.ex @@ -94,8 +94,8 @@ defmodule Pinchflat.YtDlp.MediaCollection do Returns {:ok, map()} | {:error, any, ...}. """ - def get_source_metadata(source_url) do - opts = [playlist_items: 0] + def get_source_metadata(source_url, addl_opts \\ []) do + opts = [playlist_items: 0] ++ addl_opts output_template = "playlist:%()j" with {:ok, output} <- backend_runner().run(source_url, opts, output_template), diff --git a/test/pinchflat/filesystem/filesystem_helpers_test.exs b/test/pinchflat/filesystem/filesystem_helpers_test.exs index d5aef98..05b7722 100644 --- a/test/pinchflat/filesystem/filesystem_helpers_test.exs +++ b/test/pinchflat/filesystem/filesystem_helpers_test.exs @@ -107,4 +107,37 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do assert {:error, _} = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath) end end + + describe "cp_p!/2" do + test "copies a file from source to destination" do + source = "#{tmpfile_directory()}/source.json" + FilesystemHelpers.write_p!(source, "TEST") + destination = "#{tmpfile_directory()}/destination.json" + + refute File.exists?(destination) + FilesystemHelpers.cp_p!(source, destination) + assert File.exists?(destination) + assert File.read!(destination) == "TEST" + + File.rm!(source) + File.rm!(destination) + end + + test "creates directories as needed" do + source = "#{tmpfile_directory()}/source.json" + FilesystemHelpers.write_p!(source, "TEST") + destination = "#{tmpfile_directory()}/foo/bar/destination.json" + + refute File.exists?(destination) + FilesystemHelpers.cp_p!(source, destination) + assert File.exists?(destination) + + File.rm!(source) + File.rm!(destination) + end + end + + defp tmpfile_directory do + Application.get_env(:pinchflat, :tmpfile_directory) + end end diff --git a/test/pinchflat/metadata/source_image_parser_test.exs b/test/pinchflat/metadata/source_image_parser_test.exs new file mode 100644 index 0000000..8ec3c6c --- /dev/null +++ b/test/pinchflat/metadata/source_image_parser_test.exs @@ -0,0 +1,78 @@ +defmodule Pinchflat.Metadata.SourceImageParserTest do + use Pinchflat.DataCase + + alias Pinchflat.Metadata.SourceImageParser + + @base_dir Application.compile_env(:pinchflat, :tmpfile_directory) + + describe "store_source_images/2" do + test "returns a map of image types and locations" do + metadata = render_parsed_metadata(:channel_source_metadata) + + expected = %{ + banner_filepath: "#{@base_dir}/banner.jpg", + fanart_filepath: "#{@base_dir}/fanart.jpg", + poster_filepath: "#{@base_dir}/poster.jpg" + } + + assert SourceImageParser.store_source_images(@base_dir, metadata) == expected + end + + test "returns the avatar_uncropped as the poster" do + metadata = %{ + "thumbnails" => [ + %{"id" => "avatar_uncropped", "filepath" => "/app/test/support/files/channel_photos/a.0.jpg"} + ] + } + + expected = %{ + poster_filepath: "#{@base_dir}/poster.jpg" + } + + assert SourceImageParser.store_source_images(@base_dir, metadata) == expected + end + + test "returns the banner_uncropped as the fanart" do + metadata = %{ + "thumbnails" => [ + %{"id" => "banner_uncropped", "filepath" => "/app/test/support/files/channel_photos/a.0.jpg"} + ] + } + + expected = %{ + fanart_filepath: "#{@base_dir}/fanart.jpg" + } + + assert SourceImageParser.store_source_images(@base_dir, metadata) == expected + end + + test "doesn't return a banner if no suitable images are found" do + metadata = %{ + "thumbnails" => [ + %{ + "id" => "1", + "filepath" => "foo.jpg", + "width" => 100, + "height" => 100 + } + ] + } + + expected = %{} + + assert SourceImageParser.store_source_images(@base_dir, metadata) == expected + end + + test "doesn't blow up if empty metadata is passed" do + metadata = %{} + + assert SourceImageParser.store_source_images(@base_dir, metadata) == %{} + end + + test "doesn't blow up if no thumbnails are passed" do + metadata = %{"thumbnails" => []} + + assert SourceImageParser.store_source_images(@base_dir, metadata) == %{} + end + end +end diff --git a/test/pinchflat/yt_dlp/media_collection_test.exs b/test/pinchflat/yt_dlp/media_collection_test.exs index 8fbf6fc..13baa52 100644 --- a/test/pinchflat/yt_dlp/media_collection_test.exs +++ b/test/pinchflat/yt_dlp/media_collection_test.exs @@ -151,5 +151,15 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_metadata(@channel_url) end + + test "allows you to pass additional opts" do + expect(YtDlpRunnerMock, :run, fn _url, opts, _ot -> + assert opts == [playlist_items: 0, real_opt: :yup] + + {:ok, "{}"} + end) + + assert {:ok, _} = MediaCollection.get_source_metadata(@channel_url, real_opt: :yup) + end end end diff --git a/test/support/files/channel_photos/a.0.jpg b/test/support/files/channel_photos/a.0.jpg new file mode 100644 index 0000000..c8826ca Binary files /dev/null and b/test/support/files/channel_photos/a.0.jpg differ diff --git a/test/support/files/channel_photos/a.1.jpg b/test/support/files/channel_photos/a.1.jpg new file mode 100644 index 0000000..5c5daa8 Binary files /dev/null and b/test/support/files/channel_photos/a.1.jpg differ diff --git a/test/support/files/channel_photos/a.10.jpg b/test/support/files/channel_photos/a.10.jpg new file mode 100644 index 0000000..40e9c4d Binary files /dev/null and b/test/support/files/channel_photos/a.10.jpg differ diff --git a/test/support/files/channel_photos/a.11.jpg b/test/support/files/channel_photos/a.11.jpg new file mode 100644 index 0000000..04280b7 Binary files /dev/null and b/test/support/files/channel_photos/a.11.jpg differ diff --git a/test/support/files/channel_photos/a.12.jpg b/test/support/files/channel_photos/a.12.jpg new file mode 100644 index 0000000..52e34c2 Binary files /dev/null and b/test/support/files/channel_photos/a.12.jpg differ diff --git a/test/support/files/channel_photos/a.13.jpg b/test/support/files/channel_photos/a.13.jpg new file mode 100644 index 0000000..211a149 Binary files /dev/null and b/test/support/files/channel_photos/a.13.jpg differ diff --git a/test/support/files/channel_photos/a.14.jpg b/test/support/files/channel_photos/a.14.jpg new file mode 100644 index 0000000..52e34c2 Binary files /dev/null and b/test/support/files/channel_photos/a.14.jpg differ diff --git a/test/support/files/channel_photos/a.15.jpg b/test/support/files/channel_photos/a.15.jpg new file mode 100644 index 0000000..52e34c2 Binary files /dev/null and b/test/support/files/channel_photos/a.15.jpg differ diff --git a/test/support/files/channel_photos/a.17.jpg b/test/support/files/channel_photos/a.17.jpg new file mode 100644 index 0000000..3501fb0 Binary files /dev/null and b/test/support/files/channel_photos/a.17.jpg differ diff --git a/test/support/files/channel_photos/a.2.jpg b/test/support/files/channel_photos/a.2.jpg new file mode 100644 index 0000000..9cd189b Binary files /dev/null and b/test/support/files/channel_photos/a.2.jpg differ diff --git a/test/support/files/channel_photos/a.3.jpg b/test/support/files/channel_photos/a.3.jpg new file mode 100644 index 0000000..fc61909 Binary files /dev/null and b/test/support/files/channel_photos/a.3.jpg differ diff --git a/test/support/files/channel_photos/a.4.jpg b/test/support/files/channel_photos/a.4.jpg new file mode 100644 index 0000000..934599b Binary files /dev/null and b/test/support/files/channel_photos/a.4.jpg differ diff --git a/test/support/files/channel_photos/a.5.jpg b/test/support/files/channel_photos/a.5.jpg new file mode 100644 index 0000000..0ac749e Binary files /dev/null and b/test/support/files/channel_photos/a.5.jpg differ diff --git a/test/support/files/channel_photos/a.6.jpg b/test/support/files/channel_photos/a.6.jpg new file mode 100644 index 0000000..da2d9b8 Binary files /dev/null and b/test/support/files/channel_photos/a.6.jpg differ diff --git a/test/support/files/channel_photos/a.7.jpg b/test/support/files/channel_photos/a.7.jpg new file mode 100644 index 0000000..4f1bb8f Binary files /dev/null and b/test/support/files/channel_photos/a.7.jpg differ diff --git a/test/support/files/channel_photos/a.8.jpg b/test/support/files/channel_photos/a.8.jpg new file mode 100644 index 0000000..80783be Binary files /dev/null and b/test/support/files/channel_photos/a.8.jpg differ diff --git a/test/support/files/channel_photos/a.9.jpg b/test/support/files/channel_photos/a.9.jpg new file mode 100644 index 0000000..4f1bb8f Binary files /dev/null and b/test/support/files/channel_photos/a.9.jpg differ diff --git a/test/support/files/channel_photos/a.avatar_uncropped.jpg b/test/support/files/channel_photos/a.avatar_uncropped.jpg new file mode 100644 index 0000000..46f19b9 Binary files /dev/null and b/test/support/files/channel_photos/a.avatar_uncropped.jpg differ diff --git a/test/support/files/channel_photos/a.banner_uncropped.jpg b/test/support/files/channel_photos/a.banner_uncropped.jpg new file mode 100644 index 0000000..f7cad0d Binary files /dev/null and b/test/support/files/channel_photos/a.banner_uncropped.jpg differ diff --git a/test/support/files/channel_source_metadata.json b/test/support/files/channel_source_metadata.json new file mode 100644 index 0000000..14fba48 --- /dev/null +++ b/test/support/files/channel_source_metadata.json @@ -0,0 +1,237 @@ +{ + "id": "TheUselessTrials", + "channel": "TheUselessTrials", + "channel_id": "UCEi9yL4vhQlLafWRsAgAr3w", + "title": "TheUselessTrials", + "availability": null, + "channel_follower_count": 35000, + "description": "My name is Max and I'm a Street Trials bike rider with a passion for making videos. My channel features everything from learning and experiments all the way to just plain bike action. The first video series I started is called Quick New Trick and it featured me trying to learn new bike skills as quickly as possible, to show you how you can learn them as well.\n\nHope you enjoy the videos! See you in the comments section.", + "tags": [ + "Useless Trials", + "TUT", + "UT", + "Max Fiergolla", + "maxfiergolla", + "Street Trial Bikes", + "Bike Tricks", + "How To", + "bike trial", + "TRIAL BIKE", + "quick new trick", + "bike hacks", + "trials bike", + "bike trials", + "TRIALS", + "street trial", + "trial street", + "trials biking", + "bike tricks", + "bmx tricks", + "mtb", + "mtb tricks", + "danny macaskill", + "martyn ashton", + "macaskill", + "bike stunts", + "mountainbike stunts", + "mountainbiking", + "inspired bicycles", + "imaginate", + "fahrrad tricks", + "fahrradtricks", + "fahrrad stunts" + ], + "thumbnails": [ + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", + "height": 88, + "width": 320, + "preference": -10, + "id": "0", + "resolution": "320x88", + "filepath": "/app/test/support/files/channel_photos/a.0.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", + "height": 180, + "width": 320, + "preference": -10, + "id": "1", + "resolution": "320x180", + "filepath": "/app/test/support/files/channel_photos/a.1.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", + "height": 175, + "width": 640, + "preference": -10, + "id": "2", + "resolution": "640x175", + "filepath": "/app/test/support/files/channel_photos/a.2.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", + "height": 480, + "width": 854, + "preference": -10, + "id": "3", + "resolution": "854x480", + "filepath": "/app/test/support/files/channel_photos/a.3.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", + "height": 263, + "width": 960, + "preference": -10, + "id": "4", + "resolution": "960x263", + "filepath": "/app/test/support/files/channel_photos/a.4.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", + "height": 175, + "width": 1060, + "preference": -10, + "id": "5", + "resolution": "1060x175", + "filepath": "/app/test/support/files/channel_photos/a.5.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", + "height": 188, + "width": 1138, + "preference": -10, + "id": "6", + "resolution": "1138x188", + "filepath": "/app/test/support/files/channel_photos/a.6.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", + "height": 351, + "width": 1280, + "preference": -10, + "id": "7", + "resolution": "1280x351", + "filepath": "/app/test/support/files/channel_photos/a.7.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", + "height": 720, + "width": 1280, + "preference": -10, + "id": "8", + "resolution": "1280x720", + "filepath": "/app/test/support/files/channel_photos/a.8.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj", + "height": 395, + "width": 1440, + "preference": -10, + "id": "9", + "resolution": "1440x395", + "filepath": "/app/test/support/files/channel_photos/a.9.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", + "height": 283, + "width": 1707, + "preference": -10, + "id": "10", + "resolution": "1707x283", + "filepath": "/app/test/support/files/channel_photos/a.10.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", + "height": 1080, + "width": 1920, + "preference": -10, + "id": "11", + "resolution": "1920x1080", + "filepath": "/app/test/support/files/channel_photos/a.11.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", + "height": 351, + "width": 2120, + "preference": -10, + "id": "12", + "resolution": "2120x351", + "filepath": "/app/test/support/files/channel_photos/a.12.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj", + "height": 1192, + "width": 2120, + "preference": -10, + "id": "13", + "resolution": "2120x1192", + "filepath": "/app/test/support/files/channel_photos/a.13.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", + "height": 377, + "width": 2276, + "preference": -10, + "id": "14", + "resolution": "2276x377", + "filepath": "/app/test/support/files/channel_photos/a.14.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj", + "height": 424, + "width": 2560, + "preference": -10, + "id": "15", + "resolution": "2560x424", + "filepath": "/app/test/support/files/channel_photos/a.15.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=s0", + "id": "banner_uncropped", + "preference": -5, + "filepath": "/app/test/support/files/channel_photos/a.banner_uncropped.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/ytc/AIdro_kw1KKWzbFmvq6u8WljTbo6QQ318WoAwnjX3AYrjA=s900-c-k-c0x00ffffff-no-rj", + "height": 900, + "width": 900, + "id": "17", + "resolution": "900x900", + "filepath": "/app/test/support/files/channel_photos/a.17.jpg" + }, + { + "url": "https://yt3.googleusercontent.com/ytc/AIdro_kw1KKWzbFmvq6u8WljTbo6QQ318WoAwnjX3AYrjA=s0", + "id": "avatar_uncropped", + "preference": 1, + "filepath": "/app/test/support/files/channel_photos/a.avatar_uncropped.jpg" + } + ], + "uploader_id": "@TheUselessTrials", + "uploader_url": "https://www.youtube.com/@TheUselessTrials", + "modified_date": null, + "view_count": null, + "playlist_count": 2, + "uploader": "TheUselessTrials", + "channel_url": "https://www.youtube.com/channel/UCEi9yL4vhQlLafWRsAgAr3w", + "_type": "playlist", + "entries": [], + "webpage_url": "https://www.youtube.com/c/TheUselessTrials", + "original_url": "https://www.youtube.com/c/TheUselessTrials", + "webpage_url_basename": "TheUselessTrials", + "webpage_url_domain": "youtube.com", + "extractor": "youtube:tab", + "extractor_key": "YoutubeTab", + "release_year": null, + "requested_entries": [], + "epoch": 1710800380, + "filename": "/app/test/support/files/channel_photos/a.NA", + "formats_table": null, + "thumbnails_table": "ID Width Height URL\n0 320 88 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n1 320 180 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n2 640 175 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n3 854 480 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n4 960 263 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n5 1060 175 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n6 1138 188 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n7 1280 351 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n8 1280 720 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n9 1440 395 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n10 1707 283 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n11 1920 1080 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n12 2120 351 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n13 2120 1192 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n14 2276 377 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n15 2560 424 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\nbanner_uncropped unknown unknown https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=s0\n17 900 900 https://yt3.googleusercontent.com/ytc/AIdro_kw1KKWzbFmvq6u8WljTbo6QQ318WoAwnjX3AYrjA=s900-c-k-c0x00ffffff-no-rj\navatar_uncropped unknown unknown https://yt3.googleusercontent.com/ytc/AIdro_kw1KKWzbFmvq6u8WljTbo6QQ318WoAwnjX3AYrjA=s0", + "subtitles_table": null, + "automatic_captions_table": null, + "duration_string": null, + "autonumber": 0, + "video_autonumber": 0, + "resolution": null +}