Improve episode-level compatability with media center apps (#86)

* Add media profile presets (#85)

* Added presets for output templates

* Added presets for the entire media profile form

* Append `-thumb` to thumbnails when downloading (#87)

* Appended -thumb to thumbnails when downloading

* Added code to compensate for yt-dlp bug

* Squash all the commits from the other branch bc I broke things (#88)
This commit is contained in:
Kieran 2024-03-14 12:30:08 -07:00 committed by GitHub
parent 25eb772896
commit 0f3329e97d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 710 additions and 179 deletions

View file

@ -48,7 +48,8 @@ config :pinchflat, Oban,
media_indexing: 2, media_indexing: 2,
media_collection_indexing: 2, media_collection_indexing: 2,
media_fetching: 2, media_fetching: 2,
media_local_metadata: 8 local_metadata: 8,
remote_metadata: 4
] ]
# Configures the mailer # Configures the mailer

View file

@ -2,7 +2,7 @@ defmodule Pinchflat.Boot.DataBackfillWorker do
@moduledoc false @moduledoc false
use Oban.Worker, use Oban.Worker,
queue: :media_local_metadata, queue: :local_metadata,
unique: [period: :infinity, states: [:available, :scheduled, :retryable]], unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"] tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]

View file

@ -18,7 +18,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
built_options = built_options =
default_options() ++ default_options() ++
subtitle_options(media_profile) ++ subtitle_options(media_profile) ++
thumbnail_options(media_profile) ++ thumbnail_options(media_item_with_preloads) ++
metadata_options(media_profile) ++ metadata_options(media_profile) ++
quality_options(media_profile) ++ quality_options(media_profile) ++
output_options(media_item_with_preloads) output_options(media_item_with_preloads)
@ -57,13 +57,16 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end) end)
end end
defp thumbnail_options(media_profile) do defp thumbnail_options(media_item_with_preloads) do
media_profile = media_item_with_preloads.source.media_profile
mapped_struct = Map.from_struct(media_profile) mapped_struct = Map.from_struct(media_profile)
Enum.reduce(mapped_struct, [], fn attr, acc -> Enum.reduce(mapped_struct, [], fn attr, acc ->
case attr do case attr do
{:download_thumbnail, true} -> {:download_thumbnail, true} ->
acc ++ [:write_thumbnail, convert_thumbnail: "jpg"] thumbnail_save_location = determine_thumbnail_location(media_item_with_preloads)
acc ++ [:write_thumbnail, convert_thumbnail: "jpg", output: "thumbnail:#{thumbnail_save_location}"]
{:embed_thumbnail, true} -> {:embed_thumbnail, true} ->
acc ++ [:embed_thumbnail, convert_thumbnail: "jpg"] acc ++ [:embed_thumbnail, convert_thumbnail: "jpg"]
@ -102,15 +105,20 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end end
defp output_options(media_item_with_preloads) do defp output_options(media_item_with_preloads) do
media_profile = media_item_with_preloads.source.media_profile output_path_template = media_item_with_preloads.source.media_profile.output_path_template
additional_options_map = output_options_map(media_item_with_preloads)
{:ok, output_path} = OutputPathBuilder.build(media_profile.output_path_template, additional_options_map)
[ [
output: Path.join(base_directory(), output_path) output: build_output_path(output_path_template, media_item_with_preloads)
] ]
end end
defp build_output_path(string, media_item_with_preloads) do
additional_options_map = output_options_map(media_item_with_preloads)
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
Path.join(base_directory(), output_path)
end
defp output_options_map(media_item_with_preloads) do defp output_options_map(media_item_with_preloads) do
source = media_item_with_preloads.source source = media_item_with_preloads.source
@ -120,6 +128,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
} }
end end
# I don't love the string manipulation here, but what can ya' do.
# It's dependent on the output_path_template being a string ending `.{{ ext }}`
# (or equivalent), but that's validated by the MediaProfile schema.
defp determine_thumbnail_location(media_item_with_preloads) do
output_path_template = media_item_with_preloads.source.media_profile.output_path_template
output_path_template
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
|> build_output_path(media_item_with_preloads)
end
defp base_directory do defp base_directory do
Application.get_env(:pinchflat, :media_directory) Application.get_env(:pinchflat, :media_directory)
end end

View file

@ -40,7 +40,9 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
"upload_year" => "%(upload_date>%Y)S", "upload_year" => "%(upload_date>%Y)S",
"upload_month" => "%(upload_date>%m)S", "upload_month" => "%(upload_date>%m)S",
"upload_day" => "%(upload_date>%d)S", "upload_day" => "%(upload_date>%d)S",
"upload_yyyy_mm_dd" => "%(upload_date>%Y-%m-%d)S" "upload_yyyy_mm_dd" => "%(upload_date>%Y-%m-%d)S",
"season_from_date" => "%(upload_date>%Y)S",
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S"
} }
end end
end end

View file

@ -2,7 +2,7 @@ defmodule Pinchflat.Filesystem.FilesystemDataWorker do
@moduledoc false @moduledoc false
use Oban.Worker, use Oban.Worker,
queue: :media_local_metadata, queue: :local_metadata,
tags: ["media_item", "media_metadata", "local_metadata"], tags: ["media_item", "media_metadata", "local_metadata"],
max_attempts: 1 max_attempts: 1
@ -13,6 +13,10 @@ defmodule Pinchflat.Filesystem.FilesystemDataWorker do
@doc """ @doc """
For a given media item, compute and save metadata about the file on-disk. For a given media item, compute and save metadata about the file on-disk.
IDEA: does this have to be a standalone job? I originally split it out
so a failure here wouldn't cause a downloader job retry, but I can match
for failures so it doesn't retry.
Returns :ok Returns :ok
""" """
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do def perform(%Oban.Job{args: %{"id" => media_item_id}}) do

