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..2a883f7 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 @@ -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 diff --git a/lib/pinchflat/profiles/media_profile.ex b/lib/pinchflat/profiles/media_profile.ex index ef60b02..e33568c 100644 --- a/lib/pinchflat/profiles/media_profile.ex +++ b/lib/pinchflat/profiles/media_profile.ex @@ -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 diff --git a/lib/pinchflat/sources/source.ex b/lib/pinchflat/sources/source.ex index 56bcb91..ec3075f 100644 --- a/lib/pinchflat/sources/source.ex +++ b/lib/pinchflat/sources/source.ex @@ -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 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/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex index 07cc07e..33976a2 100644 --- a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex @@ -208,7 +208,9 @@

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. + + These options only work if this Media Profile's output template is set to split media into seasons. + Try the "Media Center" preset if you're not sure.

@@ -226,6 +228,16 @@ /> +
+ <.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]))" + /> +
+ <.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Media profile diff --git a/priv/repo/migrations/20240319200823_add_source_photos_fields.exs b/priv/repo/migrations/20240319200823_add_source_photos_fields.exs new file mode 100644 index 0000000..a3fd8ec --- /dev/null +++ b/priv/repo/migrations/20240319200823_add_source_photos_fields.exs @@ -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 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/metadata/source_metadata_storage_worker_test.exs b/test/pinchflat/metadata/source_metadata_storage_worker_test.exs index c8a096e..6177499 100644 --- a/test/pinchflat/metadata/source_metadata_storage_worker_test.exs +++ b/test/pinchflat/metadata/source_metadata_storage_worker_test.exs @@ -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 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 +}