Download images for sources (#96)

* [WIP] hooked up photo downloading to source metadata worker

* Added new attributes for source images

* Added option to profile form
This commit is contained in:
Kieran 2024-03-19 13:34:01 -07:00 committed by GitHub
parent 8491f8b317
commit 70dd95211f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 584 additions and 18 deletions

View file

@ -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.

View file

@ -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

View file

@ -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
@ -33,6 +35,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
- JSON metadata for internal use
- The series directory for the source
- The NFO file for the source (if specified)
- Downloads and stores source images (if specified)
The worker is kicked off after a source is inserted/updated - this can
take an unknown amount of time so don't rely on this data being here
@ -43,19 +46,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 +71,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
if source.media_profile.download_source_images && series_directory do
output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: output_path]
metadata
end
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts)
image_attrs = SourceImageParser.store_source_images(series_directory, metadata)
defp store_source_metadata(source, metadata) do
MetadataFileHelpers.compress_and_store_metadata_for(source, metadata)
{metadata, image_attrs}
else
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, [])
{metadata, %{}}
end
end
defp determine_series_directory(source) do
@ -92,4 +104,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

View file

@ -17,6 +17,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
sub_langs
download_thumbnail
embed_thumbnail
download_source_images
download_metadata
embed_metadata
download_nfo
@ -40,6 +41,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :download_thumbnail, :boolean, default: false
field :embed_thumbnail, :boolean, default: false
field :download_source_images, :boolean, default: false
field :download_metadata, :boolean, default: false
field :embed_metadata, :boolean, default: false

View file

@ -18,6 +18,9 @@ defmodule Pinchflat.Sources.Source do
collection_type
custom_name
nfo_filepath
poster_filepath
fanart_filepath
banner_filepath
series_directory
index_frequency_minutes
fast_index
@ -55,6 +58,9 @@ defmodule Pinchflat.Sources.Source do
field :collection_id, :string
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
field :nfo_filepath, :string
field :poster_filepath, :string
field :fanart_filepath, :string
field :banner_filepath, :string
field :series_directory, :string
field :index_frequency_minutes, :integer, default: 60 * 24
field :fast_index, :boolean, default: false
@ -106,6 +112,6 @@ defmodule Pinchflat.Sources.Source do
@doc false
def filepath_attributes do
~w(nfo_filepath)a
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
end
end

View file

@ -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),

View file

@ -208,7 +208,9 @@
</h3>
<p class="text-sm mt-2 max-w-prose">
Everything in this section is experimental - please open a GitHub issue if you see something odd.
These options only work if this Media Profile's output template is set to split media into seasons.
<strong>
These options only work if this Media Profile's output template is set to split media into seasons.
</strong>
Try the "Media Center" preset if you're not sure.
</p>
@ -226,6 +228,16 @@
/>
</section>
<section x-data="{ presets: { default: false, media_center: true, audio: false, archiving: true } }">
<.input
field={f[:download_source_images]}
type="toggle"
label="Download Series Images"
help="Downloads poster and banner images for use with Jellyfin, Kodi, etc. Only works for full channels (not playlists)"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Media profile</.button>
</section>

View file

@ -0,0 +1,15 @@
defmodule Pinchflat.Repo.Migrations.AddSourcePhotosFields do
use Ecto.Migration
def change do
alter table(:sources) do
add :fanart_filepath, :string
add :poster_filepath, :string
add :banner_filepath, :string
end
alter table(:media_profiles) do
add :download_source_images, :boolean, default: false, null: false
end
end
end

View file

@ -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

View file

@ -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

View file

@ -4,6 +4,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Sources
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker
@ -84,6 +85,80 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
end
end
describe "perform/1 when testing source image downloading" do
test "downloads and stores source images" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
assert source.fanart_filepath
assert source.poster_filepath
assert source.banner_filepath
assert File.exists?(source.fanart_filepath)
assert File.exists?(source.poster_filepath)
assert File.exists?(source.banner_filepath)
Sources.delete_source(source, delete_files: true)
end
test "does not store source images if the profile is not set to" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: false})
source = source_fixture(media_profile_id: profile.id)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
refute source.fanart_filepath
refute source.poster_filepath
refute source.banner_filepath
end
test "does not store source images if the series directory cannot be determined" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
refute source.fanart_filepath
refute source.poster_filepath
refute source.banner_filepath
end
end
describe "perform/1 when determining the series_directory" do
test "sets the series directory based on the returned media filepath" do
stub(YtDlpRunnerMock, :run, fn

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -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
}