View file

@ -144,6 +144,8 @@ defmodule Pinchflat.Media do
@doc """ @doc """
Produces a flat list of the filesystem paths for a media_item's downloaded files Produces a flat list of the filesystem paths for a media_item's downloaded files
NOTE: this can almost certainly be made private
Returns [binary()] Returns [binary()]
""" """
def media_filepaths(media_item) do def media_filepaths(media_item) do
@ -162,6 +164,8 @@ defmodule Pinchflat.Media do
Produces a flat list of the filesystem paths for a media_item's metadata files. Produces a flat list of the filesystem paths for a media_item's metadata files.
Returns an empty list if the media_item has no metadata. Returns an empty list if the media_item has no metadata.
NOTE: this can almost certainly be made private
Returns [binary()] | [] Returns [binary()] | []
""" """
def metadata_filepaths(media_item) do def metadata_filepaths(media_item) do
@ -227,6 +231,7 @@ defmodule Pinchflat.Media do
def delete_media_item(%MediaItem{} = media_item, opts \\ []) do def delete_media_item(%MediaItem{} = media_item, opts \\ []) do
delete_files = Keyword.get(opts, :delete_files, false) delete_files = Keyword.get(opts, :delete_files, false)
# NOTE: this should delete metadata no matter what
if delete_files do if delete_files do
{:ok, _} = delete_all_attachments(media_item) {:ok, _} = delete_all_attachments(media_item)
end end
@ -242,6 +247,7 @@ defmodule Pinchflat.Media do
MediaItem.changeset(media_item, attrs) MediaItem.changeset(media_item, attrs)
end end
# NOTE: refactor this
defp delete_all_attachments(media_item) do defp delete_all_attachments(media_item) do
media_item = Repo.preload(media_item, :metadata) media_item = Repo.preload(media_item, :metadata)

View file

@ -54,9 +54,22 @@ defmodule Pinchflat.Metadata.MetadataParser do
|> Enum.reverse() |> Enum.reverse()
|> Enum.find_value(fn attrs -> attrs["filepath"] end) |> Enum.find_value(fn attrs -> attrs["filepath"] end)
%{ if thumbnail_filepath do
thumbnail_filepath: thumbnail_filepath # NOTE: whole ordeal needed due to a bug I found in yt-dlp
} # https://github.com/yt-dlp/yt-dlp/issues/9445
# Can be reverted to remove this entire conditional once fixed
%{
thumbnail_filepath:
thumbnail_filepath
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
}
else
%{
thumbnail_filepath: thumbnail_filepath
}
end
end end
defp parse_infojson_metadata(metadata) do defp parse_infojson_metadata(metadata) do

View file

@ -0,0 +1,36 @@
defmodule Pinchflat.Metadata.SourceMetadata do
@moduledoc """
The SourceMetadata schema.
Look. Don't @ me about Metadata vs. Metadatum. I'm very sensitive.
"""
use Ecto.Schema
import Ecto.Changeset
alias Pinchflat.Sources.Source
@allowed_fields ~w(metadata_filepath)a
@required_fields ~w(metadata_filepath)a
schema "source_metadata" do
field :metadata_filepath, :string
belongs_to :source, Source
timestamps(type: :utc_datetime)
end
@doc false
def changeset(source_metadata, attrs) do
source_metadata
|> cast(attrs, @allowed_fields)
|> validate_required(@required_fields)
|> unique_constraint([:source_id])
end
@doc false
def filepath_attributes do
~w(metadata_filepath)a
end
end

View file

@ -0,0 +1,48 @@
defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
@moduledoc false
use Oban.Worker,
queue: :remote_metadata,
tags: ["media_source", "source_metadata", "remote_metadata"],
max_attempts: 1
alias __MODULE__
alias Pinchflat.Repo
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.MetadataFileHelpers
@doc """
Starts the source metadata storage worker and creates a task for the source.
IDEA: testing out this method of handling job kickoff. I think I like it, so
I may use it in other places. Just testing it for now
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
"""
def kickoff_with_task(source) do
%{id: source.id}
|> SourceMetadataStorageWorker.new()
|> Tasks.create_job_with_task(source)
end
@impl Oban.Worker
@doc """
Fetches and stores metadata for a source in the secret metadata location.
Returns :ok
"""
def perform(%Oban.Job{args: %{"id" => source_id}}) do
source = Repo.preload(Sources.get_source!(source_id), :metadata)
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url)
Sources.update_source(source, %{
metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, metadata)
}
})
:ok
end
end

View file

@ -35,14 +35,14 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :download_subs, :boolean, default: false field :download_subs, :boolean, default: false
field :download_auto_subs, :boolean, default: false field :download_auto_subs, :boolean, default: false
field :embed_subs, :boolean, default: true field :embed_subs, :boolean, default: false
field :sub_langs, :string, default: "en" field :sub_langs, :string, default: "en"
field :download_thumbnail, :boolean, default: false field :download_thumbnail, :boolean, default: false
field :embed_thumbnail, :boolean, default: true field :embed_thumbnail, :boolean, default: false
field :download_metadata, :boolean, default: false field :download_metadata, :boolean, default: false
field :embed_metadata, :boolean, default: true field :embed_metadata, :boolean, default: false
field :download_nfo, :boolean, default: false field :download_nfo, :boolean, default: false
# NOTE: these do NOT speed up indexing - the indexer still has to go # NOTE: these do NOT speed up indexing - the indexer still has to go
@ -67,6 +67,12 @@ defmodule Pinchflat.Profiles.MediaProfile do
media_profile media_profile
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> validate_required(@required_fields) |> validate_required(@required_fields)
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|> validate_format(:output_path_template, ext_regex(), message: "must end with .{{ ext }}")
|> unique_constraint(:name) |> unique_constraint(:name)
end end
defp ext_regex do
~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/
end
end end

