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
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
69
lib/pinchflat/metadata/source_image_parser.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
78
test/pinchflat/metadata/source_image_parser_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
test/support/files/channel_photos/a.0.jpg
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
test/support/files/channel_photos/a.1.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
test/support/files/channel_photos/a.10.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
test/support/files/channel_photos/a.11.jpg
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
test/support/files/channel_photos/a.12.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
test/support/files/channel_photos/a.13.jpg
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
test/support/files/channel_photos/a.14.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
test/support/files/channel_photos/a.15.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
test/support/files/channel_photos/a.17.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
test/support/files/channel_photos/a.2.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
test/support/files/channel_photos/a.3.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
test/support/files/channel_photos/a.4.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
test/support/files/channel_photos/a.5.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
test/support/files/channel_photos/a.6.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
test/support/files/channel_photos/a.7.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
test/support/files/channel_photos/a.8.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
test/support/files/channel_photos/a.9.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
test/support/files/channel_photos/a.avatar_uncropped.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
test/support/files/channel_photos/a.banner_uncropped.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
237
test/support/files/channel_source_metadata.json
Normal 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
|
||||
}
|
||||