Media downloading indexing options (round 2) (#14)

* Adds options + option builder + metadata parsing for media thumbnails

* Added release-type options to media profile; built option parser for indexing operations

* Added new media_profile options to creation form; made show helper for rendering database items

* Added options for downloading/embedding metadata

* Adds option on sources to not download media (index only)

* reformatted docs
This commit is contained in:
Kieran
2024-02-06 15:36:41 -08:00
committed by GitHub
parent ca01f17b58
commit 013b2ccc21
31 changed files with 2317 additions and 1731 deletions
+1
View File
@@ -39,3 +39,4 @@ npm-debug.log
/.elixir_ls /.elixir_ls
.env .env
.DS_Store .DS_Store
scratchpad.md
+11 -1
View File
@@ -10,13 +10,23 @@ defmodule Pinchflat.Media.MediaItem do
alias Pinchflat.MediaSource.Source alias Pinchflat.MediaSource.Source
alias Pinchflat.Media.MediaMetadata alias Pinchflat.Media.MediaMetadata
@allowed_fields ~w(
title
media_id
media_filepath
source_id
subtitle_filepaths
thumbnail_filepath
metadata_filepath
)a
@required_fields ~w(media_id source_id)a @required_fields ~w(media_id source_id)a
@allowed_fields ~w(title media_id media_filepath source_id subtitle_filepaths)a
schema "media_items" do schema "media_items" do
field :title, :string field :title, :string
field :media_id, :string field :media_id, :string
field :media_filepath, :string field :media_filepath, :string
field :thumbnail_filepath, :string
field :metadata_filepath, :string
# This is an array of [iso-2 language, filepath] pairs. Probably could # This is an array of [iso-2 language, filepath] pairs. Probably could
# be an associated record, but I don't see the benefit right now. # be an associated record, but I don't see the benefit right now.
# Will very likely revisit because I can't leave well-enough alone. # Will very likely revisit because I can't leave well-enough alone.
@@ -25,6 +25,8 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataParser do
metadata_attrs metadata_attrs
|> Map.merge(parse_media_metadata(metadata)) |> Map.merge(parse_media_metadata(metadata))
|> Map.merge(parse_subtitle_metadata(metadata)) |> Map.merge(parse_subtitle_metadata(metadata))
|> Map.merge(parse_thumbnail_metadata(metadata))
|> Map.merge(parse_infojson_metadata(metadata))
end end
defp parse_media_metadata(metadata) do defp parse_media_metadata(metadata) do
@@ -35,10 +37,9 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataParser do
end end
defp parse_subtitle_metadata(metadata) do defp parse_subtitle_metadata(metadata) do
subtitle_map = metadata["requested_subtitles"] || %{}
# IDEA: if needed, consider filtering out subtitles that don't exist on-disk # IDEA: if needed, consider filtering out subtitles that don't exist on-disk
subtitle_filepaths = subtitle_filepaths =
subtitle_map (metadata["requested_subtitles"] || %{})
|> Enum.map(fn {lang, attrs} -> [lang, attrs["filepath"]] end) |> Enum.map(fn {lang, attrs} -> [lang, attrs["filepath"]] end)
|> Enum.sort(fn [lang_a, _], [lang_b, _] -> lang_a < lang_b end) |> Enum.sort(fn [lang_a, _], [lang_b, _] -> lang_a < lang_b end)
@@ -46,4 +47,24 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataParser do
subtitle_filepaths: subtitle_filepaths subtitle_filepaths: subtitle_filepaths
} }
end end
defp parse_thumbnail_metadata(metadata) do
thumbnail_filepath =
(metadata["thumbnails"] || %{})
# Reverse so that higher resolution thumbnails come first.
# This _shouldn't_ matter yet, but I'd rather default to the best
# in case I'm wrong.
|> Enum.reverse()
|> Enum.find_value(fn attrs -> attrs["filepath"] end)
%{
thumbnail_filepath: thumbnail_filepath
}
end
defp parse_infojson_metadata(metadata) do
%{
metadata_filepath: metadata["infojson_filename"]
}
end
end end
+25 -3
View File
@@ -6,10 +6,14 @@ defmodule Pinchflat.MediaClient.SourceDetails do
it open-ish for future expansion (just in case). it open-ish for future expansion (just in case).
""" """
alias Pinchflat.Repo
alias Pinchflat.MediaSource.Source
alias Pinchflat.MediaClient.Backends.YtDlp.VideoCollection, as: YtDlpSource alias Pinchflat.MediaClient.Backends.YtDlp.VideoCollection, as: YtDlpSource
alias Pinchflat.Profiles.Options.YtDlp.IndexOptionBuilder, as: YtDlpIndexOptionBuilder
@doc """ @doc """
Gets a source's ID and name from its URL, using the given backend. Gets a source's ID and name from its URL using the given backend.
Returns {:ok, map()} | {:error, any, ...}. Returns {:ok, map()} | {:error, any, ...}.
""" """
@@ -18,11 +22,23 @@ defmodule Pinchflat.MediaClient.SourceDetails do
end end
@doc """ @doc """
Returns a list of video IDs for the given source URL, using the given backend. Returns a list of video IDs for the given source URL OR source record using the given backend.
If passing a source record, the call to the backend may have custom options applied based on
the `option_builder`.
Returns {:ok, list(binary())} | {:error, any, ...}. Returns {:ok, list(binary())} | {:error, any, ...}.
""" """
def get_video_ids(source_url, backend \\ :yt_dlp) do def get_video_ids(sourceable, backend \\ :yt_dlp)
def get_video_ids(%Source{} = source, backend) do
media_profile = Repo.preload(source, :media_profile).media_profile
{:ok, options} = option_builder(backend).build(media_profile)
source_module(backend).get_video_ids(source.collection_id, options)
end
def get_video_ids(source_url, backend) when is_binary(source_url) do
source_module(backend).get_video_ids(source_url) source_module(backend).get_video_ids(source_url)
end end
@@ -31,4 +47,10 @@ defmodule Pinchflat.MediaClient.SourceDetails do
:yt_dlp -> YtDlpSource :yt_dlp -> YtDlpSource
end end
end end
defp option_builder(backend) do
case backend do
:yt_dlp -> YtDlpIndexOptionBuilder
end
end
end end
@@ -14,7 +14,7 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.MediaClient.Backends.YtDlp.Video, as: YtDlpVideo alias Pinchflat.MediaClient.Backends.YtDlp.Video, as: YtDlpVideo
alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder, as: YtDlpOptionBuilder alias Pinchflat.Profiles.Options.YtDlp.DownloadOptionBuilder, as: YtDlpDownloadOptionBuilder
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: YtDlpMetadataParser alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: YtDlpMetadataParser
@doc """ @doc """
@@ -22,6 +22,10 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
returned by the backend. Also saves the entire metadata response to the associated returned by the backend. Also saves the entire metadata response to the associated
media_metadata record. media_metadata record.
NOTE: related methods (like the download worker) won't download if the media item's source
is set to not download media. However, I'm not enforcing that here since I need this for testing.
This may change in the future but I'm not stressed.
Returns {:ok, %MediaItem{}} | {:error, any, ...any} Returns {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
def download_for_media_item(%MediaItem{} = media_item, backend \\ :yt_dlp) do def download_for_media_item(%MediaItem{} = media_item, backend \\ :yt_dlp) do
@@ -52,7 +56,7 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
defp option_builder(backend) do defp option_builder(backend) do
case backend do case backend do
:yt_dlp -> YtDlpOptionBuilder :yt_dlp -> YtDlpDownloadOptionBuilder
end end
end end
+2
View File
@@ -15,6 +15,7 @@ defmodule Pinchflat.MediaSource.Source do
collection_type collection_type
friendly_name friendly_name
index_frequency_minutes index_frequency_minutes
download_media
original_url original_url
media_profile_id media_profile_id
)a )a
@@ -27,6 +28,7 @@ defmodule Pinchflat.MediaSource.Source do
field :collection_id, :string field :collection_id, :string
field :collection_type, Ecto.Enum, values: [:channel, :playlist] field :collection_type, Ecto.Enum, values: [:channel, :playlist]
field :index_frequency_minutes, :integer, default: 60 * 24 field :index_frequency_minutes, :integer, default: 60 * 24
field :download_media, :boolean, default: true
# This should only be used for user reference going forward # This should only be used for user reference going forward
# as the collection_id should be used for all API calls # as the collection_id should be used for all API calls
field :original_url, :string field :original_url, :string
+21
View File
@@ -15,6 +15,12 @@ defmodule Pinchflat.Profiles.MediaProfile do
download_auto_subs download_auto_subs
embed_subs embed_subs
sub_langs sub_langs
download_thumbnail
embed_thumbnail
download_metadata
embed_metadata
shorts_behaviour
livestream_behaviour
)a )a
@required_fields ~w(name output_path_template)a @required_fields ~w(name output_path_template)a
@@ -22,11 +28,26 @@ defmodule Pinchflat.Profiles.MediaProfile do
schema "media_profiles" do schema "media_profiles" do
field :name, :string field :name, :string
field :output_path_template, :string field :output_path_template, :string
field :download_subs, :boolean, default: true field :download_subs, :boolean, default: true
field :download_auto_subs, :boolean, default: true field :download_auto_subs, :boolean, default: true
field :embed_subs, :boolean, default: true field :embed_subs, :boolean, default: true
field :sub_langs, :string, default: "en" field :sub_langs, :string, default: "en"
field :download_thumbnail, :boolean, default: true
field :embed_thumbnail, :boolean, default: true
field :download_metadata, :boolean, default: true
field :embed_metadata, :boolean, default: true
# NOTE: these do NOT speed up indexing - the indexer still has to go
# through the entire collection to determine if a video is a short or
# a livestream.
# NOTE: these can BOTH be set to :only which will download shorts and
# livestreams _only_ and ignore regular videos.
field :shorts_behaviour, Ecto.Enum, values: [:include, :exclude, :only], default: :include
field :livestream_behaviour, Ecto.Enum, values: [:include, :exclude, :only], default: :include
has_many :sources, Source has_many :sources, Source
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
@@ -1,6 +1,6 @@
defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do defmodule Pinchflat.Profiles.Options.YtDlp.DownloadOptionBuilder do
@moduledoc """ @moduledoc """
Builds the options for yt-dlp based on the given media profile. Builds the options for yt-dlp to download media based on the given media profile.
IDEA: consider making this a behaviour so I can add other backends later IDEA: consider making this a behaviour so I can add other backends later
""" """
@@ -9,7 +9,7 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do
alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder
@doc """ @doc """
Builds the options for yt-dlp based on the given media profile. Builds the options for yt-dlp to download media based on the given media profile.
IDEA: consider adding the ability to pass in a second argument to override IDEA: consider adding the ability to pass in a second argument to override
these options these options
@@ -24,6 +24,8 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do
built_options = built_options =
default_options() ++ default_options() ++
subtitle_options(media_profile) ++ subtitle_options(media_profile) ++
thumbnail_options(media_profile) ++
metadata_options(media_profile) ++
output_options(media_profile) output_options(media_profile)
{:ok, built_options} {:ok, built_options}
@@ -31,11 +33,7 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do
# This will be updated a lot as I add new options to profiles # This will be updated a lot as I add new options to profiles
defp default_options do defp default_options do
[ [:no_progress]
:embed_metadata,
:embed_thumbnail,
:no_progress
]
end end
defp subtitle_options(media_profile) do defp subtitle_options(media_profile) do
@@ -65,6 +63,30 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do
end) end)
end end
defp thumbnail_options(media_profile) do
mapped_struct = Map.from_struct(media_profile)
Enum.reduce(mapped_struct, [], fn attr, acc ->
case attr do
{:download_thumbnail, true} -> acc ++ [:write_thumbnail]
{:embed_thumbnail, true} -> acc ++ [:embed_thumbnail]
_ -> acc
end
end)
end
defp metadata_options(media_profile) do
mapped_struct = Map.from_struct(media_profile)
Enum.reduce(mapped_struct, [], fn attr, acc ->
case attr do
{:download_metadata, true} -> acc ++ [:write_info_json, :clean_info_json]
{:embed_metadata, true} -> acc ++ [:embed_metadata]
_ -> acc
end
end)
end
defp output_options(media_profile) do defp output_options(media_profile) do
{:ok, output_path} = OutputPathBuilder.build(media_profile.output_path_template) {:ok, output_path} = OutputPathBuilder.build(media_profile.output_path_template)
@@ -0,0 +1,52 @@
defmodule Pinchflat.Profiles.Options.YtDlp.IndexOptionBuilder do
@moduledoc """
Builds the options for yt-dlp to index a media source based on the given media profile.
"""
alias Pinchflat.Profiles.MediaProfile
@doc """
Builds the options for yt-dlp to index a media source based on the given media profile.
"""
def build(%MediaProfile{} = media_profile) do
built_options = release_type_options(media_profile)
{:ok, built_options}
end
defp release_type_options(media_profile) do
mapped_struct = Map.from_struct(media_profile)
# Appending multiple match filters treats them as an OR condition,
# so we have to be careful around combining `only` and `exclude` options.
# eg: only shorts + exclude livestreams = "any video that is a short OR is not a livestream"
# which will return all shorts AND normal videos.
Enum.reduce(mapped_struct, [], fn attr, acc ->
case {attr, media_profile} do
{{:shorts_behaviour, :only}, _} ->
acc ++ [match_filter: "original_url*=/shorts/"]
{{:livestream_behaviour, :only}, _} ->
acc ++ [match_filter: "was_live"]
# Since match_filter is an OR (see above), `exclude`s must be ignored entirely if the
# other type is set to `only`. There is also special behaviour if they're both excludes,
# hence why these check against `:include` alone.
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: :include}} ->
acc ++ [match_filter: "original_url!*=/shorts/"]
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: :include}} ->
acc ++ [match_filter: "!was_live"]
# Again, since it's an OR, there's a special syntax if they're both excluded
# to make it an AND. Note that I'm not checking for the other permutation of
# both excluding since this MUST get hit so adding the other version would double up.
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: :exclude}} ->
acc ++ [match_filter: "!was_live & original_url!*=/shorts/"]
_ ->
acc
end
end)
end
end
@@ -12,6 +12,11 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder do
Translates liquid-style templates into yt-dlp-style templates, Translates liquid-style templates into yt-dlp-style templates,
leaving yt-dlp syntax intact. leaving yt-dlp syntax intact.
IDEA: apart from any custom options I've defined, I can support any yt-dlp
option by assuming `{{ identifier }}` should transform to `%(identifier)S`.
It's not doing anything huge, but it's nicer to type and more approachable IMO.
IDEA: set a default for `MediaProfile`'s `output_path_template` field
""" """
def build(template_string) do def build(template_string) do
TemplateParser.parse(template_string, full_yt_dlp_options_map()) TemplateParser.parse(template_string, full_yt_dlp_options_map())
+6 -1
View File
@@ -35,6 +35,7 @@ defmodule Pinchflat.Tasks.SourceTasks do
@doc """ @doc """
Starts tasks for downloading videos for any of a sources _pending_ media items. Starts tasks for downloading videos for any of a sources _pending_ media items.
Jobs are not enqueued if the source is set to not download media. This will return :ok.
NOTE: this starts a download for each media item that is pending, NOTE: this starts a download for each media item that is pending,
not just the ones that were indexed in this job run. This should ensure not just the ones that were indexed in this job run. This should ensure
@@ -45,7 +46,7 @@ defmodule Pinchflat.Tasks.SourceTasks do
Returns :ok Returns :ok
""" """
def enqueue_pending_media_downloads(%Source{} = source) do def enqueue_pending_media_downloads(%Source{download_media: true} = source) do
source source
|> Media.list_pending_media_items_for() |> Media.list_pending_media_items_for()
|> Enum.each(fn media_item -> |> Enum.each(fn media_item ->
@@ -55,4 +56,8 @@ defmodule Pinchflat.Tasks.SourceTasks do
|> Tasks.create_job_with_task(media_item) |> Tasks.create_job_with_task(media_item)
end) end)
end end
def enqueue_pending_media_downloads(%Source{download_media: false} = _source) do
:ok
end
end end
+17 -3
View File
@@ -6,18 +6,32 @@ defmodule Pinchflat.Workers.VideoDownloadWorker do
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]], unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
tags: ["media_item", "media_fetching"] tags: ["media_item", "media_fetching"]
alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.MediaClient.VideoDownloader alias Pinchflat.MediaClient.VideoDownloader
@impl Oban.Worker @impl Oban.Worker
@doc """ @doc """
For a given media item, download the video and save the metadata. For a given media item, download the media alongside any options.
Does not download media if its source is set to not download media.
Returns {:ok, %MediaItem{}} | {:error, any, ...any} Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
media_item = Media.get_media_item!(media_item_id) media_item =
media_item_id
|> Media.get_media_item!()
|> Repo.preload(:source)
# If the source is set to not download media, perform a no-op
if media_item.source.download_media do
download_media(media_item)
else
:ok
end
end
defp download_media(media_item) do
case VideoDownloader.download_for_media_item(media_item) do case VideoDownloader.download_for_media_item(media_item) do
{:ok, _} -> {:ok, media_item} {:ok, _} -> {:ok, media_item}
err -> err err -> err
@@ -535,7 +535,7 @@ defmodule PinchflatWeb.CoreComponents do
def list(assigns) do def list(assigns) do
~H""" ~H"""
<div class="mt-14"> <div class="mt-2 mb-14">
<dl class="-my-4 divide-y divide-zinc-100"> <dl class="-my-4 divide-y divide-zinc-100">
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8"> <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt> <dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
@@ -546,6 +546,30 @@ defmodule PinchflatWeb.CoreComponents do
""" """
end end
@doc """
Renders a data list from a given map. Used in development to
quickly show the attributes of a database record.
"""
attr :map, :map, required: true
def list_items_from_map(assigns) do
attrs =
Enum.filter(assigns.map, fn
{_, %{__struct__: _}} -> false
_ -> true
end)
assigns = assign(assigns, iterable_attributes: attrs)
~H"""
<.list>
<:item :for={{k, v} <- @iterable_attributes} title={k}>
<%= v %>
</:item>
</.list>
"""
end
@doc """ @doc """
Renders a back navigation link. Renders a back navigation link.
@@ -10,4 +10,12 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
attr :action, :string, required: true attr :action, :string, required: true
def media_profile_form(assigns) def media_profile_form(assigns)
def friendly_format_type_options do
[
{"Include (default)", :include},
{"Exclude", :exclude},
{"Only", :only}
]
end
end end
@@ -4,10 +4,35 @@
</.error> </.error>
<.input field={f[:name]} type="text" label="Name" /> <.input field={f[:name]} type="text" label="Name" />
<.input field={f[:output_path_template]} type="text" label="Output path template" /> <.input field={f[:output_path_template]} type="text" label="Output path template" />
<h3>Subtitle Options</h3>
<.input field={f[:download_subs]} type="checkbox" label="Download Subs" /> <.input field={f[:download_subs]} type="checkbox" label="Download Subs" />
<.input field={f[:download_auto_subs]} type="checkbox" label="Download Autogenerated Subs" /> <.input field={f[:download_auto_subs]} type="checkbox" label="Download Autogenerated Subs" />
<.input field={f[:embed_subs]} type="checkbox" label="Embed Subs" /> <.input field={f[:embed_subs]} type="checkbox" label="Embed Subs" />
<.input field={f[:sub_langs]} type="text" label="Sub Langs" /> <.input field={f[:sub_langs]} type="text" label="Sub Langs" />
<h3>Thumbnail Options</h3>
<.input field={f[:download_thumbnail]} type="checkbox" label="Download Thumbnail" />
<.input field={f[:embed_thumbnail]} type="checkbox" label="Embed Thumbnail" />
<h3>Metadata Options</h3>
<.input field={f[:download_metadata]} type="checkbox" label="Download Metadata" />
<.input field={f[:embed_metadata]} type="checkbox" label="Embed Metadata" />
<h3>Release Format Options</h3>
<.input
field={f[:shorts_behaviour]}
options={friendly_format_type_options()}
type="select"
label="Include Shorts?"
/>
<.input
field={f[:livestream_behaviour]}
options={friendly_format_type_options()}
type="select"
label="Include Livestreams?"
/>
<:actions> <:actions>
<.button>Save Media profile</.button> <.button>Save Media profile</.button>
</:actions> </:actions>
@@ -8,15 +8,6 @@
</:actions> </:actions>
</.header> </.header>
<.list> <.list_items_from_map map={Map.from_struct(@media_profile)} />
<:item
:for={
attr <- ~w(name output_path_template download_subs download_auto_subs embed_subs sub_langs)a
}
title={attr}
>
<%= Map.get(@media_profile, attr) %>
</:item>
</.list>
<.back navigate={~p"/media_profiles"}>Back to media_profiles</.back> <.back navigate={~p"/media_profiles"}>Back to media_profiles</.back>
@@ -8,19 +8,16 @@
</:actions> </:actions>
</.header> </.header>
<h3 class="mt-14">Relationships</h3>
<.list> <.list>
<:item title="media_profile"> <:item title="media_profile">
<.link href={~p"/media_profiles/#{@source.media_profile}"}> <.link href={~p"/media_profiles/#{@source.media_profile}"}>
<%= @source.media_profile.name %> <%= @source.media_profile.name %>
</.link> </.link>
</:item> </:item>
<:item
:for={attr <- ~w(collection_type collection_name collection_id original_url friendly_name)a}
title={attr}
>
<%= Map.get(@source, attr) %>
</:item>
</.list> </.list>
<h3>Attributes</h3>
<.list_items_from_map map={Map.from_struct(@source)} />
<.back navigate={~p"/media_sources/sources"}>Back to sources</.back> <.back navigate={~p"/media_sources/sources"}>Back to sources</.back>
@@ -28,6 +28,8 @@
label="Index Frequency" label="Index Frequency"
/> />
<.input field={f[:download_media]} type="checkbox" label="Download Media?" />
<:actions> <:actions>
<.button>Save Source</.button> <.button>Save Source</.button>
</:actions> </:actions>
@@ -0,0 +1,14 @@
defmodule Pinchflat.Repo.Migrations.AddThumbnailOptionsToMediaProfiles do
use Ecto.Migration
def change do
alter table(:media_profiles) do
add :download_thumbnail, :boolean, default: true, null: false
add :embed_thumbnail, :boolean, default: true, null: false
end
alter table(:media_items) do
add :thumbnail_filepath, :string
end
end
end
@@ -0,0 +1,10 @@
defmodule Pinchflat.Repo.Migrations.AddReleaseTypeBehaviourToMediaProfiles do
use Ecto.Migration
def change do
alter table(:media_profiles) do
add :shorts_behaviour, :string, null: false, default: "include"
add :livestream_behaviour, :string, null: false, default: "include"
end
end
end
@@ -0,0 +1,14 @@
defmodule Pinchflat.Repo.Migrations.AddMetadataOptionsToMediaProfiles do
use Ecto.Migration
def change do
alter table(:media_profiles) do
add :download_metadata, :boolean, default: true, null: false
add :embed_metadata, :boolean, default: true, null: false
end
alter table(:media_items) do
add :metadata_filepath, :string
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddDownloadMediaToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :download_media, :boolean, default: true, null: false
end
end
end
@@ -47,7 +47,7 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.MediaParserTest do
test "extracts the subtitle filepaths", %{metadata: metadata} do test "extracts the subtitle filepaths", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata) result = Parser.parse_for_media_item(metadata)
assert [["de", german_filepath], ["en", english_filepath]] = result.subtitle_filepaths assert [["de", german_filepath], ["en", english_filepath] | _rest] = result.subtitle_filepaths
assert String.ends_with?(english_filepath, ".en.srt") assert String.ends_with?(english_filepath, ".en.srt")
assert String.ends_with?(german_filepath, ".de.srt") assert String.ends_with?(german_filepath, ".de.srt")
@@ -83,4 +83,44 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.MediaParserTest do
assert result.subtitle_filepaths == [] assert result.subtitle_filepaths == []
end end
end end
describe "parse_for_media_item/1 when testing thumbnail metadata" do
test "extracts the thumbnail filepath", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert String.ends_with?(result.thumbnail_filepath, ".webp")
end
test "doesn't freak out if the video has no thumbnails", %{metadata: metadata} do
metadata = Map.put(metadata, "thumbnails", %{})
result = Parser.parse_for_media_item(metadata)
assert result.thumbnail_filepath == nil
end
test "doesn't freak out if the thumbnails key is missing", %{metadata: metadata} do
metadata = Map.delete(metadata, "thumbnails")
result = Parser.parse_for_media_item(metadata)
assert result.thumbnail_filepath == nil
end
end
describe "parse_for_media_item/1 when testing infojson metadata" do
test "extracts the metadata filepath", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert String.ends_with?(result.metadata_filepath, ".info.json")
end
test "doesn't freak out if the video has no infojson", %{metadata: metadata} do
metadata = Map.put(metadata, "infojson_filename", nil)
result = Parser.parse_for_media_item(metadata)
assert result.metadata_filepath == nil
end
end
end end
@@ -1,6 +1,8 @@
defmodule Pinchflat.MediaClient.SourceDetailsTest do defmodule Pinchflat.MediaClient.SourceDetailsTest do
use ExUnit.Case, async: true use Pinchflat.DataCase
import Mox import Mox
import Pinchflat.ProfilesFixtures
import Pinchflat.MediaSourceFixtures
alias Pinchflat.MediaClient.SourceDetails alias Pinchflat.MediaClient.SourceDetails
@@ -41,7 +43,7 @@ defmodule Pinchflat.MediaClient.SourceDetailsTest do
end end
end end
describe "get_video_ids/2" do describe "get_video_ids/2 when passed a string" do
test "it passes the expected arguments to the backend" do test "it passes the expected arguments to the backend" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot -> expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
assert opts == [:simulate, :skip_download] assert opts == [:simulate, :skip_download]
@@ -61,4 +63,33 @@ defmodule Pinchflat.MediaClient.SourceDetailsTest do
assert {:ok, ["video1", "video2", "video3"]} = SourceDetails.get_video_ids(@channel_url) assert {:ok, ["video1", "video2", "video3"]} = SourceDetails.get_video_ids(@channel_url)
end end
end end
describe "get_video_ids/2 when passed a Source record" do
test "it calls the backend with the source's collection ID" do
source = source_fixture()
expect(YtDlpRunnerMock, :run, fn url, _opts, _ot ->
assert source.collection_id == url
{:ok, "video1\nvideo2\nvideo3"}
end)
assert {:ok, _} = SourceDetails.get_video_ids(source)
end
test "it builds options based on the source's media profile" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
assert opts == [{:match_filter, "!was_live"}, :simulate, :skip_download]
{:ok, ""}
end)
media_profile =
media_profile_fixture(
shorts_behaviour: :include,
livestream_behaviour: :exclude
)
source = source_fixture(media_profile_id: media_profile.id)
assert {:ok, _} = SourceDetails.get_video_ids(source)
end
end
end end
@@ -28,18 +28,6 @@ defmodule Pinchflat.MediaClient.VideoDownloaderTest do
assert {:ok, _} = VideoDownloader.download_for_media_item(media_item) assert {:ok, _} = VideoDownloader.download_for_media_item(media_item)
end end
test "it writes attributes to the media item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)}
end)
assert %{media_filepath: nil, title: nil, subtitle_filepaths: []} = media_item
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
assert updated_media_item.media_filepath
assert updated_media_item.title
assert length(updated_media_item.subtitle_filepaths) > 0
end
test "it saves the metadata to the database", %{media_item: media_item} do test "it saves the metadata to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
@@ -59,4 +47,44 @@ defmodule Pinchflat.MediaClient.VideoDownloaderTest do
assert {:error, :some_error} = VideoDownloader.download_for_media_item(media_item) assert {:error, :some_error} = VideoDownloader.download_for_media_item(media_item)
end end
end end
describe "download_for_media_item/3 when testing media_item attributes" do
setup do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)}
end)
:ok
end
test "it extracts the title", %{media_item: media_item} do
assert media_item.title == nil
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
assert updated_media_item.title == "Trying to Wheelie Without the Rear Brake"
end
test "it extracts the media_filepath", %{media_item: media_item} do
assert media_item.media_filepath == nil
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
assert String.ends_with?(updated_media_item.media_filepath, ".mkv")
end
test "it extracts the subtitle_filepaths", %{media_item: media_item} do
assert media_item.subtitle_filepaths == []
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths
end
test "it extracts the thumbnail_filepath", %{media_item: media_item} do
assert media_item.thumbnail_filepath == nil
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
assert String.ends_with?(updated_media_item.thumbnail_filepath, ".webp")
end
test "it extracts the metadata_filepath", %{media_item: media_item} do
assert media_item.metadata_filepath == nil
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
assert String.ends_with?(updated_media_item.metadata_filepath, ".info.json")
end
end
end end
@@ -0,0 +1,161 @@
defmodule Pinchflat.Profiles.Options.YtDlp.DownloadOptionBuilderTest do
use ExUnit.Case, async: true
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Profiles.Options.YtDlp.DownloadOptionBuilder
@media_profile %MediaProfile{
output_path_template: "{{ title }}.%(ext)s"
}
describe "build/1" do
test "it generates an expanded output path based on the given template" do
assert {:ok, res} = DownloadOptionBuilder.build(@media_profile)
assert {:output, "/tmp/videos/%(title)S.%(ext)s"} in res
end
end
describe "build/1 when testing subtitle options" do
test "includes :write_subs option when specified" do
media_profile = %MediaProfile{@media_profile | download_subs: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert :write_subs in res
end
test "forces SRT format when download_subs is true" do
media_profile = %MediaProfile{@media_profile | download_subs: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert {:convert_subs, "srt"} in res
end
test "includes :write_auto_subs option when specified" do
media_profile = %MediaProfile{@media_profile | download_subs: true, download_auto_subs: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert :write_auto_subs in res
end
test "doesn't include :write_auto_subs option when download_subs is false" do
media_profile = %MediaProfile{@media_profile | download_subs: false, download_auto_subs: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
refute :write_auto_subs in res
end
test "includes :embed_subs option when specified" do
media_profile = %MediaProfile{@media_profile | embed_subs: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert :embed_subs in res
end
test "includes sub_langs option when download_subs is true" do
media_profile = %MediaProfile{@media_profile | download_subs: true, sub_langs: "en"}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert {:sub_langs, "en"} in res
end
test "includes sub_langs option when embed_subs is true" do
media_profile = %MediaProfile{@media_profile | embed_subs: true, sub_langs: "en"}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert {:sub_langs, "en"} in res
end
test "doesn't include sub_langs option when neither downloading nor embedding" do
media_profile = %MediaProfile{
@media_profile
| embed_subs: false,
download_subs: false,
sub_langs: "en"
}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
refute {:sub_langs, "en"} in res
end
test "other struct attributes are ignored" do
media_profile = %MediaProfile{@media_profile | id: -1}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
refute {:id, -1} in res
end
end
describe "build/1 when testing thumbnail options" do
test "includes :write_thumbnail option when specified" do
media_profile = %MediaProfile{@media_profile | download_thumbnail: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert :write_thumbnail in res
end
test "includes :embed_thumbnail option when specified" do
media_profile = %MediaProfile{@media_profile | embed_thumbnail: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert :embed_thumbnail in res
end
test "doesn't include these options when not specified" do
media_profile = %MediaProfile{
@media_profile
| embed_thumbnail: false,
download_thumbnail: false
}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
refute :write_thumbnail in res
refute :embed_thumbnail in res
end
end
describe "build/1 when testing metadata options" do
test "includes :write_info_json option when specified" do
media_profile = %MediaProfile{@media_profile | download_metadata: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert :write_info_json in res
assert :clean_info_json in res
end
test "includes :embed_metadata option when specified" do
media_profile = %MediaProfile{@media_profile | embed_metadata: true}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
assert :embed_metadata in res
end
test "doesn't include these options when not specified" do
media_profile = %MediaProfile{
@media_profile
| embed_metadata: false,
download_metadata: false
}
assert {:ok, res} = DownloadOptionBuilder.build(media_profile)
refute :write_info_json in res
refute :clean_info_json in res
refute :embed_metadata in res
end
end
end
@@ -0,0 +1,104 @@
defmodule Pinchflat.Profiles.Options.YtDlp.IndexOptionBuilderTest do
use ExUnit.Case, async: true
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Profiles.Options.YtDlp.IndexOptionBuilder
@media_profile %MediaProfile{
output_path_template: "{{ title }}.%(ext)s",
shorts_behaviour: :include,
livestream_behaviour: :include
}
describe "build/1 when testing release type options" do
test "adds correct filter when shorts_behaviour is :only" do
media_profile = %MediaProfile{@media_profile | shorts_behaviour: :only}
assert {:ok, res} = IndexOptionBuilder.build(media_profile)
assert {:match_filter, "original_url*=/shorts/"} in res
refute {:match_filter, "original_url!*=/shorts/"} in res
refute {:match_filter, "!was_live"} in res
refute {:match_filter, "was_live"} in res
end
test "adds correct filter when livestream_behaviour is :only" do
media_profile = %MediaProfile{@media_profile | livestream_behaviour: :only}
assert {:ok, res} = IndexOptionBuilder.build(media_profile)
assert {:match_filter, "was_live"} in res
refute {:match_filter, "!was_live"} in res
refute {:match_filter, "!original_url*=/shorts/"} in res
refute {:match_filter, "original_url*=/shorts/"} in res
end
test "adds correct filter when both livestreams and shorts are :only" do
media_profile = %MediaProfile{
@media_profile
| shorts_behaviour: :only,
livestream_behaviour: :only
}
assert {:ok, res} = IndexOptionBuilder.build(media_profile)
assert {:match_filter, "original_url*=/shorts/"} in res
assert {:match_filter, "was_live"} in res
refute {:match_filter, "original_url!*=/shorts/"} in res
refute {:match_filter, "!was_live"} in res
end
test "adds correct filter when shorts_behaviour is :exclude" do
media_profile = %MediaProfile{@media_profile | shorts_behaviour: :exclude}
assert {:ok, res} = IndexOptionBuilder.build(media_profile)
assert {:match_filter, "original_url!*=/shorts/"} in res
refute {:match_filter, "original_url*=/shorts/"} in res
refute {:match_filter, "was_live"} in res
refute {:match_filter, "!was_live"} in res
end
test "adds correct filter when livestream_behaviour is :exclude" do
media_profile = %MediaProfile{@media_profile | livestream_behaviour: :exclude}
assert {:ok, res} = IndexOptionBuilder.build(media_profile)
assert {:match_filter, "!was_live"} in res
refute {:match_filter, "was_live"} in res
refute {:match_filter, "original_url!*=/shorts/"} in res
refute {:match_filter, "original_url*=/shorts/"} in res
end
test "adds correct filter when shorts and livestreams are both exclude" do
media_profile = %MediaProfile{
@media_profile
| shorts_behaviour: :exclude,
livestream_behaviour: :exclude
}
assert {:ok, res} = IndexOptionBuilder.build(media_profile)
assert {:match_filter, "!was_live & original_url!*=/shorts/"} in res
refute {:match_filter, "original_url!*=/shorts/"} in res
refute {:match_filter, "!was_live"} in res
refute {:match_filter, "original_url*=/shorts/"} in res
refute {:match_filter, "was_live"} in res
end
test "does not add exclusion filter if one is excluded and the other is only" do
media_profile = %MediaProfile{
@media_profile
| shorts_behaviour: :exclude,
livestream_behaviour: :only
}
assert {:ok, res} = IndexOptionBuilder.build(media_profile)
assert {:match_filter, "was_live"} in res
refute {:match_filter, "original_url!*=/shorts/"} in res
refute {:match_filter, "original_url*=/shorts/"} in res
refute {:match_filter, "!was_live"} in res
end
end
end
@@ -1,97 +0,0 @@
defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilderTest do
use ExUnit.Case, async: true
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder
@media_profile %MediaProfile{
output_path_template: "{{ title }}.%(ext)s"
}
describe "build/1" do
test "it generates an expanded output path based on the given template" do
assert {:ok, res} = OptionBuilder.build(@media_profile)
assert {:output, "/tmp/videos/%(title)S.%(ext)s"} in res
end
end
describe "build/1 when testing subtitle options" do
test "includes :write_subs option when specified" do
media_profile = %MediaProfile{@media_profile | download_subs: true}
assert {:ok, res} = OptionBuilder.build(media_profile)
assert :write_subs in res
end
test "forces SRT format when download_subs is true" do
media_profile = %MediaProfile{@media_profile | download_subs: true}
assert {:ok, res} = OptionBuilder.build(media_profile)
assert {:convert_subs, "srt"} in res
end
test "includes :write_auto_subs option when specified" do
media_profile = %MediaProfile{@media_profile | download_subs: true, download_auto_subs: true}
assert {:ok, res} = OptionBuilder.build(media_profile)
assert :write_auto_subs in res
end
test "doesn't include :write_auto_subs option when download_subs is false" do
media_profile = %MediaProfile{@media_profile | download_subs: false, download_auto_subs: true}
assert {:ok, res} = OptionBuilder.build(media_profile)
refute :write_auto_subs in res
end
test "includes :embed_subs option when specified" do
media_profile = %MediaProfile{@media_profile | embed_subs: true}
assert {:ok, res} = OptionBuilder.build(media_profile)
assert :embed_subs in res
end
test "includes sub_langs option when download_subs is true" do
media_profile = %MediaProfile{@media_profile | download_subs: true, sub_langs: "en"}
assert {:ok, res} = OptionBuilder.build(media_profile)
assert {:sub_langs, "en"} in res
end
test "includes sub_langs option when embed_subs is true" do
media_profile = %MediaProfile{@media_profile | embed_subs: true, sub_langs: "en"}
assert {:ok, res} = OptionBuilder.build(media_profile)
assert {:sub_langs, "en"} in res
end
test "doesn't include sub_langs option when neither downloading nor embedding" do
media_profile = %MediaProfile{
@media_profile
| embed_subs: false,
download_subs: false,
sub_langs: "en"
}
assert {:ok, res} = OptionBuilder.build(media_profile)
refute {:sub_langs, "en"} in res
end
test "other struct attributes are ignored" do
media_profile = %MediaProfile{@media_profile | id: -1}
assert {:ok, res} = OptionBuilder.build(media_profile)
refute {:id, -1} in res
end
end
end
@@ -75,5 +75,21 @@ defmodule Pinchflat.Tasks.SourceTasksTest do
assert [_] = Tasks.list_tasks_for(:media_item_id, media_item.id) assert [_] = Tasks.list_tasks_for(:media_item_id, media_item.id)
end end
test "it does not create a job if the source is set to not download" do
source = source_fixture(download_media: false)
assert :ok = SourceTasks.enqueue_pending_media_downloads(source)
refute_enqueued(worker: VideoDownloadWorker)
end
test "it does not attach tasks if the source is set to not download" do
source = source_fixture(download_media: false)
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
assert :ok = SourceTasks.enqueue_pending_media_downloads(source)
assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id)
end
end end
end end
@@ -4,6 +4,7 @@ defmodule Pinchflat.Workers.VideoDownloadWorkerTest do
import Mox import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
alias Pinchflat.MediaSource
alias Pinchflat.Workers.VideoDownloadWorker alias Pinchflat.Workers.VideoDownloadWorker
setup :verify_on_exit! setup :verify_on_exit!
@@ -55,5 +56,13 @@ defmodule Pinchflat.Workers.VideoDownloadWorkerTest do
assert job.state == "retryable" assert job.state == "retryable"
end) end)
end end
test "it does not download if the source is set to not download", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> :ok end)
MediaSource.update_source(media_item.source, %{download_media: false})
perform_job(VideoDownloadWorker, %{id: media_item.id})
end
end end
end end
File diff suppressed because one or more lines are too long