View file

@ -10,6 +10,7 @@ defmodule Pinchflat.Sources.Source do
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Metadata.SourceMetadata
@allowed_fields ~w( @allowed_fields ~w(
collection_name collection_name
@ -28,7 +29,8 @@ defmodule Pinchflat.Sources.Source do
# Expensive API calls are made when a source is inserted/updated so # Expensive API calls are made when a source is inserted/updated so
# we want to ensure that the source is valid before making the call. # we want to ensure that the source is valid before making the call.
# This way, we check that the other attributes are valid before ensuring # This way, we check that the other attributes are valid before ensuring
# that all fields are valid. # that all fields are valid. This is still only one DB insert but it's
# a two-stage validation process to fail fast before the API call.
@initially_required_fields ~w( @initially_required_fields ~w(
index_frequency_minutes index_frequency_minutes
fast_index fast_index
@ -60,6 +62,8 @@ defmodule Pinchflat.Sources.Source do
belongs_to :media_profile, MediaProfile belongs_to :media_profile, MediaProfile
has_one :metadata, SourceMetadata, on_replace: :update
has_many :tasks, Task has_many :tasks, Task
has_many :media_items, MediaItem, foreign_key: :source_id has_many :media_items, MediaItem, foreign_key: :source_id
@ -80,6 +84,7 @@ defmodule Pinchflat.Sources.Source do
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end) |> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> validate_required(required_fields) |> validate_required(required_fields)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|> unique_constraint([:collection_id, :media_profile_id]) |> unique_constraint([:collection_id, :media_profile_id])
end end

View file

@ -11,9 +11,11 @@ defmodule Pinchflat.Sources do
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceMetadata
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.FastIndexingHelpers alias Pinchflat.FastIndexing.FastIndexingHelpers
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker
@doc """ @doc """
Returns the list of sources. Returns [%Source{}, ...] Returns the list of sources. Returns [%Source{}, ...]
@ -54,7 +56,7 @@ defmodule Pinchflat.Sources do
case change_source(%Source{}, attrs, :initial) do case change_source(%Source{}, attrs, :initial) do
%Ecto.Changeset{valid?: true} -> %Ecto.Changeset{valid?: true} ->
%Source{} %Source{}
|> change_source_from_url(attrs) |> maybe_change_source_from_url(attrs)
|> maybe_change_indexing_frequency() |> maybe_change_indexing_frequency()
|> commit_and_handle_tasks() |> commit_and_handle_tasks()
@ -82,7 +84,7 @@ defmodule Pinchflat.Sources do
case change_source(source, attrs, :initial) do case change_source(source, attrs, :initial) do
%Ecto.Changeset{valid?: true} -> %Ecto.Changeset{valid?: true} ->
source source
|> change_source_from_url(attrs) |> maybe_change_source_from_url(attrs)
|> maybe_change_indexing_frequency() |> maybe_change_indexing_frequency()
|> commit_and_handle_tasks() |> commit_and_handle_tasks()
@ -107,6 +109,7 @@ defmodule Pinchflat.Sources do
end) end)
Tasks.delete_tasks_for(source) Tasks.delete_tasks_for(source)
delete_source_metadata_files(source)
Repo.delete(source) Repo.delete(source)
end end
@ -122,14 +125,12 @@ defmodule Pinchflat.Sources do
fetches source details from the original_url (if provided). If the source fetches source details from the original_url (if provided). If the source
details cannot be fetched, an error is added to the changeset. details cannot be fetched, an error is added to the changeset.
Note that this fetches source details as long as the `original_url` is present.
This means that it'll go for it even if a changeset is otherwise invalid. This
is pretty easy to change, but for MVP I'm not concerned.
NOTE: When operating in the ideal path, this effectively adds an API call NOTE: When operating in the ideal path, this effectively adds an API call
to the source creation/update process. Should be used only when needed. to the source creation/update process. Should be used only when needed.
NOTE: this can almost certainly be made private now
""" """
def change_source_from_url(%Source{} = source, attrs) do def maybe_change_source_from_url(%Source{} = source, attrs) do
case change_source(source, attrs) do case change_source(source, attrs) do
%Ecto.Changeset{changes: %{original_url: _}} = changeset -> %Ecto.Changeset{changes: %{original_url: _}} = changeset ->
add_source_details_to_changeset(source, changeset) add_source_details_to_changeset(source, changeset)
@ -139,6 +140,18 @@ defmodule Pinchflat.Sources do
end end
end end
defp delete_source_metadata_files(source) do
metadata = Repo.preload(source, :metadata).metadata || %SourceMetadata{}
mapped_struct = Map.from_struct(metadata)
filepaths =
SourceMetadata.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1)
Enum.each(filepaths, &File.rm/1)
end
defp add_source_details_to_changeset(source, changeset) do defp add_source_details_to_changeset(source, changeset) do
%Ecto.Changeset{changes: changes} = changeset %Ecto.Changeset{changes: changes} = changeset
@ -196,6 +209,9 @@ defmodule Pinchflat.Sources do
{:ok, %Source{} = source} -> {:ok, %Source{} = source} ->
maybe_handle_media_tasks(changeset, source) maybe_handle_media_tasks(changeset, source)
maybe_run_indexing_task(changeset, source) maybe_run_indexing_task(changeset, source)
run_metadata_storage_task(source)
{:ok, source}
err -> err ->
err err
@ -215,8 +231,6 @@ defmodule Pinchflat.Sources do
_ -> _ ->
:ok :ok
end end
{:ok, source}
end end
defp maybe_run_indexing_task(changeset, source) do defp maybe_run_indexing_task(changeset, source) do
@ -231,8 +245,11 @@ defmodule Pinchflat.Sources do
maybe_update_slow_indexing_task(changeset, source) maybe_update_slow_indexing_task(changeset, source)
maybe_update_fast_indexing_task(changeset, source) maybe_update_fast_indexing_task(changeset, source)
end end
end
{:ok, source} # This runs every time to pick up any changes to the metadata
defp run_metadata_storage_task(source) do
SourceMetadataStorageWorker.kickoff_with_task(source)
end end
defp maybe_update_slow_indexing_task(changeset, source) do defp maybe_update_slow_indexing_task(changeset, source) do

View file

@ -20,6 +20,8 @@ defmodule Pinchflat.Tasks do
Returns the list of tasks for a given record type and ID. Optionally allows you to specify Returns the list of tasks for a given record type and ID. Optionally allows you to specify
which worker or job states to include. which worker or job states to include.
IDEA: this should be updated to take a struct instead of a record type and ID
Returns [%Task{}, ...] Returns [%Task{}, ...]
""" """
def list_tasks_for(attached_record_type, attached_record_id, worker_name \\ nil, job_states \\ Oban.Job.states()) do def list_tasks_for(attached_record_type, attached_record_id, worker_name \\ nil, job_states \\ Oban.Job.states()) do

View file

@ -50,8 +50,8 @@ defmodule Pinchflat.YtDlp.MediaCollection do
@doc """ @doc """
Gets a source's ID and name from its URL. Gets a source's ID and name from its URL.
yt-dlp does not _really_ have source-specific functions, so yt-dlp does not _really_ have source-specific functions that return what
instead we're fetching just the first video (using playlist_end: 1) we need, so instead we're fetching just the first video (using playlist_end: 1)
and parsing the source ID and name from _its_ metadata and parsing the source ID and name from _its_ metadata
Returns {:ok, map()} | {:error, any, ...}. Returns {:ok, map()} | {:error, any, ...}.
@ -71,7 +71,35 @@ defmodule Pinchflat.YtDlp.MediaCollection do
end end
end end
@doc """
Gets a source's metadata from its URL.
This is mostly for things like getting the source's avatar and banner image
(if applicable). However, this yt-dlp call doesn't have enough overlap with
`get_source_details/1` to allow combining them - this one has _almost_ everything
we need, but it doesn't contain enough information to tell 100% if the url is a channel
or a playlist.
The main purpose of this (past using as a fetcher for _other_ metadata) is to live
as a compressed blob for possible future use. That's why it's not getting formatted like
`get_source_details/1`
Returns {:ok, map()} | {:error, any, ...}.
"""
def get_source_metadata(source_url) do
opts = [playlist_items: 0]
output_template = "playlist:%()j"
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json}
else
err -> err
end
end
defp format_source_details(response) do defp format_source_details(response) do
# NOTE: I should probably make this a struct some day
%{ %{
channel_id: response["channel_id"], channel_id: response["channel_id"],
channel_name: response["channel"], channel_name: response["channel"],

View file

@ -317,17 +317,7 @@ defmodule PinchflatWeb.CoreComponents do
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span> <span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
</.label> </.label>
<div class="relative"> <div class="relative">
<input type="hidden" name={@name} value="false" /> <input type="hidden" id={@id} name={@name} x-bind:value="enabled" {@rest} />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
x-bind:checked="enabled"
class="sr-only"
@change="enabled = !enabled"
{@rest}
/>
<div class="inline-block cursor-pointer" @click="enabled = !enabled"> <div class="inline-block cursor-pointer" @click="enabled = !enabled">
<div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div> <div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div>
<div <div
@ -349,23 +339,26 @@ defmodule PinchflatWeb.CoreComponents do
def input(%{type: "select"} = assigns) do def input(%{type: "select"} = assigns) do
~H""" ~H"""
<div phx-feedback-for={@name}> <div phx-feedback-for={@name}>
<.label for={@id}> <.label :if={@label} for={@id}>
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span> <%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
</.label> </.label>
<select <div class="flex">
id={@id} <select
name={@name} id={@id}
class={[ name={@name}
"relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition", class={[
"focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white", "relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition",
@inputclass "focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white",
]} @inputclass
multiple={@multiple} ]}
{@rest} multiple={@multiple}
> {@rest}
<option :if={@prompt} value=""><%= @prompt %></option> >
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %> <option :if={@prompt} value=""><%= @prompt %></option>
</select> <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
</select>
<%= render_slot(@inner_block) %>
</div>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>

View file

@ -28,9 +28,10 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
"#{@rounding} inline-flex items-center justify-center px-8 py-4", "#{@rounding} inline-flex items-center justify-center px-8 py-4",
"#{@color}", "#{@color}",
"hover:bg-opacity-90 lg:px-8 xl:px-10", "hover:bg-opacity-90 lg:px-8 xl:px-10",
"disabled:bg-opacity-50 disabled:cursor-not-allowed disabled:text-gray-2", "disabled:bg-opacity-50 disabled:cursor-not-allowed disabled:text-grey-5",
@class @class
]} ]}
type={@type}
disabled={@disabled} disabled={@disabled}
{@rest} {@rest}
> >

View file

@ -1,6 +1,8 @@
defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
alias Pinchflat.Profiles.MediaProfile
embed_templates "media_profile_html/*" embed_templates "media_profile_html/*"
@doc """ @doc """
@ -53,4 +55,25 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
duration_string duration_string
)a )a
end end
def preset_options do
[
{"Default", "default"},
{"Media Center (Plex, Jellyfin, Kodi, etc.)", "media_center"},
{"Music", "audio"},
{"Archiving", "archiving"}
]
end
defp default_output_template do
%MediaProfile{}.output_path_template
end
defp media_center_output_template do
"/shows/{{ source_custom_name }}/{{ season_from_date }}/{{ season_episode_from_date }} - {{ title }}.{{ ext }}"
end
defp audio_output_template do
"/music/{{ source_custom_name }}/{{ title }}.{{ ext }}"
end
end end

View file

@ -3,123 +3,218 @@
Oops, something went wrong! Please check the errors below. Oops, something went wrong! Please check the errors below.
</.error> </.error>
<h3 class="my-4 text-2xl text-black dark:text-white"> <section x-data="{ selectedPreset: null }">
General Options <h3 class="my-4 text-2xl text-black dark:text-white">
</h3> Use a Preset
<.input </h3>
field={f[:name]} <section x-data="{ selection: null }">
type="text" <.input
label="Name" prompt="Select preset"
placeholder="New Profile" name="media_profile_preset"
help="Something descriptive. Does not impact indexing or downloading (required)" value=""
/> options={preset_options()}
type="select"
x-model="selection"
inputclass="w-full"
help="You can further customize the settings after selecting a preset. This is just a starting point"
>
<.button
class="h-13 w-2/5 lg:w-1/5 ml-2 md:ml-4"
rounding="rounded"
type="button"
x-on:click="selectedPreset = selection; selection = null"
x-bind:disabled="!selection"
>
<span x-text="selection ? 'Load' : 'Select'">Select</span><span class="hidden lg:inline ml-1">Preset</span>
</.button>
</.input>
</section>
<.input <h3 class="mt-8 text-2xl text-black dark:text-white">
field={f[:output_path_template]} General Options
type="text" </h3>
inputclass="font-mono"
label="Output path template"
help="Must end with .{{ ext }}. See below for more details. The default is good for most cases (required)"
/>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <section x-data="{
Subtitle Options presets: {
</h3> default: 'Default',
<.input media_center: 'TV Shows',
field={f[:download_subs]} audio: 'Audio',
type="toggle" archiving: 'Archiving'
label="Download Subtitles" }
help="Downloads subtitle files alongside media file" }">
/> <.input
<.input field={f[:name]}
field={f[:download_auto_subs]} type="text"
type="toggle" label="Name"
label="Download Autogenerated Subtitles" placeholder="New Profile"
help="Prefers normal subs but will download autogenerated if needed. Requires 'Download Subtitles' to be enabled" help="Something descriptive. Does not impact indexing or downloading (required)"
/> x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
<.input />
field={f[:embed_subs]} </section>
type="toggle"
label="Embed Subtitles"
help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles' (recommended)"
/>
<.input
field={f[:sub_langs]}
type="text"
label="Subtitle Languages"
help="Use commas for multiple languages (eg: en,de)"
/>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <section x-data={"{
Thumbnail Options presets: {
</h3> default: '#{default_output_template()}',
<.input media_center: '#{media_center_output_template()}',
field={f[:download_thumbnail]} audio: '#{audio_output_template()}',
type="toggle" archiving: '#{default_output_template()}'
label="Download Thumbnail" }
help="Downloads thumbnail alongside media file" }"}>
/> <.input
<.input field={f[:output_path_template]}
field={f[:embed_thumbnail]} type="text"
type="toggle" inputclass="font-mono"
label="Embed Thumbnail" label="Output path template"
help="Downloads and embeds thumbnail in the media file itself, if supported. Uneffected by 'Download Thumbnail' (recommended)" help="Must end with .{{ ext }}. See below for more details. The default is good for most cases (required)"
/> x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
</section>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <h3 class="mt-10 text-2xl text-black dark:text-white">
Metadata Options Subtitle Options
</h3> </h3>
<.input
field={f[:download_metadata]}
type="toggle"
label="Download Metadata"
help="Downloads metadata file alongside media file"
/>
<.input
field={f[:embed_metadata]}
type="toggle"
label="Embed Metadata"
help="Downloads and embeds metadata in the media file itself, if supported. Uneffected by 'Download Metadata' (recommended)"
/>
<.input
field={f[:download_nfo]}
type="toggle"
label="Download NFO data"
help="Downloads NFO data alongside media file for use with Jellyfin, Kodi, etc."
/>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
Release Format Options <.input
</h3> field={f[:download_subs]}
type="toggle"
label="Download Subtitles"
help="Downloads subtitle files alongside media file"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<.input <section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: false } }">
field={f[:shorts_behaviour]} <.input
options={friendly_format_type_options()} field={f[:download_auto_subs]}
type="select" type="toggle"
label="Include Shorts?" label="Download Autogenerated Subtitles"
help="Experimental. Please report any issues on GitHub" help="Prefers normal subs but will download autogenerated if needed. Requires 'Download Subtitles' to be enabled"
/> x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
<.input />
field={f[:livestream_behaviour]} </section>
options={friendly_format_type_options()}
type="select"
label="Include Livestreams?"
help="Excludes media that comes from a past livestream"
/>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
Quality Options <.input
</h3> field={f[:embed_subs]}
type="toggle"
label="Embed Subtitles"
help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles' (recommended)"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<.input <section x-data="{ presets: { default: 'en', media_center: 'en', audio: '', archiving: 'all' } }">
field={f[:preferred_resolution]} <.input
options={friendly_resolution_options()} field={f[:sub_langs]}
type="select" type="text"
label="Preferred Resolution" label="Subtitle Languages"
help="Will grab the closest available resolution if your preferred is not available. 'Audio Only' grabs the highest quality m4a" help="Use commas for multiple languages (eg: en,de)"
/> x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Media profile</.button> <h3 class="mt-10 text-2xl text-black dark:text-white">
Thumbnail Options
</h3>
<section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
<.input
field={f[:download_thumbnail]}
type="toggle"
label="Download Thumbnail"
help="Downloads thumbnail alongside media file"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<section x-data="{ presets: { default: true, media_center: true, audio: true, archiving: true } }">
<.input
field={f[:embed_thumbnail]}
type="toggle"
label="Embed Thumbnail"
help="Downloads and embeds thumbnail in the media file itself, if supported. Uneffected by 'Download Thumbnail' (recommended)"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<h3 class="mt-10 text-2xl text-black dark:text-white">
Metadata Options
</h3>
<section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: true } }">
<.input
field={f[:download_metadata]}
type="toggle"
label="Download Metadata"
help="Downloads metadata file alongside media file"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<section x-data="{ presets: { default: true, media_center: true, audio: true, archiving: true } }">
<.input
field={f[:embed_metadata]}
type="toggle"
label="Embed Metadata"
help="Downloads and embeds metadata in the media file itself, if supported. Uneffected by 'Download Metadata' (recommended)"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<section x-data="{ presets: { default: false, media_center: true, audio: false, archiving: true } }">
<.input
field={f[:download_nfo]}
type="toggle"
label="Download NFO data"
help="Downloads NFO data alongside media file for use with Jellyfin, Kodi, etc."
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<h3 class="mt-10 text-2xl text-black dark:text-white">
Release Format Options
</h3>
<section x-data="{ presets: { default: 'include', media_center: 'exclude', audio: 'exclude', archiving: 'include' } }">
<.input
field={f[:shorts_behaviour]}
options={friendly_format_type_options()}
type="select"
label="Include Shorts?"
help="Experimental. Please report any issues on GitHub"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
</section>
<section x-data="{ presets: { default: 'exclude', media_center: 'exclude', audio: 'exclude', archiving: 'include' } }">
<.input
field={f[:livestream_behaviour]}
options={friendly_format_type_options()}
type="select"
label="Include Livestreams?"
help="Excludes media that comes from a past livestream"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
</section>
<h3 class="mt-10 text-2xl text-black dark:text-white">
Quality Options
</h3>
<section x-data="{ presets: { default: '1080p', media_center: '1080p', audio: 'audio', archiving: '2160p' } }">
<.input
field={f[:preferred_resolution]}
options={friendly_resolution_options()}
type="select"
label="Preferred Resolution"
help="Will grab the closest available resolution if your preferred is not available. 'Audio Only' grabs the highest quality m4a"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Media profile</.button>
</section>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5"> <div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
<.output_template_help /> <.output_template_help />

View file

@ -0,0 +1,14 @@
defmodule Pinchflat.Repo.Migrations.CreateSourceMetadata do
use Ecto.Migration
def change do
create table(:source_metadata) do
add :metadata_filepath, :string, null: false
add :source_id, references(:sources, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create unique_index(:source_metadata, [:source_id])
end
end

View file

@ -125,7 +125,15 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert :write_thumbnail in res assert :write_thumbnail in res
end end
test "convertes thumbnail to jpg when download_thumbnail is true", %{media_item: media_item} do test "appends -thumb to the thumbnail name when download_thumbnail is true", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{download_thumbnail: true})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:output, "thumbnail:/tmp/test/media/%(title)S-thumb.%(ext)s"} in res
end
test "converts thumbnail to jpg when download_thumbnail is true", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{download_thumbnail: true}) media_item = update_media_profile_attribute(media_item, %{download_thumbnail: true})
assert {:ok, res} = DownloadOptionBuilder.build(media_item) assert {:ok, res} = DownloadOptionBuilder.build(media_item)

View file

@ -94,6 +94,15 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
assert String.ends_with?(result.thumbnail_filepath, ".webp") assert String.ends_with?(result.thumbnail_filepath, ".webp")
end end
# NOTE: this can be removed once this bug is fixed
# https://github.com/yt-dlp/yt-dlp/issues/9445
# and the associated conditional in the parser is removed
test "automatically appends `-thumb` to the thumbnail filename", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert String.contains?(result.thumbnail_filepath, "-thumb.webp")
end
test "doesn't freak out if the media has no thumbnails", %{metadata: metadata} do test "doesn't freak out if the media has no thumbnails", %{metadata: metadata} do
metadata = Map.put(metadata, "thumbnails", %{}) metadata = Map.put(metadata, "thumbnails", %{})

View file

@ -0,0 +1,55 @@
defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
use Pinchflat.DataCase
import Mox
import Pinchflat.SourcesFixtures
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker
setup :verify_on_exit!
describe "kickoff_with_task/1" do
test "enqueues a new worker for the source" do
source = source_fixture()
assert {:ok, _} = SourceMetadataStorageWorker.kickoff_with_task(source)
assert_enqueued(worker: SourceMetadataStorageWorker, args: %{"id" => source.id})
end
test "creates a new task for the source" do
source = source_fixture()
assert {:ok, task} = SourceMetadataStorageWorker.kickoff_with_task(source)
assert task.source_id == source.id
end
end
describe "perform/1" do
test "sets metadata location for source" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
source = Repo.preload(source_fixture(), :metadata)
refute source.metadata
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.preload(Repo.reload(source), :metadata)
assert source.metadata.metadata_filepath
File.rm!(source.metadata.metadata_filepath)
end
test "fetches and stores returned metadata for source" do
source = source_fixture()
file_contents = Phoenix.json_library().encode!(%{"title" => "test"})
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, file_contents} end)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.preload(Repo.reload(source), :metadata)
{:ok, metadata} = MetadataFileHelpers.read_compressed_metadata(source.metadata.metadata_filepath)
assert metadata == %{"title" => "test"}
end
end
end

View file

@ -26,11 +26,11 @@ defmodule Pinchflat.ProfilesTest do
describe "create_media_profile/1" do describe "create_media_profile/1" do
test "creation with valid data creates a media_profile" do test "creation with valid data creates a media_profile" do
valid_attrs = %{name: "some name", output_path_template: "some output_path_template"} valid_attrs = %{name: "some name", output_path_template: "output_template.{{ ext }}"}
assert {:ok, %MediaProfile{} = media_profile} = Profiles.create_media_profile(valid_attrs) assert {:ok, %MediaProfile{} = media_profile} = Profiles.create_media_profile(valid_attrs)
assert media_profile.name == "some name" assert media_profile.name == "some name"
assert media_profile.output_path_template == "some output_path_template" assert media_profile.output_path_template == "output_template.{{ ext }}"
end end
test "creation with invalid data returns error changeset" do test "creation with invalid data returns error changeset" do
@ -44,14 +44,14 @@ defmodule Pinchflat.ProfilesTest do
update_attrs = %{ update_attrs = %{
name: "some updated name", name: "some updated name",
output_path_template: "some updated output_path_template" output_path_template: "new_output_template.{{ ext }}"
} }
assert {:ok, %MediaProfile{} = media_profile} = assert {:ok, %MediaProfile{} = media_profile} =
Profiles.update_media_profile(media_profile, update_attrs) Profiles.update_media_profile(media_profile, update_attrs)
assert media_profile.name == "some updated name" assert media_profile.name == "some updated name"
assert media_profile.output_path_template == "some updated output_path_template" assert media_profile.output_path_template == "new_output_template.{{ ext }}"
end end
test "updating with invalid data returns error changeset" do test "updating with invalid data returns error changeset" do
@ -132,5 +132,41 @@ defmodule Pinchflat.ProfilesTest do
media_profile = media_profile_fixture() media_profile = media_profile_fixture()
assert %Ecto.Changeset{} = Profiles.change_media_profile(media_profile) assert %Ecto.Changeset{} = Profiles.change_media_profile(media_profile)
end end
test "it ensures the media profile's output template ends with an extension" do
valid_templates = [
"output_template.{{ ext }}",
"output_template.{{ext}}",
"output_template.%(ext)s",
"output_template.%(ext)S",
"output_template.%( ext )s",
"output_template.%( ext )S"
]
for template <- valid_templates do
cs = Profiles.change_media_profile(%MediaProfile{}, %{name: "a", output_path_template: template})
assert cs.valid?
end
end
test "it does not allow invalid output templates" do
invalid_templates = [
"output_template.{{ ext }}.something",
"output_template.{{ ext }}",
"output_template{{ ext }}",
"output_template.%(ext)s.something",
"output_template.txt",
"output_template%(ext)s",
"output_template.%(nope)s",
"output_template"
]
for template <- invalid_templates do
cs = Profiles.change_media_profile(%MediaProfile{}, %{name: "a", output_path_template: template})
refute cs.valid?
end
end
end end
end end

View file

@ -101,7 +101,10 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
task_count_fetcher = fn -> Enum.count(Tasks.list_tasks()) end
task_count_fetcher = fn ->
Enum.count(Tasks.list_tasks_for(:source_id, source.id, "MediaCollectionIndexingWorker"))
end
assert_changed([from: 0, to: 1], task_count_fetcher, fn -> assert_changed([from: 0, to: 1], task_count_fetcher, fn ->
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})

View file

@ -8,16 +8,32 @@ defmodule Pinchflat.SourcesTest do
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.MediaIndexingWorker alias Pinchflat.FastIndexing.MediaIndexingWorker
alias Pinchflat.Metadata.SourceMetadataStorageWorker
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
@invalid_source_attrs %{name: nil, collection_id: nil} @invalid_source_attrs %{name: nil, collection_id: nil}
setup :verify_on_exit! setup :verify_on_exit!
describe "schema" do
test "source_metadata is deleted when the source is deleted" do
source =
source_fixture(%{metadata: %{metadata_filepath: "/metadata.json.gz"}})
metadata = source.metadata
assert {:ok, %Source{}} = Sources.delete_source(source)
assert_raise Ecto.NoResultsError, fn ->
Repo.reload!(metadata)
end
end
end
describe "list_sources/0" do describe "list_sources/0" do
test "it returns all sources" do test "it returns all sources" do
source = source_fixture() source = source_fixture()
@ -220,6 +236,21 @@ defmodule Pinchflat.SourcesTest do
assert source.index_frequency_minutes == 0 assert source.index_frequency_minutes == 0
end end
test "creating will kickoff a metadata storage worker" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123",
fast_index: false,
index_frequency_minutes: 0
}
assert {:ok, %Source{} = source} = Sources.create_source(valid_attrs)
assert_enqueued(worker: SourceMetadataStorageWorker, args: %{"id" => source.id})
end
end end
describe "update_source/2" do describe "update_source/2" do
@ -384,6 +415,15 @@ defmodule Pinchflat.SourcesTest do
assert source.index_frequency_minutes == 0 assert source.index_frequency_minutes == 0
end end
test "updating will kickoff a metadata storage worker" do
source = source_fixture()
update_attrs = %{name: "some updated name"}
assert {:ok, %Source{} = source} = Sources.update_source(source, update_attrs)
assert_enqueued(worker: SourceMetadataStorageWorker, args: %{"id" => source.id})
end
end end
describe "delete_source/2" do describe "delete_source/2" do
@ -421,6 +461,22 @@ defmodule Pinchflat.SourcesTest do
assert {:ok, %Source{}} = Sources.delete_source(source) assert {:ok, %Source{}} = Sources.delete_source(source)
assert File.exists?(media_item.media_filepath) assert File.exists?(media_item.media_filepath)
end end
test "deletes the source's metadata files" do
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
source = Repo.preload(source_fixture(), :metadata)
update_attrs = %{
metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, %{})
}
}
{:ok, updated_source} = Sources.update_source(source, update_attrs)
assert {:ok, _} = Sources.delete_source(updated_source, delete_files: true)
refute File.exists?(updated_source.metadata.metadata_filepath)
end
end end
describe "delete_source/2 when deleting files" do describe "delete_source/2 when deleting files" do
@ -452,18 +508,18 @@ defmodule Pinchflat.SourcesTest do
end end
end end
describe "change_source_from_url/2" do describe "maybe_change_source_from_url/2" do
test "it returns a changeset" do test "it returns a changeset" do
stub(YtDlpRunnerMock, :run, &channel_mock/3) stub(YtDlpRunnerMock, :run, &channel_mock/3)
source = source_fixture() source = source_fixture()
assert %Ecto.Changeset{} = Sources.change_source_from_url(source, %{}) assert %Ecto.Changeset{} = Sources.maybe_change_source_from_url(source, %{})
end end
test "it does not fetch source details if the original_url isn't in the changeset" do test "it does not fetch source details if the original_url isn't in the changeset" do
expect(YtDlpRunnerMock, :run, 0, &channel_mock/3) expect(YtDlpRunnerMock, :run, 0, &channel_mock/3)
changeset = Sources.change_source_from_url(%Source{}, %{name: "some updated name"}) changeset = Sources.maybe_change_source_from_url(%Source{}, %{name: "some updated name"})
assert %Ecto.Changeset{} = changeset assert %Ecto.Changeset{} = changeset
end end
@ -472,7 +528,7 @@ defmodule Pinchflat.SourcesTest do
expect(YtDlpRunnerMock, :run, &channel_mock/3) expect(YtDlpRunnerMock, :run, &channel_mock/3)
changeset = changeset =
Sources.change_source_from_url(%Source{}, %{ Sources.maybe_change_source_from_url(%Source{}, %{
original_url: "https://www.youtube.com/channel/abc123" original_url: "https://www.youtube.com/channel/abc123"
}) })
@ -486,7 +542,7 @@ defmodule Pinchflat.SourcesTest do
media_profile_id = media_profile.id media_profile_id = media_profile.id
changeset = changeset =
Sources.change_source_from_url(%Source{}, %{ Sources.maybe_change_source_from_url(%Source{}, %{
original_url: "https://www.youtube.com/channel/abc123", original_url: "https://www.youtube.com/channel/abc123",
media_profile_id: media_profile.id media_profile_id: media_profile.id
}) })
@ -507,7 +563,7 @@ defmodule Pinchflat.SourcesTest do
end) end)
changeset = changeset =
Sources.change_source_from_url(%Source{}, %{ Sources.maybe_change_source_from_url(%Source{}, %{
original_url: "https://www.youtube.com/channel/abc123" original_url: "https://www.youtube.com/channel/abc123"
}) })

View file

@ -108,4 +108,39 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_details(@channel_url) assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_details(@channel_url)
end end
end end
describe "get_source_metadata/1" do
test "it returns a map with data on success" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
Phoenix.json_library().encode(%{channel: "TheUselessTrials"})
end)
assert {:ok, res} = MediaCollection.get_source_metadata(@channel_url)
assert %{"channel" => "TheUselessTrials"} = res
end
test "it passes the expected args to the backend runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
assert opts == [playlist_items: 0]
assert ot == "playlist:%()j"
{:ok, "{}"}
end)
assert {:ok, _} = MediaCollection.get_source_metadata(@channel_url)
end
test "it returns an error if the runner returns an error" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = MediaCollection.get_source_metadata(@channel_url)
end
test "it returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_metadata(@channel_url)
end
end
end end

View file

@ -8,10 +8,10 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Settings alias Pinchflat.Settings
@create_attrs %{name: "some name", output_path_template: "some output_path_template"} @create_attrs %{name: "some name", output_path_template: "output_template.{{ ext }}"}
@update_attrs %{ @update_attrs %{
name: "some updated name", name: "some updated name",
output_path_template: "some updated output_path_template" output_path_template: "new_output_template.{{ ext }}"
} }
@invalid_attrs %{name: nil, output_path_template: nil} @invalid_attrs %{name: nil, output_path_template: nil}

View file

@ -34,6 +34,20 @@ defmodule Pinchflat.SourcesFixtures do
source source
end end
@doc """
Generate a source with metadata.
"""
def source_with_metadata(attrs \\ %{}) do
merged_attrs =
Map.merge(attrs, %{
metadata: %{
metadata_filepath: Application.get_env(:pinchflat, :metadata_directory) <> "/metadata.json.gz"
}
})
source_fixture(merged_attrs)
end
def source_attributes_return_fixture do def source_attributes_return_fixture do
source_attributes = [ source_attributes = [
%{ %{