Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 087c9c2d0f | |||
| c046abf36f | |||
| 70dd95211f | |||
| 8491f8b317 | |||
| d0cb782082 | |||
| 7a7e8080cd | |||
| fbe21cb304 | |||
| 0f3329e97d | |||
| 25eb772896 | |||
| cf59bf99cd |
@@ -128,7 +128,7 @@
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
|
||||
{Credo.Check.Refactor.Nesting, []},
|
||||
{Credo.Check.Refactor.Nesting, [max_nesting: 4]},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, []},
|
||||
{Credo.Check.Refactor.RejectReject, []},
|
||||
{Credo.Check.Refactor.UnlessWithElse, []},
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
This is alpha software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges.
|
||||
|
||||
## EFF Donation Receipts
|
||||
|
||||
A portion of all donations to Pinchflat will be donated to the Electronic Frontier Foundation. [See here](https://github.com/kieraneglin/pinchflat/wiki/EFF-Donation-Receipts) for a list of donation receipts.
|
||||
|
||||
## What is Pinchflat?
|
||||
|
||||
TODO: expand on this.
|
||||
|
||||
@@ -48,7 +48,8 @@ config :pinchflat, Oban,
|
||||
media_indexing: 2,
|
||||
media_collection_indexing: 2,
|
||||
media_fetching: 2,
|
||||
media_local_metadata: 8
|
||||
local_metadata: 8,
|
||||
remote_metadata: 4
|
||||
]
|
||||
|
||||
# Configures the mailer
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule Pinchflat.Boot.DataBackfillWorker do
|
||||
@moduledoc false
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :media_local_metadata,
|
||||
queue: :local_metadata,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, %{}, opts)
|
||||
@@ -31,6 +32,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
@impl true
|
||||
def init(state) do
|
||||
apply_default_settings()
|
||||
ensure_directories_are_writeable()
|
||||
rename_old_job_workers()
|
||||
|
||||
{:ok, state}
|
||||
@@ -41,6 +43,21 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
Settings.fetch!(:pro_enabled, false)
|
||||
end
|
||||
|
||||
defp ensure_directories_are_writeable do
|
||||
directories = [
|
||||
Application.get_env(:pinchflat, :media_directory),
|
||||
Application.get_env(:pinchflat, :tmpfile_directory),
|
||||
Application.get_env(:pinchflat, :metadata_directory)
|
||||
]
|
||||
|
||||
Enum.each(directories, fn dir ->
|
||||
file = Path.join([dir, ".keep"])
|
||||
|
||||
# This will fail if the directory is not writeable, stopping boot
|
||||
FilesystemHelpers.write_p!(file, "")
|
||||
end)
|
||||
end
|
||||
|
||||
# As part of a large refactor, I ended up moving a bunch of workers around. This
|
||||
# is a problem because the workers are stored in the database and the runner
|
||||
# will try to run the OLD jobs. This is also why these tasks run before the job
|
||||
@@ -52,7 +69,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
rename_map = [
|
||||
["Pinchflat.Workers.MediaIndexingWorker", "Pinchflat.FastIndexing.MediaIndexingWorker"],
|
||||
["Pinchflat.Workers.MediaDownloadWorker", "Pinchflat.Downloading.MediaDownloadWorker"],
|
||||
["Pinchflat.Workers.FilesystemDataWorker", "Pinchflat.Filesystem.FilesystemDataWorker"],
|
||||
["Pinchflat.Workers.FastIndexingWorker", "Pinchflat.FastIndexing.FastIndexingWorker"],
|
||||
["Pinchflat.Workers.MediaCollectionIndexingWorker", "Pinchflat.SlowIndexing.MediaCollectionIndexingWorker"],
|
||||
["Pinchflat.Workers.DataBackfillWorker", "Pinchflat.Boot.DataBackfillWorker"]
|
||||
|
||||
@@ -3,6 +3,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
Builds the options for yt-dlp to download media based on the given media profile.
|
||||
"""
|
||||
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.OutputPathBuilder
|
||||
|
||||
@@ -18,7 +19,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
built_options =
|
||||
default_options() ++
|
||||
subtitle_options(media_profile) ++
|
||||
thumbnail_options(media_profile) ++
|
||||
thumbnail_options(media_item_with_preloads) ++
|
||||
metadata_options(media_profile) ++
|
||||
quality_options(media_profile) ++
|
||||
output_options(media_item_with_preloads)
|
||||
@@ -26,6 +27,18 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
{:ok, built_options}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds the output path for yt-dlp to download media based on the given source's
|
||||
media profile.
|
||||
|
||||
Returns binary()
|
||||
"""
|
||||
def build_output_path_for(%Source{} = source_with_preloads) do
|
||||
output_path_template = source_with_preloads.media_profile.output_path_template
|
||||
|
||||
build_output_path(output_path_template, source_with_preloads)
|
||||
end
|
||||
|
||||
defp default_options do
|
||||
[:no_progress, :windows_filenames]
|
||||
end
|
||||
@@ -57,13 +70,16 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
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)
|
||||
|
||||
Enum.reduce(mapped_struct, [], fn attr, acc ->
|
||||
case attr do
|
||||
{: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} ->
|
||||
acc ++ [:embed_thumbnail, convert_thumbnail: "jpg"]
|
||||
@@ -96,30 +112,43 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
:"480p" -> [format_sort: "res:480,#{video_codec_options}"]
|
||||
:"720p" -> [format_sort: "res:720,#{video_codec_options}"]
|
||||
:"1080p" -> [format_sort: "res:1080,#{video_codec_options}"]
|
||||
:"1440p" -> [format_sort: "res:1440,#{video_codec_options}"]
|
||||
:"2160p" -> [format_sort: "res:2160,#{video_codec_options}"]
|
||||
end
|
||||
end
|
||||
|
||||
defp output_options(media_item_with_preloads) do
|
||||
media_profile = media_item_with_preloads.source.media_profile
|
||||
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_for(media_item_with_preloads.source)
|
||||
]
|
||||
end
|
||||
|
||||
defp output_options_map(media_item_with_preloads) do
|
||||
source = media_item_with_preloads.source
|
||||
defp build_output_path(string, source) do
|
||||
additional_options_map = output_options_map(source)
|
||||
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
|
||||
|
||||
Path.join(base_directory(), output_path)
|
||||
end
|
||||
|
||||
defp output_options_map(source) do
|
||||
%{
|
||||
"source_custom_name" => source.custom_name,
|
||||
"source_collection_type" => source.collection_type
|
||||
}
|
||||
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.source)
|
||||
end
|
||||
|
||||
defp base_directory do
|
||||
Application.get_env(:pinchflat, :media_directory)
|
||||
end
|
||||
|
||||
@@ -26,11 +26,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
||||
def enqueue_pending_download_tasks(%Source{download_media: true} = source) do
|
||||
source
|
||||
|> Media.list_pending_media_items_for()
|
||||
|> Enum.each(fn media_item ->
|
||||
%{id: media_item.id}
|
||||
|> MediaDownloadWorker.new()
|
||||
|> Tasks.create_job_with_task(media_item)
|
||||
end)
|
||||
|> Enum.each(&MediaDownloadWorker.kickoff_with_task/1)
|
||||
end
|
||||
|
||||
def enqueue_pending_download_tasks(%Source{download_media: false}) do
|
||||
|
||||
@@ -6,19 +6,32 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||
tags: ["media_item", "media_fetching"]
|
||||
|
||||
require Logger
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Downloading.MediaDownloader
|
||||
alias Pinchflat.Filesystem.FilesystemDataWorker
|
||||
|
||||
@impl Oban.Worker
|
||||
@doc """
|
||||
Starts the media_item media download worker and creates a task for the media_item.
|
||||
|
||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def kickoff_with_task(media_item, opts \\ []) do
|
||||
%{id: media_item.id}
|
||||
|> MediaDownloadWorker.new(opts)
|
||||
|> Tasks.create_job_with_task(media_item)
|
||||
end
|
||||
|
||||
@doc """
|
||||
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 | {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||
"""
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
|
||||
media_item =
|
||||
media_item_id
|
||||
@@ -31,26 +44,30 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
else
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} not found")
|
||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
||||
end
|
||||
|
||||
defp download_media_and_schedule_jobs(media_item) do
|
||||
case MediaDownloader.download_for_media_item(media_item) do
|
||||
{:ok, _} ->
|
||||
schedule_filesystem_data_worker(media_item)
|
||||
{:ok, media_item}
|
||||
{:ok, updated_media_item} ->
|
||||
compute_and_save_media_filesize(updated_media_item)
|
||||
|
||||
{:ok, updated_media_item}
|
||||
|
||||
err ->
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule_filesystem_data_worker(media_item) do
|
||||
%{id: media_item.id}
|
||||
|> FilesystemDataWorker.new()
|
||||
|> Tasks.create_job_with_task(media_item)
|
||||
|> case do
|
||||
{:ok, task} -> {:ok, task}
|
||||
{:error, :duplicate_job} -> {:ok, :job_exists}
|
||||
defp compute_and_save_media_filesize(media_item) do
|
||||
case File.stat(media_item.media_filepath) do
|
||||
{:ok, %{size: size}} ->
|
||||
Media.update_media_item(media_item, %{media_size_bytes: size})
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,11 +8,12 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Metadata.NfoBuilder
|
||||
alias Pinchflat.Metadata.MetadataParser
|
||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||
|
||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||
alias Pinchflat.Downloading.DownloadOptionBuilder, as: YtDlpDownloadOptionBuilder
|
||||
alias Pinchflat.Metadata.MetadataParser, as: YtDlpMetadataParser
|
||||
alias Pinchflat.Metadata.MetadataFileHelpers, as: YtDlpMetadataHelpers
|
||||
|
||||
@doc """
|
||||
Downloads media for a media item, updating the media item based on the metadata
|
||||
@@ -30,16 +31,17 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
||||
|
||||
case download_with_options(media_item.original_url, item_with_preloads) do
|
||||
{:ok, parsed_json} ->
|
||||
{parser, helpers} = {YtDlpMetadataParser, YtDlpMetadataHelpers}
|
||||
|
||||
parsed_attrs =
|
||||
parsed_json
|
||||
|> parser.parse_for_media_item()
|
||||
|> MetadataParser.parse_for_media_item()
|
||||
|> Map.merge(%{
|
||||
media_downloaded_at: DateTime.utc_now(),
|
||||
nfo_filepath: determine_nfo_filepath(item_with_preloads, parsed_json),
|
||||
metadata: %{
|
||||
metadata_filepath: helpers.compress_and_store_metadata_for(media_item, parsed_json),
|
||||
thumbnail_filepath: helpers.download_and_store_thumbnail_for(media_item, parsed_json)
|
||||
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
||||
# could fail and I want to handle that in isolation
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, parsed_json),
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item, parsed_json)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -52,13 +54,18 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
||||
end
|
||||
end
|
||||
|
||||
# def download_for_source(source, url) do
|
||||
# # Create MI from source and URL
|
||||
# media_item = nil
|
||||
# end
|
||||
defp determine_nfo_filepath(media_item, parsed_json) do
|
||||
if media_item.source.media_profile.download_nfo do
|
||||
filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo"
|
||||
|
||||
NfoBuilder.build_and_store_for_media_item(filepath, parsed_json)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp download_with_options(url, item_with_preloads) do
|
||||
{:ok, options} = YtDlpDownloadOptionBuilder.build(item_with_preloads)
|
||||
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads)
|
||||
|
||||
YtDlpMedia.download(url, options)
|
||||
end
|
||||
|
||||
@@ -40,7 +40,9 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
|
||||
"upload_year" => "%(upload_date>%Y)S",
|
||||
"upload_month" => "%(upload_date>%m)S",
|
||||
"upload_day" => "%(upload_date>%d)S",
|
||||
"upload_yyyy_mm_dd" => "%(upload_date>%Y)S-%(upload_date>%m)S-%(upload_date>%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
|
||||
|
||||
@@ -6,34 +6,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
"""
|
||||
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.YoutubeRss
|
||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
||||
|
||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||
|
||||
@doc """
|
||||
Starts tasks for running a fast indexing task for a source's media
|
||||
regardless of the source's fast_index state. It's assumed the
|
||||
caller will check for fast_index.
|
||||
|
||||
This is used for running fast index tasks on update. On creation, the
|
||||
fast index is enqueued after the slow index is complete.
|
||||
|
||||
Returns {:ok, %Task{}}.
|
||||
"""
|
||||
def kickoff_fast_indexing_task(%Source{} = source) do
|
||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
||||
|
||||
%{id: source.id}
|
||||
# Schedule this one immediately, but future ones will be on an interval
|
||||
|> FastIndexingWorker.new()
|
||||
|> Tasks.create_job_with_task(source)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches new media IDs from a source's YouTube RSS feed and kicks off indexing tasks
|
||||
for any new media items. See comments in `MediaIndexingWorker` for more info on the
|
||||
@@ -54,9 +33,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
Enum.each(new_media_ids, fn media_id ->
|
||||
url = "https://www.youtube.com/watch?v=#{media_id}"
|
||||
|
||||
%{id: source.id, media_url: url}
|
||||
|> MediaIndexingWorker.new()
|
||||
|> Tasks.create_job_with_task(source)
|
||||
MediaIndexingWorker.kickoff_with_task(source, url)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -74,9 +51,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
case maybe_media_item do
|
||||
{:ok, media_item} ->
|
||||
if source.download_media && Media.pending_download?(media_item) do
|
||||
%{id: media_item.id}
|
||||
|> MediaDownloadWorker.new()
|
||||
|> Tasks.create_job_with_task(media_item)
|
||||
MediaDownloadWorker.kickoff_with_task(media_item)
|
||||
end
|
||||
|
||||
{:ok, media_item}
|
||||
|
||||
@@ -6,13 +6,25 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||
tags: ["media_source", "fast_indexing"]
|
||||
|
||||
require Logger
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
|
||||
@impl Oban.Worker
|
||||
@doc """
|
||||
Starts the source fast indexing worker and creates a task for the source.
|
||||
|
||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def kickoff_with_task(source, opts \\ []) do
|
||||
%{id: source.id}
|
||||
|> FastIndexingWorker.new(opts)
|
||||
|> Tasks.create_job_with_task(source)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Kicks off the fast indexing process for a source, reschedules the job to run again
|
||||
once complete. See `MediaCollectionIndexingWorker` and `MediaIndexingWorker` comments
|
||||
@@ -20,6 +32,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||
|
||||
Returns :ok | {:ok, :job_exists} | {:ok, %Task{}}
|
||||
"""
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
||||
source = Sources.get_source!(source_id)
|
||||
|
||||
@@ -30,15 +43,15 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||
else
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
|
||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||
end
|
||||
|
||||
defp reschedule_indexing(source) do
|
||||
next_run_in = Source.fast_index_frequency() * 60
|
||||
|
||||
%{id: source.id}
|
||||
|> FastIndexingWorker.new(schedule_in: next_run_in)
|
||||
|> Tasks.create_job_with_task(source)
|
||||
|> case do
|
||||
case kickoff_with_task(source, schedule_in: next_run_in) do
|
||||
{:ok, task} -> {:ok, task}
|
||||
{:error, :duplicate_job} -> {:ok, :job_exists}
|
||||
end
|
||||
|
||||
@@ -8,10 +8,22 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorker do
|
||||
|
||||
require Logger
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
|
||||
@impl Oban.Worker
|
||||
@doc """
|
||||
Starts the fast media indexing worker and creates a task for the source.
|
||||
|
||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def kickoff_with_task(source, media_url, opts \\ []) do
|
||||
%{id: source.id, media_url: media_url}
|
||||
|> MediaIndexingWorker.new(opts)
|
||||
|> Tasks.create_job_with_task(source)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Similar to `MediaCollectionIndexingWorker`, but for individual media items.
|
||||
Does not reschedule or check anything to do with a source's indexing
|
||||
@@ -37,6 +49,7 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorker do
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"id" => source_id, "media_url" => media_url}}) do
|
||||
source = Sources.get_source!(source_id)
|
||||
|
||||
@@ -49,5 +62,8 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorker do
|
||||
end
|
||||
|
||||
:ok
|
||||
rescue
|
||||
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
|
||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
defmodule Pinchflat.Filesystem.FilesystemDataWorker do
|
||||
@moduledoc false
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :media_local_metadata,
|
||||
tags: ["media_item", "media_metadata", "local_metadata"],
|
||||
max_attempts: 1
|
||||
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
@impl Oban.Worker
|
||||
@doc """
|
||||
For a given media item, compute and save metadata about the file on-disk.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
|
||||
media_item = Media.get_media_item!(media_item_id)
|
||||
|
||||
FilesystemHelpers.compute_and_save_media_filesize(media_item)
|
||||
|
||||
# Don't retry on failure - if it didn't work immediately there's no
|
||||
# reason to believe it will work later.
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -15,12 +15,38 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
|
||||
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
filepath = Path.join([tmpfile_directory, "#{StringUtils.random_string(64)}.#{type}"])
|
||||
|
||||
:ok = File.mkdir_p!(Path.dirname(filepath))
|
||||
:ok = File.write(filepath, "")
|
||||
:ok = write_p!(filepath, "")
|
||||
|
||||
filepath
|
||||
end
|
||||
|
||||
@doc """
|
||||
Writes content to a file, creating directories as needed.
|
||||
Takes the same args as File.write!/3.
|
||||
|
||||
Returns :ok | raises on error
|
||||
"""
|
||||
def write_p!(filepath, content, modes \\ []) do
|
||||
filepath
|
||||
|> Path.dirname()
|
||||
|> File.mkdir_p!()
|
||||
|
||||
File.write!(filepath, content, modes)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Copies a file from source to destination, creating directories as needed.
|
||||
|
||||
Returns :ok | raises on error
|
||||
"""
|
||||
def cp_p!(source, destination) do
|
||||
destination
|
||||
|> Path.dirname()
|
||||
|> File.mkdir_p!()
|
||||
|
||||
File.cp!(source, destination)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches the file size of a media item and saves it to the database.
|
||||
|
||||
@@ -35,4 +61,36 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a file and removes any empty directories in the path.
|
||||
Does NOT remove any directories that are not empty.
|
||||
|
||||
Returns :ok | {:error, any()}
|
||||
"""
|
||||
def delete_file_and_remove_empty_directories(filepath) do
|
||||
case File.rm(filepath) do
|
||||
:ok ->
|
||||
filepath
|
||||
|> Path.dirname()
|
||||
|> recursively_delete_empty_directories()
|
||||
|
||||
err ->
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
defp recursively_delete_empty_directories(directory) do
|
||||
case File.rmdir(directory) do
|
||||
:ok ->
|
||||
directory
|
||||
|> Path.dirname()
|
||||
|> recursively_delete_empty_directories()
|
||||
|
||||
err ->
|
||||
err
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,9 +7,10 @@ defmodule Pinchflat.Media do
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
@doc """
|
||||
Returns the list of media_items.
|
||||
@@ -141,38 +142,6 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
def get_media_item!(id), do: Repo.get!(MediaItem, id)
|
||||
|
||||
@doc """
|
||||
Produces a flat list of the filesystem paths for a media_item's downloaded files
|
||||
|
||||
Returns [binary()]
|
||||
"""
|
||||
def media_filepaths(media_item) do
|
||||
mapped_struct = Map.from_struct(media_item)
|
||||
|
||||
MediaItem.filepath_attributes()
|
||||
|> Enum.map(fn
|
||||
:subtitle_filepaths = field -> Enum.map(mapped_struct[field], fn [_, filepath] -> filepath end)
|
||||
field -> List.wrap(mapped_struct[field])
|
||||
end)
|
||||
|> List.flatten()
|
||||
|> Enum.filter(&is_binary/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
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 [binary()] | []
|
||||
"""
|
||||
def metadata_filepaths(media_item) do
|
||||
metadata = Repo.preload(media_item, :metadata).metadata || %MediaMetadata{}
|
||||
mapped_struct = Map.from_struct(metadata)
|
||||
|
||||
MediaMetadata.filepath_attributes()
|
||||
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||
|> Enum.filter(&is_binary/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a media_item.
|
||||
|
||||
@@ -219,19 +188,22 @@ defmodule Pinchflat.Media do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a media_item and its associated tasks.
|
||||
Can optionally delete the media_item's files.
|
||||
Deletes a media_item, its associated tasks, and our internal metadata files.
|
||||
Can optionally delete the media_item's media files (media, thumbnail, subtitles, etc).
|
||||
|
||||
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def delete_media_item(%MediaItem{} = media_item, opts \\ []) do
|
||||
delete_files = Keyword.get(opts, :delete_files, false)
|
||||
|
||||
Tasks.delete_tasks_for(media_item)
|
||||
|
||||
if delete_files do
|
||||
{:ok, _} = delete_all_attachments(media_item)
|
||||
{:ok, _} = delete_media_files(media_item)
|
||||
end
|
||||
|
||||
Tasks.delete_tasks_for(media_item)
|
||||
# Should delete these no matter what
|
||||
delete_internal_metadata_files(media_item)
|
||||
Repo.delete(media_item)
|
||||
end
|
||||
|
||||
@@ -242,26 +214,31 @@ defmodule Pinchflat.Media do
|
||||
MediaItem.changeset(media_item, attrs)
|
||||
end
|
||||
|
||||
defp delete_all_attachments(media_item) do
|
||||
media_item = Repo.preload(media_item, :metadata)
|
||||
defp delete_media_files(media_item) do
|
||||
mapped_struct = Map.from_struct(media_item)
|
||||
|
||||
media_item
|
||||
|> media_filepaths()
|
||||
|> Enum.concat(metadata_filepaths(media_item))
|
||||
|> Enum.each(&File.rm/1)
|
||||
|
||||
# rmdir will attempt to delete the directory, but only if it is empty
|
||||
if media_item.media_filepath do
|
||||
File.rmdir(Path.dirname(media_item.media_filepath))
|
||||
end
|
||||
|
||||
if media_item.metadata && media_item.metadata.metadata_filepath do
|
||||
File.rmdir(Path.dirname(media_item.metadata.metadata_filepath))
|
||||
end
|
||||
MediaItem.filepath_attributes()
|
||||
|> Enum.map(fn
|
||||
:subtitle_filepaths = field -> Enum.map(mapped_struct[field], fn [_, filepath] -> filepath end)
|
||||
field -> List.wrap(mapped_struct[field])
|
||||
end)
|
||||
|> List.flatten()
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||
|
||||
{:ok, media_item}
|
||||
end
|
||||
|
||||
defp delete_internal_metadata_files(media_item) do
|
||||
metadata = Repo.preload(media_item, :metadata).metadata || %MediaMetadata{}
|
||||
mapped_struct = Map.from_struct(metadata)
|
||||
|
||||
MediaMetadata.filepath_attributes()
|
||||
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||
end
|
||||
|
||||
defp maybe_apply_cutoff_date(source) do
|
||||
if source.download_cutoff_date do
|
||||
dynamic([mi], mi.upload_date >= ^source.download_cutoff_date)
|
||||
|
||||
@@ -27,7 +27,8 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
:media_size_bytes,
|
||||
:subtitle_filepaths,
|
||||
:thumbnail_filepath,
|
||||
:metadata_filepath
|
||||
:metadata_filepath,
|
||||
:nfo_filepath
|
||||
]
|
||||
# Pretty much all the fields captured at index are required.
|
||||
@required_fields ~w(
|
||||
@@ -54,6 +55,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
field :media_size_bytes, :integer
|
||||
field :thumbnail_filepath, :string
|
||||
field :metadata_filepath, :string
|
||||
field :nfo_filepath, :string
|
||||
# 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.
|
||||
# Will very likely revisit because I can't leave well-enough alone.
|
||||
@@ -82,6 +84,6 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
|
||||
@doc false
|
||||
def filepath_attributes do
|
||||
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths)a
|
||||
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,6 +9,8 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
needed
|
||||
"""
|
||||
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
@doc """
|
||||
Compresses and stores metadata for a media item, returning the filepath.
|
||||
|
||||
@@ -18,8 +20,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
filepath = generate_filepath_for(database_record, "metadata.json.gz")
|
||||
{:ok, json} = Phoenix.json_library().encode(metadata_map)
|
||||
|
||||
File.mkdir_p!(Path.dirname(filepath))
|
||||
:ok = File.write(filepath, json, [:compressed])
|
||||
:ok = FilesystemHelpers.write_p!(filepath, json, [:compressed])
|
||||
|
||||
filepath
|
||||
end
|
||||
@@ -45,12 +46,60 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
|
||||
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
|
||||
|
||||
File.mkdir_p!(Path.dirname(filepath))
|
||||
:ok = File.write(filepath, thumbnail_blob)
|
||||
:ok = FilesystemHelpers.write_p!(filepath, thumbnail_blob)
|
||||
|
||||
filepath
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses an upload date from the YYYYMMDD string returned in yt-dlp metadata
|
||||
and returns a Date struct.
|
||||
|
||||
Returns Date.t()
|
||||
"""
|
||||
def parse_upload_date(upload_date) do
|
||||
<<year::binary-size(4)>> <> <<month::binary-size(2)>> <> <<day::binary-size(2)>> = upload_date
|
||||
|
||||
Date.from_iso8601!("#{year}-#{month}-#{day}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts to determine the series directory from a media filepath.
|
||||
The series directory is the "root" directory for a given source
|
||||
which should contain all the season-level folders of that source.
|
||||
|
||||
Used for determining where to store things like NFO data and banners
|
||||
for media center apps. Not useful without a media center app.
|
||||
|
||||
Returns {:ok, binary()} | {:error, :indeterminable}
|
||||
"""
|
||||
def series_directory_from_media_filepath(media_filepath) do
|
||||
# Matches "s" or "season" (case-insensitive)
|
||||
# followed by an optional non-word character (. or _ or <space>, etc)
|
||||
# followed by at least one digit
|
||||
# followed immediately by the end of the string
|
||||
# Example matches: s1, s.1, s01 season 1, Season.01, Season_1, Season 1, Season1
|
||||
# Example non-matches: s01e01, season, series 1,
|
||||
season_regex = ~r/^s(eason)?(\W|_)?\d{1,}$/i
|
||||
|
||||
{series_directory, found_series_directory} =
|
||||
media_filepath
|
||||
|> Path.split()
|
||||
|> Enum.reduce_while({[], false}, fn part, {directory_acc, _} ->
|
||||
if String.match?(part, season_regex) do
|
||||
{:halt, {directory_acc, true}}
|
||||
else
|
||||
{:cont, {directory_acc ++ [part], false}}
|
||||
end
|
||||
end)
|
||||
|
||||
if found_series_directory do
|
||||
{:ok, Path.join(series_directory)}
|
||||
else
|
||||
{:error, :indeterminable}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_thumbnail_from_url(url) do
|
||||
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
||||
{:ok, body} = http_client.get(url, [], body_format: :binary)
|
||||
|
||||
@@ -54,9 +54,22 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
||||
|> Enum.reverse()
|
||||
|> Enum.find_value(fn attrs -> attrs["filepath"] end)
|
||||
|
||||
%{
|
||||
thumbnail_filepath: thumbnail_filepath
|
||||
}
|
||||
if thumbnail_filepath do
|
||||
# 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
|
||||
|
||||
defp parse_infojson_metadata(metadata) do
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
defmodule Pinchflat.Metadata.NfoBuilder do
|
||||
@moduledoc """
|
||||
Provides methods for building and storing NFO files for
|
||||
use by Kodi/Jellyfin and other media center software.
|
||||
"""
|
||||
|
||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
@doc """
|
||||
Builds an NFO file for a media item (read: single "episode") and
|
||||
stores it at the specified location.
|
||||
|
||||
Returns the filepath of the NFO file.
|
||||
"""
|
||||
def build_and_store_for_media_item(filepath, metadata) do
|
||||
nfo = build_for_media_item(metadata)
|
||||
|
||||
FilesystemHelpers.write_p!(filepath, nfo)
|
||||
|
||||
filepath
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds an NFO file for a souce and stores it at the specified location.
|
||||
Technically works for playlists, but it's really made for channels.
|
||||
|
||||
Returns the filepath of the NFO file.
|
||||
"""
|
||||
def build_and_store_for_source(filepath, metadata) do
|
||||
nfo = build_for_source(metadata)
|
||||
|
||||
FilesystemHelpers.write_p!(filepath, nfo)
|
||||
|
||||
filepath
|
||||
end
|
||||
|
||||
defp build_for_media_item(metadata) do
|
||||
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
|
||||
# Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub.
|
||||
# WHO NEEDS A FANCY XML PARSER ANYWAY?!
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
||||
<episodedetails>
|
||||
<title>#{metadata["title"]}</title>
|
||||
<showtitle>#{metadata["uploader"]}</showtitle>
|
||||
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
|
||||
<plot>#{metadata["description"]}</plot>
|
||||
<aired>#{upload_date}</aired>
|
||||
<season>#{upload_date.year}</season>
|
||||
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
|
||||
<genre>YouTube</genre>
|
||||
</episodedetails>
|
||||
"""
|
||||
end
|
||||
|
||||
defp build_for_source(metadata) do
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
||||
<tvshow>
|
||||
<title>#{metadata["title"]}</title>
|
||||
<plot>#{metadata["description"]}</plot>
|
||||
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
|
||||
<genre>YouTube</genre>
|
||||
</tvshow>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
defmodule Pinchflat.Metadata.SourceImageParser do
|
||||
@moduledoc """
|
||||
Functions for parsing and storing source images.
|
||||
"""
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
@doc """
|
||||
Given a base directory and source metadata, look for the appropriate images
|
||||
and move them to the base directory. Returns a map of image types and their
|
||||
filepaths (specifically for a source). If a given image type is not found,
|
||||
the key will not be present in the map.
|
||||
|
||||
The metadata is expected to contain the `filepath` key for images, implying
|
||||
that the images have already been downloaded by yt-dlp. This method will NOT
|
||||
download images from the internet - it just copys them around.
|
||||
|
||||
Returns a map with the possible keys :poster_filepath, :fanart_filepath, and
|
||||
:banner_filepath.
|
||||
"""
|
||||
def store_source_images(base_directory, source_metadata) do
|
||||
(source_metadata["thumbnails"] || [])
|
||||
|> Enum.filter(&(&1["filepath"] != nil))
|
||||
|> select_useful_images()
|
||||
|> Enum.map(&move_image(&1, base_directory))
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
defp select_useful_images(images) do
|
||||
labelled_images =
|
||||
Enum.reduce(images, [], fn image_map, acc ->
|
||||
case image_map do
|
||||
%{"id" => "avatar_uncropped"} ->
|
||||
acc ++ [{:poster, :poster_filepath, image_map["filepath"]}]
|
||||
|
||||
%{"id" => "banner_uncropped"} ->
|
||||
acc ++ [{:fanart, :fanart_filepath, image_map["filepath"]}]
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
labelled_images
|
||||
|> Enum.concat([{:banner, :banner_filepath, determine_best_banner(images)}])
|
||||
|> Enum.filter(fn {_, _, tmp_filepath} -> tmp_filepath end)
|
||||
end
|
||||
|
||||
defp determine_best_banner(images) do
|
||||
best_candidate =
|
||||
images
|
||||
# Filter out images that don't have a width and height attribute
|
||||
|> Enum.filter(&(&1["width"] && &1["height"]))
|
||||
# Sort images with the highest width first
|
||||
|> Enum.sort(&(&1["width"] >= &2["width"]))
|
||||
# Find the first image where the ratio of width to height is greater than 3
|
||||
|> Enum.find(&(&1["width"] / &1["height"] > 3))
|
||||
|
||||
Map.get(best_candidate || %{}, "filepath")
|
||||
end
|
||||
|
||||
defp move_image({filename, source_attr_name, tmp_filepath}, base_directory) do
|
||||
extension = Path.extname(tmp_filepath)
|
||||
final_filepath = Path.join([base_directory, "#{filename}#{extension}"])
|
||||
|
||||
FilesystemHelpers.cp_p!(tmp_filepath, final_filepath)
|
||||
|
||||
{source_attr_name, final_filepath}
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,111 @@
|
||||
defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
||||
@moduledoc false
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :remote_metadata,
|
||||
tags: ["media_source", "source_metadata", "remote_metadata"],
|
||||
max_attempts: 3
|
||||
|
||||
require Logger
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Utils.StringUtils
|
||||
alias Pinchflat.Metadata.NfoBuilder
|
||||
alias Pinchflat.YtDlp.MediaCollection
|
||||
alias Pinchflat.Metadata.SourceImageParser
|
||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||
|
||||
@doc """
|
||||
Starts the source metadata storage worker and creates a task for the source.
|
||||
|
||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def kickoff_with_task(source, opts \\ []) do
|
||||
%{id: source.id}
|
||||
|> SourceMetadataStorageWorker.new(opts)
|
||||
|> Tasks.create_job_with_task(source)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches and stores various forms of metadata for a source:
|
||||
- JSON metadata for internal use
|
||||
- The series directory for the source
|
||||
- The NFO file for the source (if specified)
|
||||
- Downloads and stores source images (if specified)
|
||||
|
||||
The worker is kicked off after a source is inserted/updated - this can
|
||||
take an unknown amount of time so don't rely on this data being here
|
||||
before, say, the first indexing or downloading task is complete.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
||||
source = Repo.preload(Sources.get_source!(source_id), [:metadata, :media_profile])
|
||||
series_directory = determine_series_directory(source)
|
||||
{source_metadata, image_filepath_attrs} = fetch_source_metadata_and_images(series_directory, source)
|
||||
|
||||
# `run_post_commit_tasks: false` prevents this from running in an infinite loop
|
||||
Sources.update_source(
|
||||
source,
|
||||
Map.merge(
|
||||
%{
|
||||
series_directory: series_directory,
|
||||
nfo_filepath: store_source_nfo(source, series_directory, source_metadata),
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, source_metadata)
|
||||
}
|
||||
},
|
||||
image_filepath_attrs
|
||||
),
|
||||
run_post_commit_tasks: false
|
||||
)
|
||||
|
||||
:ok
|
||||
rescue
|
||||
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
|
||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||
end
|
||||
|
||||
defp fetch_source_metadata_and_images(series_directory, source) do
|
||||
if source.media_profile.download_source_images && series_directory do
|
||||
output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
|
||||
opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: output_path]
|
||||
|
||||
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts)
|
||||
image_attrs = SourceImageParser.store_source_images(series_directory, metadata)
|
||||
|
||||
{metadata, image_attrs}
|
||||
else
|
||||
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, [])
|
||||
|
||||
{metadata, %{}}
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_series_directory(source) do
|
||||
output_path = DownloadOptionBuilder.build_output_path_for(source)
|
||||
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, output: output_path)
|
||||
|
||||
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
|
||||
{:ok, series_directory} -> series_directory
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp store_source_nfo(source, series_directory, metadata) do
|
||||
if source.media_profile.download_nfo && series_directory do
|
||||
nfo_filepath = Path.join(series_directory, "tvshow.nfo")
|
||||
|
||||
NfoBuilder.build_and_store_for_source(nfo_filepath, metadata)
|
||||
end
|
||||
end
|
||||
|
||||
defp tmp_directory do
|
||||
Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
end
|
||||
end
|
||||
@@ -17,8 +17,10 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
sub_langs
|
||||
download_thumbnail
|
||||
embed_thumbnail
|
||||
download_source_images
|
||||
download_metadata
|
||||
embed_metadata
|
||||
download_nfo
|
||||
shorts_behaviour
|
||||
livestream_behaviour
|
||||
preferred_resolution
|
||||
@@ -32,17 +34,19 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
field :output_path_template, :string,
|
||||
default: "/{{ source_custom_name }}/{{ upload_yyyy_mm_dd }} {{ title }}/{{ title }} [{{ id }}].{{ ext }}"
|
||||
|
||||
field :download_subs, :boolean, default: true
|
||||
field :download_auto_subs, :boolean, default: true
|
||||
field :embed_subs, :boolean, default: true
|
||||
field :download_subs, :boolean, default: false
|
||||
field :download_auto_subs, :boolean, default: false
|
||||
field :embed_subs, :boolean, default: false
|
||||
field :sub_langs, :string, default: "en"
|
||||
|
||||
field :download_thumbnail, :boolean, default: true
|
||||
field :embed_thumbnail, :boolean, default: true
|
||||
field :download_thumbnail, :boolean, default: false
|
||||
field :embed_thumbnail, :boolean, default: false
|
||||
field :download_source_images, :boolean, default: false
|
||||
|
||||
field :download_metadata, :boolean, default: true
|
||||
field :embed_metadata, :boolean, default: true
|
||||
field :download_metadata, :boolean, default: false
|
||||
field :embed_metadata, :boolean, default: false
|
||||
|
||||
field :download_nfo, :boolean, default: false
|
||||
# NOTE: these do NOT speed up indexing - the indexer still has to go
|
||||
# through the entire collection to determine if a media is a short or
|
||||
# a livestream.
|
||||
@@ -65,6 +69,12 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
media_profile
|
||||
|> cast(attrs, @allowed_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)
|
||||
end
|
||||
|
||||
defp ext_regex do
|
||||
~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||
tags: ["media_source", "media_collection_indexing"]
|
||||
|
||||
require Logger
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources
|
||||
@@ -13,7 +15,17 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||
|
||||
@impl Oban.Worker
|
||||
@doc """
|
||||
Starts the source slow indexing worker and creates a task for the source.
|
||||
|
||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def kickoff_with_task(source, opts \\ []) do
|
||||
%{id: source.id}
|
||||
|> MediaCollectionIndexingWorker.new(opts)
|
||||
|> Tasks.create_job_with_task(source)
|
||||
end
|
||||
|
||||
@doc """
|
||||
The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
|
||||
the provided source, kicks off downloads for each new MediaItem, and
|
||||
@@ -58,6 +70,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||
|
||||
Returns :ok | {:ok, %Task{}}
|
||||
"""
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
||||
source = Sources.get_source!(source_id)
|
||||
|
||||
@@ -79,6 +92,9 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||
# perform a no-op
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
|
||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||
end
|
||||
|
||||
defp reschedule_indexing(source) do
|
||||
|
||||
@@ -31,10 +31,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
|
||||
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
||||
|
||||
%{id: source.id}
|
||||
# Schedule this one immediately, but future ones will be on an interval
|
||||
|> MediaCollectionIndexingWorker.new()
|
||||
|> Tasks.create_job_with_task(source)
|
||||
MediaCollectionIndexingWorker.kickoff_with_task(source)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -125,9 +122,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||
if source.download_media && Media.pending_download?(media_item) do
|
||||
Logger.debug("FileFollowerServer Handler: Enqueuing download task for #{inspect(media_attrs)}")
|
||||
|
||||
%{id: media_item.id}
|
||||
|> MediaDownloadWorker.new()
|
||||
|> Tasks.create_job_with_task(media_item)
|
||||
MediaDownloadWorker.kickoff_with_task(media_item)
|
||||
end
|
||||
|
||||
{:error, changeset} ->
|
||||
|
||||
@@ -10,12 +10,18 @@ defmodule Pinchflat.Sources.Source do
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
alias Pinchflat.Metadata.SourceMetadata
|
||||
|
||||
@allowed_fields ~w(
|
||||
collection_name
|
||||
collection_id
|
||||
collection_type
|
||||
custom_name
|
||||
nfo_filepath
|
||||
poster_filepath
|
||||
fanart_filepath
|
||||
banner_filepath
|
||||
series_directory
|
||||
index_frequency_minutes
|
||||
fast_index
|
||||
download_media
|
||||
@@ -28,7 +34,8 @@ defmodule Pinchflat.Sources.Source do
|
||||
# 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.
|
||||
# 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(
|
||||
index_frequency_minutes
|
||||
fast_index
|
||||
@@ -50,6 +57,11 @@ defmodule Pinchflat.Sources.Source do
|
||||
field :collection_name, :string
|
||||
field :collection_id, :string
|
||||
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
|
||||
field :nfo_filepath, :string
|
||||
field :poster_filepath, :string
|
||||
field :fanart_filepath, :string
|
||||
field :banner_filepath, :string
|
||||
field :series_directory, :string
|
||||
field :index_frequency_minutes, :integer, default: 60 * 24
|
||||
field :fast_index, :boolean, default: false
|
||||
field :download_media, :boolean, default: true
|
||||
@@ -60,6 +72,8 @@ defmodule Pinchflat.Sources.Source do
|
||||
|
||||
belongs_to :media_profile, MediaProfile
|
||||
|
||||
has_one :metadata, SourceMetadata, on_replace: :update
|
||||
|
||||
has_many :tasks, Task
|
||||
has_many :media_items, MediaItem, foreign_key: :source_id
|
||||
|
||||
@@ -80,6 +94,7 @@ defmodule Pinchflat.Sources.Source do
|
||||
|> cast(attrs, @allowed_fields)
|
||||
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|
||||
|> validate_required(required_fields)
|
||||
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|
||||
|> unique_constraint([:collection_id, :media_profile_id])
|
||||
end
|
||||
|
||||
@@ -94,4 +109,9 @@ defmodule Pinchflat.Sources.Source do
|
||||
# minutes
|
||||
15
|
||||
end
|
||||
|
||||
@doc false
|
||||
def filepath_attributes do
|
||||
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,9 +11,12 @@ defmodule Pinchflat.Sources do
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
alias Pinchflat.YtDlp.MediaCollection
|
||||
alias Pinchflat.Metadata.SourceMetadata
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
alias Pinchflat.Downloading.DownloadingHelpers
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||
|
||||
@doc """
|
||||
Returns the list of sources. Returns [%Source{}, ...]
|
||||
@@ -48,15 +51,19 @@ defmodule Pinchflat.Sources do
|
||||
though we know it's going to fail so it picks up any addl. database errors
|
||||
and fulfills our return contract.
|
||||
|
||||
You can pass options to control the behavior of the function:
|
||||
- `run_post_commit_tasks` (default: true) - If false, the function will not
|
||||
enqueue any tasks in `commit_and_handle_tasks`.
|
||||
|
||||
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def create_source(attrs) do
|
||||
def create_source(attrs, opts \\ []) do
|
||||
case change_source(%Source{}, attrs, :initial) do
|
||||
%Ecto.Changeset{valid?: true} ->
|
||||
%Source{}
|
||||
|> change_source_from_url(attrs)
|
||||
|> maybe_change_source_from_url(attrs)
|
||||
|> maybe_change_indexing_frequency()
|
||||
|> commit_and_handle_tasks()
|
||||
|> commit_and_handle_tasks(opts)
|
||||
|
||||
changeset ->
|
||||
Repo.insert(changeset)
|
||||
@@ -76,15 +83,19 @@ defmodule Pinchflat.Sources do
|
||||
though we know it's going to fail so it picks up any addl. database errors
|
||||
and fulfills our return contract.
|
||||
|
||||
You can pass options to control the behavior of the function:
|
||||
- `run_post_commit_tasks` (default: true) - If false, the function will not
|
||||
enqueue any tasks in `commit_and_handle_tasks`.
|
||||
|
||||
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def update_source(%Source{} = source, attrs) do
|
||||
def update_source(%Source{} = source, attrs, opts \\ []) do
|
||||
case change_source(source, attrs, :initial) do
|
||||
%Ecto.Changeset{valid?: true} ->
|
||||
source
|
||||
|> change_source_from_url(attrs)
|
||||
|> maybe_change_source_from_url(attrs)
|
||||
|> maybe_change_indexing_frequency()
|
||||
|> commit_and_handle_tasks()
|
||||
|> commit_and_handle_tasks(opts)
|
||||
|
||||
changeset ->
|
||||
Repo.update(changeset)
|
||||
@@ -99,6 +110,7 @@ defmodule Pinchflat.Sources do
|
||||
"""
|
||||
def delete_source(%Source{} = source, opts \\ []) do
|
||||
delete_files = Keyword.get(opts, :delete_files, false)
|
||||
Tasks.delete_tasks_for(source)
|
||||
|
||||
source
|
||||
|> Media.list_media_items_for()
|
||||
@@ -106,7 +118,11 @@ defmodule Pinchflat.Sources do
|
||||
Media.delete_media_item(media_item, delete_files: delete_files)
|
||||
end)
|
||||
|
||||
Tasks.delete_tasks_for(source)
|
||||
if delete_files do
|
||||
delete_source_files(source)
|
||||
end
|
||||
|
||||
delete_internal_metadata_files(source)
|
||||
Repo.delete(source)
|
||||
end
|
||||
|
||||
@@ -117,19 +133,9 @@ defmodule Pinchflat.Sources do
|
||||
Source.changeset(source, attrs, validation_stage)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking source changes and additionally
|
||||
fetches source details from the original_url (if provided). If the source
|
||||
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
|
||||
to the source creation/update process. Should be used only when needed.
|
||||
"""
|
||||
def change_source_from_url(%Source{} = source, attrs) do
|
||||
# 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.
|
||||
defp maybe_change_source_from_url(%Source{} = source, attrs) do
|
||||
case change_source(source, attrs) do
|
||||
%Ecto.Changeset{changes: %{original_url: _}} = changeset ->
|
||||
add_source_details_to_changeset(source, changeset)
|
||||
@@ -139,10 +145,27 @@ defmodule Pinchflat.Sources do
|
||||
end
|
||||
end
|
||||
|
||||
defp add_source_details_to_changeset(source, changeset) do
|
||||
%Ecto.Changeset{changes: changes} = changeset
|
||||
defp delete_source_files(source) do
|
||||
mapped_struct = Map.from_struct(source)
|
||||
|
||||
case MediaCollection.get_source_details(changes.original_url) do
|
||||
Source.filepath_attributes()
|
||||
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||
end
|
||||
|
||||
defp delete_internal_metadata_files(source) do
|
||||
metadata = Repo.preload(source, :metadata).metadata || %SourceMetadata{}
|
||||
mapped_struct = Map.from_struct(metadata)
|
||||
|
||||
SourceMetadata.filepath_attributes()
|
||||
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||
end
|
||||
|
||||
defp add_source_details_to_changeset(source, changeset) do
|
||||
case MediaCollection.get_source_details(changeset.changes.original_url) do
|
||||
{:ok, source_details} ->
|
||||
add_source_details_by_collection_type(source, changeset, source_details)
|
||||
|
||||
@@ -191,11 +214,18 @@ defmodule Pinchflat.Sources do
|
||||
end
|
||||
end
|
||||
|
||||
defp commit_and_handle_tasks(changeset) do
|
||||
defp commit_and_handle_tasks(changeset, opts) do
|
||||
run_post_commit_tasks = Keyword.get(opts, :run_post_commit_tasks, true)
|
||||
|
||||
case Repo.insert_or_update(changeset) do
|
||||
{:ok, %Source{} = source} ->
|
||||
maybe_handle_media_tasks(changeset, source)
|
||||
maybe_run_indexing_task(changeset, source)
|
||||
if run_post_commit_tasks do
|
||||
maybe_handle_media_tasks(changeset, source)
|
||||
maybe_run_indexing_task(changeset, source)
|
||||
run_metadata_storage_task(source)
|
||||
end
|
||||
|
||||
{:ok, source}
|
||||
|
||||
err ->
|
||||
err
|
||||
@@ -215,8 +245,6 @@ defmodule Pinchflat.Sources do
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
{:ok, source}
|
||||
end
|
||||
|
||||
defp maybe_run_indexing_task(changeset, source) do
|
||||
@@ -231,8 +259,11 @@ defmodule Pinchflat.Sources do
|
||||
maybe_update_slow_indexing_task(changeset, source)
|
||||
maybe_update_fast_indexing_task(changeset, source)
|
||||
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
|
||||
|
||||
defp maybe_update_slow_indexing_task(changeset, source) do
|
||||
@@ -253,7 +284,8 @@ defmodule Pinchflat.Sources do
|
||||
defp maybe_update_fast_indexing_task(changeset, source) do
|
||||
case changeset.changes do
|
||||
%{fast_index: true} ->
|
||||
FastIndexingHelpers.kickoff_fast_indexing_task(source)
|
||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
||||
FastIndexingWorker.kickoff_with_task(source)
|
||||
|
||||
%{fast_index: false} ->
|
||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
||||
|
||||
@@ -22,9 +22,15 @@ defmodule Pinchflat.Tasks do
|
||||
|
||||
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(record, worker_name \\ nil, job_states \\ Oban.Job.states()) do
|
||||
stringified_states = Enum.map(job_states, &to_string/1)
|
||||
|
||||
record_type =
|
||||
case record do
|
||||
%Source{} -> :source_id
|
||||
%MediaItem{} -> :media_item_id
|
||||
end
|
||||
|
||||
worker_name_finder =
|
||||
if worker_name do
|
||||
# Workers are the full module name - we want to match on the string ENDING with
|
||||
@@ -41,7 +47,7 @@ defmodule Pinchflat.Tasks do
|
||||
Repo.all(
|
||||
from t in Task,
|
||||
join: j in assoc(t, :job),
|
||||
where: field(t, ^attached_record_type) == ^attached_record_id,
|
||||
where: field(t, ^record_type) == ^record.id,
|
||||
where: ^worker_name_finder,
|
||||
where: j.state in ^stringified_states
|
||||
)
|
||||
@@ -53,10 +59,9 @@ defmodule Pinchflat.Tasks do
|
||||
|
||||
Returns [%Task{}, ...]
|
||||
"""
|
||||
def list_pending_tasks_for(attached_record_type, attached_record_id, worker_name \\ nil) do
|
||||
def list_pending_tasks_for(record, worker_name \\ nil) do
|
||||
list_tasks_for(
|
||||
attached_record_type,
|
||||
attached_record_id,
|
||||
record,
|
||||
worker_name,
|
||||
[:available, :scheduled, :retryable]
|
||||
)
|
||||
@@ -126,14 +131,10 @@ defmodule Pinchflat.Tasks do
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
def delete_tasks_for(attached_record, worker_name \\ nil) do
|
||||
tasks =
|
||||
case attached_record do
|
||||
%Source{} = source -> list_tasks_for(:source_id, source.id, worker_name)
|
||||
%MediaItem{} = media_item -> list_tasks_for(:media_item_id, media_item.id, worker_name)
|
||||
end
|
||||
|
||||
Enum.each(tasks, &delete_task/1)
|
||||
def delete_tasks_for(record, worker_name \\ nil) do
|
||||
record
|
||||
|> list_tasks_for(worker_name)
|
||||
|> Enum.each(&delete_task/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -142,14 +143,10 @@ defmodule Pinchflat.Tasks do
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
def delete_pending_tasks_for(attached_record, worker_name \\ nil) do
|
||||
tasks =
|
||||
case attached_record do
|
||||
%Source{} = source -> list_pending_tasks_for(:source_id, source.id, worker_name)
|
||||
%MediaItem{} = media_item -> list_pending_tasks_for(:media_item_id, media_item.id, worker_name)
|
||||
end
|
||||
|
||||
Enum.each(tasks, &delete_task/1)
|
||||
def delete_pending_tasks_for(record, worker_name \\ nil) do
|
||||
record
|
||||
|> list_pending_tasks_for(worker_name)
|
||||
|> Enum.each(&delete_task/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
||||
@@ -37,7 +37,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
||||
|
||||
case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do
|
||||
{_, 0} ->
|
||||
# IDEA: consider deleting the file after reading it
|
||||
# IDEA: consider deleting the file after reading it. It's in the tmp dir, so it's not
|
||||
# a huge deal, but it's still a good idea to clean up after ourselves.
|
||||
# (even on error? especially on error?)
|
||||
File.read(output_filepath)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||
|
||||
@doc """
|
||||
Downloads a single piece of media (and possibly its metadata) directly to its
|
||||
@@ -86,7 +87,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
original_url: response["webpage_url"],
|
||||
livestream: response["was_live"],
|
||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
||||
upload_date: response["upload_date"] && parse_upload_date(response["upload_date"])
|
||||
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||
}
|
||||
end
|
||||
|
||||
@@ -106,12 +107,6 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_upload_date(upload_date) do
|
||||
<<year::binary-size(4)>> <> <<month::binary-size(2)>> <> <<day::binary-size(2)>> = upload_date
|
||||
|
||||
Date.from_iso8601!("#{year}-#{month}-#{day}")
|
||||
end
|
||||
|
||||
defp backend_runner do
|
||||
# This approach lets us mock the command for testing
|
||||
Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||
|
||||
@@ -6,7 +6,6 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||
|
||||
@@ -36,11 +35,20 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
||||
|
||||
case runner.run(url, command_opts, output_template, output_filepath: output_filepath) do
|
||||
{:ok, output} ->
|
||||
output
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&Phoenix.json_library().decode!/1)
|
||||
|> Enum.map(&YtDlpMedia.response_to_struct/1)
|
||||
|> FunctionUtils.wrap_ok()
|
||||
parsed_lines =
|
||||
output
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(fn line ->
|
||||
case Phoenix.json_library().decode(line) do
|
||||
{:ok, parsed_json} ->
|
||||
YtDlpMedia.response_to_struct(parsed_json)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, Enum.filter(parsed_lines, &(&1 != nil))}
|
||||
|
||||
res ->
|
||||
res
|
||||
@@ -50,20 +58,20 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
||||
@doc """
|
||||
Gets a source's ID and name from its URL.
|
||||
|
||||
yt-dlp does not _really_ have source-specific functions, so
|
||||
instead we're fetching just the first video (using playlist_end: 1)
|
||||
yt-dlp does not _really_ have source-specific functions that return what
|
||||
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
|
||||
|
||||
Returns {:ok, map()} | {:error, any, ...}.
|
||||
"""
|
||||
def get_source_details(source_url) do
|
||||
def get_source_details(source_url, addl_opts \\ []) do
|
||||
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
||||
# the first video has not released yet (ie: is a premier). We don't care about
|
||||
# available formats since we're just getting the source details
|
||||
opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1]
|
||||
output_template = "%(.{channel,channel_id,playlist_id,playlist_title})j"
|
||||
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1] ++ addl_opts
|
||||
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
|
||||
|
||||
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
|
||||
with {:ok, output} <- backend_runner().run(source_url, command_opts, output_template),
|
||||
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||
{:ok, format_source_details(parsed_json)}
|
||||
else
|
||||
@@ -71,12 +79,44 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
||||
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, addl_opts \\ []) do
|
||||
opts = [playlist_items: 0] ++ addl_opts
|
||||
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
|
||||
# NOTE: I should probably make this a struct some day
|
||||
%{
|
||||
channel_id: response["channel_id"],
|
||||
channel_name: response["channel"],
|
||||
playlist_id: response["playlist_id"],
|
||||
playlist_name: response["playlist_title"]
|
||||
playlist_name: response["playlist_title"],
|
||||
# It's not a name, it's a path dammit!
|
||||
# This actually isn't used for the inital response - it's
|
||||
# used later to update a source's metadata
|
||||
filepath: response["filename"]
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -317,17 +317,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
||||
</.label>
|
||||
<div class="relative">
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
x-bind:checked="enabled"
|
||||
class="sr-only"
|
||||
@change="enabled = !enabled"
|
||||
{@rest}
|
||||
/>
|
||||
<input type="hidden" id={@id} name={@name} x-bind:value="enabled" {@rest} />
|
||||
<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
|
||||
@@ -349,23 +339,26 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<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>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition",
|
||||
"focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white",
|
||||
@inputclass
|
||||
]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<div class="flex">
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition",
|
||||
"focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white",
|
||||
@inputclass
|
||||
]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
<.help :if={@help}><%= @help %></.help>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
|
||||
@@ -28,9 +28,10 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
||||
"#{@rounding} inline-flex items-center justify-center px-8 py-4",
|
||||
"#{@color}",
|
||||
"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
|
||||
]}
|
||||
type={@type}
|
||||
disabled={@disabled}
|
||||
{@rest}
|
||||
>
|
||||
|
||||
@@ -31,6 +31,20 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a subtle link with the given href and content.
|
||||
"""
|
||||
attr :href, :string, required: true
|
||||
slot :inner_block
|
||||
|
||||
def subtle_link(assigns) do
|
||||
~H"""
|
||||
<.link href={@href} class="underline decoration-bodydark decoration-1 hover:decoration-white">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an icon as a link with the given href.
|
||||
"""
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-full">
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Files
|
||||
</.button>
|
||||
</.link>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
use PinchflatWeb, :html
|
||||
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
|
||||
embed_templates "media_profile_html/*"
|
||||
|
||||
@doc """
|
||||
@@ -53,4 +55,25 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
duration_string
|
||||
)a
|
||||
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 {{ season_from_date }}/{{ season_episode_from_date }} - {{ title }}.{{ ext }}"
|
||||
end
|
||||
|
||||
defp audio_output_template do
|
||||
"/music/{{ source_custom_name }}/{{ title }}.{{ ext }}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<.link href={~p"/media_profiles"}>
|
||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||
</.link>
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">Edit Media Profile</h2>
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||
Editing "<%= @media_profile.name %>"
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</h2>
|
||||
<nav>
|
||||
<.link href={~p"/media_profiles/new"}>
|
||||
<.button color="bg-primary" rounding="rounded-full">
|
||||
<.button color="bg-primary" rounding="rounded-lg">
|
||||
<span class="font-bold text-xl mx-2">+</span> New <span class="hidden sm:inline pl-1">Media Profile</span>
|
||||
</.button>
|
||||
</.link>
|
||||
@@ -16,10 +16,9 @@
|
||||
<div class="flex flex-col gap-10 min-w-max">
|
||||
<.table rows={@media_profiles} table_class="text-black dark:text-white">
|
||||
<:col :let={media_profile} label="Name">
|
||||
<%= media_profile.name %>
|
||||
</:col>
|
||||
<:col :let={media_profile} label="Output Template">
|
||||
<code class="text-sm"><%= media_profile.output_path_template %></code>
|
||||
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
|
||||
<%= media_profile.name %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={media_profile} label="Preferred Resolution">
|
||||
<%= media_profile.preferred_resolution %>
|
||||
|
||||
@@ -3,116 +3,243 @@
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
|
||||
<h3 class="my-4 text-2xl text-black dark:text-white">
|
||||
General Options
|
||||
</h3>
|
||||
<.input
|
||||
field={f[:name]}
|
||||
type="text"
|
||||
label="Name"
|
||||
placeholder="New Profile"
|
||||
help="Something descriptive. Does not impact indexing or downloading (required)"
|
||||
/>
|
||||
<section x-data="{ selectedPreset: null }">
|
||||
<h3 class="my-4 text-2xl text-black dark:text-white">
|
||||
Use a Preset
|
||||
</h3>
|
||||
<section x-data="{ selection: null }">
|
||||
<.input
|
||||
prompt="Select preset"
|
||||
name="media_profile_preset"
|
||||
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
|
||||
field={f[:output_path_template]}
|
||||
type="text"
|
||||
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">
|
||||
General Options
|
||||
</h3>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Subtitle Options
|
||||
</h3>
|
||||
<.input
|
||||
field={f[:download_subs]}
|
||||
type="toggle"
|
||||
label="Download Subtitles"
|
||||
help="Downloads subtitle files alongside media file"
|
||||
/>
|
||||
<.input
|
||||
field={f[:download_auto_subs]}
|
||||
type="toggle"
|
||||
label="Download Autogenerated Subtitles"
|
||||
help="Prefers normal subs but will download autogenerated if needed"
|
||||
/>
|
||||
<.input
|
||||
field={f[:embed_subs]}
|
||||
type="toggle"
|
||||
label="Embed Subtitles"
|
||||
help="Embeds subtitles in the video file itself, if supported (recommended)"
|
||||
/>
|
||||
<.input
|
||||
field={f[:sub_langs]}
|
||||
type="text"
|
||||
label="Subtitle Languages"
|
||||
help="Use commas for multiple languages (eg: en,de)"
|
||||
/>
|
||||
<section x-data="{
|
||||
presets: {
|
||||
default: 'Default',
|
||||
media_center: 'TV Shows',
|
||||
audio: 'Audio',
|
||||
archiving: 'Archiving'
|
||||
}
|
||||
}">
|
||||
<.input
|
||||
field={f[:name]}
|
||||
type="text"
|
||||
label="Name"
|
||||
placeholder="New Profile"
|
||||
help="Something descriptive. Does not impact indexing or downloading (required)"
|
||||
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Thumbnail Options
|
||||
</h3>
|
||||
<.input
|
||||
field={f[:download_thumbnail]}
|
||||
type="toggle"
|
||||
label="Download Thumbnail"
|
||||
help="Downloads thumbnail alongside media file"
|
||||
/>
|
||||
<.input
|
||||
field={f[:embed_thumbnail]}
|
||||
type="toggle"
|
||||
label="Embed Thumbnail"
|
||||
help="Embeds thumbnail in the video file itself, if supported (recommended)"
|
||||
/>
|
||||
<section x-data={"{
|
||||
presets: {
|
||||
default: '#{default_output_template()}',
|
||||
media_center: '#{media_center_output_template()}',
|
||||
audio: '#{audio_output_template()}',
|
||||
archiving: '#{default_output_template()}'
|
||||
}
|
||||
}"}>
|
||||
<.input
|
||||
field={f[:output_path_template]}
|
||||
type="text"
|
||||
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)"
|
||||
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Metadata Options
|
||||
</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="Embeds metadata in the video file itself, if supported (recommended)"
|
||||
/>
|
||||
<h3 class="mt-10 text-2xl text-black dark:text-white">
|
||||
Subtitle Options
|
||||
</h3>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Release Format Options
|
||||
</h3>
|
||||
<section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
|
||||
<.input
|
||||
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
|
||||
field={f[:shorts_behaviour]}
|
||||
options={friendly_format_type_options()}
|
||||
type="select"
|
||||
label="Include Shorts?"
|
||||
help="Experimental. Please report any issues on GitHub"
|
||||
/>
|
||||
<.input
|
||||
field={f[:livestream_behaviour]}
|
||||
options={friendly_format_type_options()}
|
||||
type="select"
|
||||
label="Include Livestreams?"
|
||||
/>
|
||||
<section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: false } }">
|
||||
<.input
|
||||
field={f[:download_auto_subs]}
|
||||
type="toggle"
|
||||
label="Download Autogenerated Subtitles"
|
||||
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]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Quality Options
|
||||
</h3>
|
||||
<section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
|
||||
<.input
|
||||
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
|
||||
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"
|
||||
/>
|
||||
<section x-data="{ presets: { default: 'en', media_center: 'en', audio: '', archiving: 'all' } }">
|
||||
<.input
|
||||
field={f[:sub_langs]}
|
||||
type="text"
|
||||
label="Subtitle Languages"
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Media Center Options
|
||||
</h3>
|
||||
<p class="text-sm mt-2 max-w-prose">
|
||||
Everything in this section is experimental - please open a GitHub issue if you see something odd.
|
||||
<strong>
|
||||
These options only work if this Media Profile's output template is set to split media into seasons.
|
||||
</strong>
|
||||
Try the "Media Center" preset if you're not sure.
|
||||
</p>
|
||||
|
||||
<section
|
||||
phx-click={show_modal("upgrade-modal")}
|
||||
x-data="{ presets: { default: false, media_center: true, audio: false, archiving: false } }"
|
||||
>
|
||||
<.input
|
||||
field={f[:download_nfo]}
|
||||
type="toggle"
|
||||
label="Download NFO data"
|
||||
label_suffix="(pro)"
|
||||
help="Downloads NFO data alongside media file for use with Jellyfin, Kodi, etc."
|
||||
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_source_images]}
|
||||
type="toggle"
|
||||
label="Download Series Images"
|
||||
help="Downloads poster and banner images for use with Jellyfin, Kodi, etc. Only works for full channels (not playlists)"
|
||||
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Media profile</.button>
|
||||
</section>
|
||||
|
||||
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
|
||||
<.output_template_help />
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||
</.link>
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
|
||||
Media Profile <span class="hidden sm:inline">#<%= @media_profile.id %></span>
|
||||
<%= @media_profile.name %>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<.link href={~p"/media_profiles/#{@media_profile}/edit"}>
|
||||
<.button color="bg-primary" rounding="rounded-full">
|
||||
<.button color="bg-primary" rounding="rounded-lg">
|
||||
<.icon name="hero-pencil-square" class="mr-2" />Edit <span class="hidden sm:inline pl-1">Media Profile</span>
|
||||
</.button>
|
||||
</.link>
|
||||
@@ -31,7 +31,7 @@
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-full">
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Profile and its Sources
|
||||
</.button>
|
||||
</.link>
|
||||
@@ -41,7 +41,7 @@
|
||||
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
|
||||
class="mt-5 md:mt-0"
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-full">
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Profile, Sources, and Files
|
||||
</.button>
|
||||
</.link>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<p class="text-md text-bodydark">Don't worry, you can create more Media Profiles later!</p>
|
||||
<div class="mt-8">
|
||||
<.link href={~p"/media_profiles/new"}>
|
||||
<.button color="bg-primary" rounding="rounded-full" disabled={@media_profiles_exist}>
|
||||
<.button color="bg-primary" rounding="rounded-lg" disabled={@media_profiles_exist}>
|
||||
<span class="font-bold mx-2">+</span> New Media Profile
|
||||
</.button>
|
||||
</.link>
|
||||
@@ -25,7 +25,7 @@
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
<.link href={~p"/sources/new"}>
|
||||
<.button color="bg-primary" rounding="rounded-full" disabled={not @media_profiles_exist}>
|
||||
<.button color="bg-primary" rounding="rounded-lg" disabled={not @media_profiles_exist}>
|
||||
<span class="font-bold mx-2">+</span> New Source
|
||||
</.button>
|
||||
</.link>
|
||||
@@ -40,7 +40,7 @@
|
||||
<p class="text-md text-bodydark">Feel free to add more Media Profiles or Sources in the meantime!</p>
|
||||
<div class="mt-8">
|
||||
<.link href={~p"/?onboarding=0"}>
|
||||
<.button color="bg-primary" rounding="rounded-full" disabled={not @sources_exist}>
|
||||
<.button color="bg-primary" rounding="rounded-lg" disabled={not @sources_exist}>
|
||||
Let's Go <span class="font-bold mx-2">🚀</span>
|
||||
</.button>
|
||||
</.link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white">
|
||||
<span class="hidden sm:inline">Search </span>Results for "<%= StringUtils.truncate(@search_term, 50) %>"
|
||||
Results for "<%= StringUtils.truncate(@search_term, 50) %>"
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<%= if match?([_|_], @search_results) do %>
|
||||
<.table rows={@search_results} table_class="text-black dark:text-white">
|
||||
<:col :let={result} label="Title">
|
||||
<%= StringUtils.truncate(result.title, 40) %>
|
||||
<%= result.title %>
|
||||
</:col>
|
||||
<:col :let={result} label="Excerpt">
|
||||
<.highlight_search_terms text={result.matching_search_term} />
|
||||
|
||||
@@ -46,7 +46,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||
def show(conn, %{"id" => id}) do
|
||||
source = Repo.preload(Sources.get_source!(id), :media_profile)
|
||||
|
||||
pending_tasks = Repo.preload(Tasks.list_pending_tasks_for(:source_id, source.id), :job)
|
||||
pending_tasks = Repo.preload(Tasks.list_pending_tasks_for(source), :job)
|
||||
pending_media = Media.list_pending_media_items_for(source, limit: 100)
|
||||
downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
||||
|
||||
def friendly_index_frequencies do
|
||||
[
|
||||
{"On Create", -1},
|
||||
{"Only once when first created", -1},
|
||||
{"1 Hour", 60},
|
||||
{"3 Hours", 3 * 60},
|
||||
{"6 Hours", 6 * 60},
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<.link href={~p"/sources"}>
|
||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||
</.link>
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">Edit Source</h2>
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||
Editing "<%= @source.custom_name %>"
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
|
||||
<nav>
|
||||
<.link href={~p"/sources/new"}>
|
||||
<.button color="bg-primary" rounding="rounded-full">
|
||||
<.button color="bg-primary" rounding="rounded-lg">
|
||||
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
|
||||
</.button>
|
||||
</.link>
|
||||
@@ -14,19 +14,18 @@
|
||||
<div class="flex flex-col gap-10 min-w-max">
|
||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||
<:col :let={source} label="Name">
|
||||
<%= source.custom_name || source.collection_name %>
|
||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||
<%= source.custom_name || source.collection_name %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||
<:col :let={source} label="Should Download?">
|
||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||
</:col>
|
||||
<:col :let={source} label="Media Profile">
|
||||
<.link
|
||||
href={~p"/media_profiles/#{source.media_profile_id}"}
|
||||
class="hover:text-secondary duration-200 ease-in-out"
|
||||
>
|
||||
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
||||
<%= source.media_profile.name %>
|
||||
</.link>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={source} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||
</.link>
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||
Source <span class="hidden sm:inline">#<%= @source.id %></span>
|
||||
<%= @source.custom_name %>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<.link href={~p"/sources/#{@source}/edit"}>
|
||||
<.button color="bg-primary" rounding="rounded-full">
|
||||
<.button color="bg-primary" rounding="rounded-lg">
|
||||
<.icon name="hero-pencil-square" class="mr-2" /> Edit <span class="hidden sm:inline pl-1">Source</span>
|
||||
</.button>
|
||||
</.link>
|
||||
@@ -38,7 +38,7 @@
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-full">
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Source
|
||||
</.button>
|
||||
</.link>
|
||||
@@ -48,7 +48,7 @@
|
||||
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
||||
class="mt-5 md:mt-0"
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-full">
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Source and Files
|
||||
</.button>
|
||||
</.link>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
options={Enum.map(@media_profiles, &{&1.name, &1.id})}
|
||||
type="select"
|
||||
label="Media Profile"
|
||||
help="Sets your preferences for what media to look for and how to store it"
|
||||
/>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
@@ -32,7 +33,7 @@
|
||||
options={friendly_index_frequencies()}
|
||||
type="select"
|
||||
label="Index Frequency"
|
||||
help="Time between one index of this source finishing and the next one starting. Setting to 'On Create' will still run an initial index but no subsequent ones"
|
||||
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting"
|
||||
/>
|
||||
|
||||
<%!-- TODO: use Alpine to disable the index frequency when fast indexing is enabled --%>
|
||||
@@ -40,7 +41,7 @@
|
||||
<.input
|
||||
field={f[:fast_index]}
|
||||
type="toggle"
|
||||
label="Use Fast Indexing?"
|
||||
label="Use Fast Indexing"
|
||||
label_suffix="(pro)"
|
||||
help="Experimental. Ignores 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
|
||||
/>
|
||||
@@ -53,7 +54,7 @@
|
||||
<.input
|
||||
field={f[:download_media]}
|
||||
type="toggle"
|
||||
label="Download Media?"
|
||||
label="Download Media"
|
||||
help="Unchecking still indexes media but it won't be downloaded until you enable this option"
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :pinchflat,
|
||||
version: "0.1.0-alpha.4",
|
||||
version: "0.1.0-alpha.6",
|
||||
elixir: "~> 1.16",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
@@ -13,6 +13,16 @@ defmodule Pinchflat.MixProject do
|
||||
preferred_cli_env: [
|
||||
check: :test,
|
||||
credo: :test
|
||||
],
|
||||
test_coverage: [
|
||||
ignore_modules: [
|
||||
Pinchflat.HTTP.HTTPClient,
|
||||
PinchflatWeb.Layouts,
|
||||
Pinchflat.DataCase,
|
||||
Pinchflat.Release,
|
||||
~r/Fixtures/,
|
||||
~r/HTML$/
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddNfoFilepathToMediaItem do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_items) do
|
||||
add :nfo_filepath, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddDownloadNfoToMediaProfile do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_profiles) do
|
||||
add :download_nfo, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddNfoPathToSources do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:sources) do
|
||||
add :nfo_filepath, :string
|
||||
add :series_directory, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddSourcePhotosFields do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:sources) do
|
||||
add :fanart_filepath, :string
|
||||
add :poster_filepath, :string
|
||||
add :banner_filepath, :string
|
||||
end
|
||||
|
||||
alter table(:media_profiles) do
|
||||
add :download_source_images, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.Boot.DataBackfillWorkerTest do
|
||||
import Pinchflat.MediaFixtures
|
||||
|
||||
alias Pinchflat.Boot.DataBackfillWorker
|
||||
alias Pinchflat.Filesystem.FilesystemDataWorker
|
||||
alias Pinchflat.JobFixtures.TestJobWorker
|
||||
|
||||
describe "cancel_pending_backfill_jobs/0" do
|
||||
test "cancels all pending backfill jobs" do
|
||||
@@ -21,14 +21,14 @@ defmodule Pinchflat.Boot.DataBackfillWorkerTest do
|
||||
|
||||
test "does not cancel jobs for other workers" do
|
||||
%{id: 0}
|
||||
|> FilesystemDataWorker.new()
|
||||
|> TestJobWorker.new()
|
||||
|> Repo.insert_unique_job()
|
||||
|
||||
assert_enqueued(worker: FilesystemDataWorker)
|
||||
assert_enqueued(worker: TestJobWorker)
|
||||
|
||||
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||
|
||||
assert_enqueued(worker: FilesystemDataWorker)
|
||||
assert_enqueued(worker: TestJobWorker)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -125,7 +125,15 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
assert :write_thumbnail in res
|
||||
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})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
@@ -189,12 +197,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
end
|
||||
|
||||
describe "build/1 when testing quality options" do
|
||||
test "it includes quality options", %{media_item: media_item} do
|
||||
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"})
|
||||
test "it includes quality options" do
|
||||
resolutions = ["360", "480", "720", "1080", "2160"]
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
Enum.each(resolutions, fn resolution ->
|
||||
resolution_atom = String.to_existing_atom(resolution <> "p")
|
||||
|
||||
assert {:format_sort, "res:1080,+codec:avc:m4a"} in res
|
||||
media_profile = media_profile_fixture(%{preferred_resolution: resolution_atom})
|
||||
source = source_fixture(%{media_profile_id: media_profile.id})
|
||||
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res
|
||||
end)
|
||||
end
|
||||
|
||||
test "it includes quality options for audio only", %{media_item: media_item} do
|
||||
@@ -207,13 +222,21 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_output_path_for/1" do
|
||||
test "builds an output path for a source", %{media_item: media_item} do
|
||||
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
|
||||
|
||||
assert path == "/tmp/test/media/%(title)S.%(ext)s"
|
||||
end
|
||||
end
|
||||
|
||||
defp update_media_profile_attribute(media_item_with_preloads, attrs) do
|
||||
media_item_with_preloads.source.media_profile
|
||||
|> Profiles.change_media_profile(attrs)
|
||||
|> Repo.update!()
|
||||
|> Repo.update()
|
||||
|
||||
media_item_with_preloads
|
||||
|> Repo.reload()
|
||||
|> Repo.preload(source: :media_profile)
|
||||
|> Repo.preload([source: :media_profile], force: true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,11 +34,11 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
||||
source = source_fixture()
|
||||
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
|
||||
|
||||
assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id)
|
||||
assert [] = Tasks.list_tasks_for(media_item)
|
||||
|
||||
assert :ok = DownloadingHelpers.enqueue_pending_download_tasks(source)
|
||||
|
||||
assert [_] = Tasks.list_tasks_for(:media_item_id, media_item.id)
|
||||
assert [_] = Tasks.list_tasks_for(media_item)
|
||||
end
|
||||
|
||||
test "it does not create a job if the source is set to not download" do
|
||||
@@ -54,7 +54,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
||||
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
|
||||
|
||||
assert :ok = DownloadingHelpers.enqueue_pending_download_tasks(source)
|
||||
assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id)
|
||||
assert [] = Tasks.list_tasks_for(media_item)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,7 +69,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
||||
assert :ok = DownloadingHelpers.dequeue_pending_download_tasks(source)
|
||||
|
||||
refute_enqueued(worker: MediaDownloadWorker)
|
||||
assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id)
|
||||
assert [] = Tasks.list_tasks_for(media_item)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,25 +5,37 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||
import Pinchflat.MediaFixtures
|
||||
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.Filesystem.FilesystemDataWorker
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
setup do
|
||||
media_item =
|
||||
Repo.preload(
|
||||
media_item_fixture(%{media_filepath: nil}),
|
||||
[:metadata, source: :media_profile]
|
||||
)
|
||||
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||
{:ok, ""}
|
||||
end)
|
||||
|
||||
media_item =
|
||||
%{media_filepath: nil}
|
||||
|> media_item_fixture()
|
||||
|> Repo.preload([:metadata, source: :media_profile])
|
||||
|
||||
{:ok, %{media_item: media_item}}
|
||||
end
|
||||
|
||||
describe "kickoff_with_task/2" do
|
||||
test "starts the worker", %{media_item: media_item} do
|
||||
assert [] = all_enqueued(worker: MediaDownloadWorker)
|
||||
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item)
|
||||
assert [_] = all_enqueued(worker: MediaDownloadWorker)
|
||||
end
|
||||
|
||||
test "attaches a task", %{media_item: media_item} do
|
||||
assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item)
|
||||
assert task.media_item_id == media_item.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
test "it saves attributes to the media_item", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
@@ -70,16 +82,22 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||
end
|
||||
|
||||
test "it schedules a filesystem data worker", %{media_item: media_item} do
|
||||
test "it saves the file's size to the database", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
{:ok, render_metadata(:media_metadata)}
|
||||
metadata = render_parsed_metadata(:media_metadata)
|
||||
FilesystemHelpers.write_p!(metadata["filepath"], "test")
|
||||
|
||||
{:ok, Phoenix.json_library().encode!(metadata)}
|
||||
end)
|
||||
|
||||
assert [] = all_enqueued(worker: FilesystemDataWorker)
|
||||
|
||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||
media_item = Repo.reload(media_item)
|
||||
|
||||
assert [_] = all_enqueued(worker: FilesystemDataWorker)
|
||||
assert media_item.media_size_bytes > 0
|
||||
end
|
||||
|
||||
test "does not blow up if the record doesn't exist" do
|
||||
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,8 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
use Pinchflat.DataCase
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
import Pinchflat.ProfilesFixtures
|
||||
|
||||
alias Pinchflat.Downloading.MediaDownloader
|
||||
|
||||
@@ -103,4 +105,37 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
assert String.ends_with?(updated_media_item.metadata_filepath, ".info.json")
|
||||
end
|
||||
end
|
||||
|
||||
describe "download_for_media_item/3 when testing NFO generation" do
|
||||
setup do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
{:ok, render_metadata(:media_metadata)}
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "it generates an NFO file if the source is set to download NFOs" do
|
||||
profile = media_profile_fixture(%{download_nfo: true})
|
||||
source = source_fixture(%{media_profile_id: profile.id})
|
||||
media_item = media_item_fixture(%{source_id: source.id})
|
||||
|
||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||
|
||||
assert String.ends_with?(updated_media_item.nfo_filepath, ".nfo")
|
||||
assert File.exists?(updated_media_item.nfo_filepath)
|
||||
|
||||
File.rm!(updated_media_item.nfo_filepath)
|
||||
end
|
||||
|
||||
test "it does not generate an NFO file if the source is set to not download NFOs" do
|
||||
profile = media_profile_fixture(%{download_nfo: false})
|
||||
source = source_fixture(%{media_profile_id: profile.id})
|
||||
media_item = media_item_fixture(%{source_id: source.id})
|
||||
|
||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||
|
||||
assert updated_media_item.nfo_filepath == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,51 +2,20 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.TasksFixtures
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
import Pinchflat.ProfilesFixtures
|
||||
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
@media_url "https://www.youtube.com/watch?v=test_1"
|
||||
|
||||
describe "kickoff_fast_indexing_task/1" do
|
||||
test "it schedules a job" do
|
||||
source = source_fixture()
|
||||
|
||||
assert {:ok, _} = FastIndexingHelpers.kickoff_fast_indexing_task(source)
|
||||
|
||||
assert_enqueued(worker: FastIndexingWorker, args: %{"id" => source.id})
|
||||
end
|
||||
|
||||
test "it creates and attaches a task" do
|
||||
source = source_fixture()
|
||||
|
||||
assert {:ok, %Task{} = task} = FastIndexingHelpers.kickoff_fast_indexing_task(source)
|
||||
|
||||
assert task.source_id == source.id
|
||||
end
|
||||
|
||||
test "it deletes any fast indexing tasks for the source" do
|
||||
source = source_fixture()
|
||||
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
|
||||
task = task_fixture(source_id: source.id, job_id: job.id)
|
||||
|
||||
assert {:ok, _} = FastIndexingHelpers.kickoff_fast_indexing_task(source)
|
||||
|
||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
||||
end
|
||||
end
|
||||
|
||||
describe "kickoff_indexing_tasks_from_youtube_rss_feed/1" do
|
||||
setup do
|
||||
{:ok, [source: source_fixture()]}
|
||||
@@ -104,7 +73,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
||||
test "creates a download task record", %{source: source} do
|
||||
assert {:ok, media_item} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url)
|
||||
|
||||
assert [_] = Tasks.list_tasks_for(:media_item_id, media_item.id, "MediaDownloadWorker")
|
||||
assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker")
|
||||
end
|
||||
|
||||
test "does not enqueue a download job if the source does not allow it" do
|
||||
|
||||
@@ -9,18 +9,35 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "kickoff_with_task/2" do
|
||||
test "starts the worker" do
|
||||
source = source_fixture(fast_index: true)
|
||||
|
||||
assert [] = all_enqueued(worker: FastIndexingWorker)
|
||||
assert {:ok, _} = FastIndexingWorker.kickoff_with_task(source)
|
||||
assert [_] = all_enqueued(worker: FastIndexingWorker)
|
||||
end
|
||||
|
||||
test "attaches a task" do
|
||||
source = source_fixture(fast_index: true)
|
||||
|
||||
assert {:ok, task} = FastIndexingWorker.kickoff_with_task(source)
|
||||
assert task.source_id == source.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
test "calls out to Youtube RSS if enabled" do
|
||||
expect(HTTPClientMock, :get, fn _url -> {:ok, ""} end)
|
||||
source = source_fixture(fast_index: true)
|
||||
|
||||
perform_job(FastIndexingWorker, %{"id" => source.id})
|
||||
perform_job(FastIndexingWorker, %{id: source.id})
|
||||
end
|
||||
|
||||
test "reschedules itself if fast indexing is enabled" do
|
||||
expect(HTTPClientMock, :get, fn _url -> {:ok, ""} end)
|
||||
source = source_fixture(fast_index: true)
|
||||
perform_job(FastIndexingWorker, %{"id" => source.id})
|
||||
perform_job(FastIndexingWorker, %{id: source.id})
|
||||
|
||||
assert_enqueued(
|
||||
worker: FastIndexingWorker,
|
||||
@@ -29,18 +46,32 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
|
||||
)
|
||||
end
|
||||
|
||||
test "does not reschedule if that would create a duplicate job" do
|
||||
stub(HTTPClientMock, :get, fn _url -> {:ok, ""} end)
|
||||
source = source_fixture(fast_index: true)
|
||||
|
||||
perform_job(FastIndexingWorker, %{id: source.id})
|
||||
perform_job(FastIndexingWorker, %{id: source.id})
|
||||
|
||||
assert [_] = all_enqueued(worker: FastIndexingWorker)
|
||||
end
|
||||
|
||||
test "does not call out to Youtube RSS if disabled" do
|
||||
expect(HTTPClientMock, :get, 0, fn _url -> {:ok, ""} end)
|
||||
source = source_fixture(fast_index: false)
|
||||
|
||||
perform_job(FastIndexingWorker, %{"id" => source.id})
|
||||
perform_job(FastIndexingWorker, %{id: source.id})
|
||||
end
|
||||
|
||||
test "does not reschedule itself if fast indexing is disabled" do
|
||||
source = source_fixture(fast_index: false)
|
||||
perform_job(FastIndexingWorker, %{"id" => source.id})
|
||||
perform_job(FastIndexingWorker, %{id: source.id})
|
||||
|
||||
refute_enqueued(worker: FastIndexingWorker, args: %{"id" => source.id})
|
||||
end
|
||||
|
||||
test "does not blow up if the record doesn't exist" do
|
||||
assert :ok = perform_job(FastIndexingWorker, %{id: 0})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,8 +6,8 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorkerTest do
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
||||
|
||||
@media_url "https://www.youtube.com/watch?v=1234567890"
|
||||
|
||||
@@ -19,6 +19,19 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorkerTest do
|
||||
{:ok, source: source}
|
||||
end
|
||||
|
||||
describe "kickoff_with_task/2" do
|
||||
test "starts the worker", %{source: source} do
|
||||
assert [] = all_enqueued(worker: MediaIndexingWorker)
|
||||
assert {:ok, _} = MediaIndexingWorker.kickoff_with_task(source, @media_url)
|
||||
assert [_] = all_enqueued(worker: MediaIndexingWorker)
|
||||
end
|
||||
|
||||
test "attaches a task", %{source: source} do
|
||||
assert {:ok, task} = MediaIndexingWorker.kickoff_with_task(source, @media_url)
|
||||
assert task.source_id == source.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
test "indexes the media item and saves it to the database", %{source: source} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
@@ -40,5 +53,9 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorkerTest do
|
||||
|
||||
assert [_] = all_enqueued(worker: MediaDownloadWorker)
|
||||
end
|
||||
|
||||
test "does not blow up if the record doesn't exist" do
|
||||
assert :ok = perform_job(MediaDownloadWorker, %{id: 0, media_url: @media_url})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
defmodule Pinchflat.Filesystem.FilesystemDataWorkerTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Pinchflat.MediaFixtures
|
||||
|
||||
alias Pinchflat.Filesystem.FilesystemDataWorker
|
||||
|
||||
describe "perform/1" do
|
||||
test "Computes and stores the media file size" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
refute media_item.media_size_bytes
|
||||
|
||||
perform_job(FilesystemDataWorker, %{id: media_item.id})
|
||||
|
||||
assert Repo.reload!(media_item).media_size_bytes
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -33,4 +33,111 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||
assert {:error, _} = FilesystemHelpers.compute_and_save_media_filesize(media_item)
|
||||
end
|
||||
end
|
||||
|
||||
describe "write_p!/3" do
|
||||
test "writes content to a file" do
|
||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
||||
content = "{}"
|
||||
|
||||
assert :ok = FilesystemHelpers.write_p!(filepath, content)
|
||||
assert File.read!(filepath) == content
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
|
||||
test "creates directories as needed" do
|
||||
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
filepath = Path.join([tmpfile_directory, "foo", "bar", "file.json"])
|
||||
content = "{}"
|
||||
|
||||
assert :ok = FilesystemHelpers.write_p!(filepath, content)
|
||||
assert File.read!(filepath) == content
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_file_and_remove_empty_directories/1" do
|
||||
test "deletes file at the provided filepath" do
|
||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
||||
|
||||
assert File.exists?(filepath)
|
||||
|
||||
assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
|
||||
|
||||
refute File.exists?(filepath)
|
||||
end
|
||||
|
||||
test "deletes empty directories" do
|
||||
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
filepath = Path.join([tmpfile_directory, "foo", "bar", "baz", "qux.json"])
|
||||
FilesystemHelpers.write_p!(filepath, "")
|
||||
|
||||
assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
|
||||
|
||||
refute File.exists?(filepath)
|
||||
refute File.exists?(Path.join([tmpfile_directory, "foo", "bar", "baz"]))
|
||||
refute File.exists?(Path.join([tmpfile_directory, "foo", "bar"]))
|
||||
refute File.exists?(Path.join([tmpfile_directory, "foo"]))
|
||||
end
|
||||
|
||||
test "does not delete directories with other files in them" do
|
||||
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
filepath_1 = Path.join([tmpfile_directory, "foo", "bar", "baz", "qux.json"])
|
||||
filepath_2 = Path.join([tmpfile_directory, "foo", "baz.json"])
|
||||
FilesystemHelpers.write_p!(filepath_1, "")
|
||||
FilesystemHelpers.write_p!(filepath_2, "")
|
||||
|
||||
assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath_1)
|
||||
|
||||
refute File.exists?(filepath_1)
|
||||
refute File.exists?(Path.join([tmpfile_directory, "foo", "bar", "baz"]))
|
||||
refute File.exists?(Path.join([tmpfile_directory, "foo", "bar"]))
|
||||
|
||||
assert File.exists?(filepath_2)
|
||||
assert File.exists?(Path.join([tmpfile_directory, "foo"]))
|
||||
|
||||
# cleanup
|
||||
FilesystemHelpers.delete_file_and_remove_empty_directories(filepath_2)
|
||||
end
|
||||
|
||||
test "returns an error if file could not be deleted" do
|
||||
filepath = "/nonexistent/file.json"
|
||||
|
||||
assert {:error, _} = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
|
||||
end
|
||||
end
|
||||
|
||||
describe "cp_p!/2" do
|
||||
test "copies a file from source to destination" do
|
||||
source = "#{tmpfile_directory()}/source.json"
|
||||
FilesystemHelpers.write_p!(source, "TEST")
|
||||
destination = "#{tmpfile_directory()}/destination.json"
|
||||
|
||||
refute File.exists?(destination)
|
||||
FilesystemHelpers.cp_p!(source, destination)
|
||||
assert File.exists?(destination)
|
||||
assert File.read!(destination) == "TEST"
|
||||
|
||||
File.rm!(source)
|
||||
File.rm!(destination)
|
||||
end
|
||||
|
||||
test "creates directories as needed" do
|
||||
source = "#{tmpfile_directory()}/source.json"
|
||||
FilesystemHelpers.write_p!(source, "TEST")
|
||||
destination = "#{tmpfile_directory()}/foo/bar/destination.json"
|
||||
|
||||
refute File.exists?(destination)
|
||||
FilesystemHelpers.cp_p!(source, destination)
|
||||
assert File.exists?(destination)
|
||||
|
||||
File.rm!(source)
|
||||
File.rm!(destination)
|
||||
end
|
||||
end
|
||||
|
||||
defp tmpfile_directory do
|
||||
Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -360,57 +360,6 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "media_filepaths/1" do
|
||||
test "returns filepaths in a flat list" do
|
||||
filepaths = %{
|
||||
media_filepath: "/video/test.mp4",
|
||||
thumbnail_filepath: "/video/test.jpg",
|
||||
subtitle_filepaths: [["en", "video/test.srt"]]
|
||||
}
|
||||
|
||||
media_item = media_item_fixture(filepaths)
|
||||
|
||||
assert Media.media_filepaths(media_item) == [
|
||||
"/video/test.mp4",
|
||||
"/video/test.jpg",
|
||||
"video/test.srt"
|
||||
]
|
||||
end
|
||||
|
||||
test "strips out nil values" do
|
||||
filepaths = %{
|
||||
media_filepath: "/video/test.mp4",
|
||||
thumbnail_filepath: nil,
|
||||
subtitle_filepaths: [["en", nil]]
|
||||
}
|
||||
|
||||
media_item = media_item_fixture(filepaths)
|
||||
|
||||
assert Media.media_filepaths(media_item) == ["/video/test.mp4"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "metadata_filepaths" do
|
||||
test "returns filepaths in a flat list" do
|
||||
filepaths = %{
|
||||
metadata_filepath: "/metadata.json.gz",
|
||||
thumbnail_filepath: "/thumbnail.jpg"
|
||||
}
|
||||
|
||||
media_item = media_item_fixture(%{metadata: filepaths})
|
||||
|
||||
assert Media.metadata_filepaths(media_item) == [
|
||||
"/metadata.json.gz",
|
||||
"/thumbnail.jpg"
|
||||
]
|
||||
end
|
||||
|
||||
test "returns an empty list when there is no metadata" do
|
||||
media_item = media_item_fixture()
|
||||
assert Media.metadata_filepaths(media_item) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_media_item/1" do
|
||||
test "creating with valid data creates a media_item" do
|
||||
valid_attrs = %{
|
||||
@@ -515,6 +464,26 @@ defmodule Pinchflat.MediaTest do
|
||||
assert {:ok, _} = Media.delete_media_item(media_item)
|
||||
assert File.exists?(media_item.media_filepath)
|
||||
end
|
||||
|
||||
test "does delete the media item's metadata files" do
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
||||
|
||||
update_attrs = %{
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||
thumbnail_filepath:
|
||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, %{
|
||||
"thumbnail" => "https://example.com/thumbnail.jpg"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
{:ok, updated_media_item} = Media.update_media_item(media_item, update_attrs)
|
||||
|
||||
assert {:ok, _} = Media.delete_media_item(updated_media_item)
|
||||
refute File.exists?(updated_media_item.metadata.metadata_filepath)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_media_item/2 when testing file deletion" do
|
||||
|
||||
@@ -84,4 +84,60 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
||||
assert Path.basename(filepath) == "maxres.webp"
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_upload_date/1" do
|
||||
test "returns a date from the given metadata upload date" do
|
||||
upload_date = "20210101"
|
||||
|
||||
assert Helpers.parse_upload_date(upload_date) == ~D[2021-01-01]
|
||||
end
|
||||
end
|
||||
|
||||
describe "series_directory_from_media_filepath/1" do
|
||||
test "returns base series directory if filepaths are setup as expected" do
|
||||
good_filepaths = [
|
||||
"/media/season1/episode.mp4",
|
||||
"/media/season 1/episode.mp4",
|
||||
"/media/season.1/episode.mp4",
|
||||
"/media/season_1/episode.mp4",
|
||||
"/media/season-1/episode.mp4",
|
||||
"/media/SEASON 1/episode.mp4",
|
||||
"/media/SEASON.1/episode.mp4",
|
||||
"/media/s1/episode.mp4",
|
||||
"/media/s.1/episode.mp4",
|
||||
"/media/s_1/episode.mp4",
|
||||
"/media/s-1/episode.mp4",
|
||||
"/media/s 1/episode.mp4",
|
||||
"/media/S1/episode.mp4",
|
||||
"/media/S.1/episode.mp4"
|
||||
]
|
||||
|
||||
for filepath <- good_filepaths do
|
||||
assert {:ok, "/media"} = Helpers.series_directory_from_media_filepath(filepath)
|
||||
end
|
||||
end
|
||||
|
||||
test "returns an error if the season filepath can't be determined" do
|
||||
bad_filepaths = [
|
||||
"/media/1/episode.mp4",
|
||||
"/media/(s1)/episode.mp4",
|
||||
"/media/episode.mp4",
|
||||
"/media/s1e1/episode.mp4",
|
||||
"/media/s1 e1/episode.mp4",
|
||||
"/media/s1 (something else)/episode.mp4",
|
||||
"/media/season1e1/episode.mp4",
|
||||
"/media/season1 e1/episode.mp4",
|
||||
"/media/seasoning1/episode.mp4",
|
||||
"/media/season/episode.mp4",
|
||||
"/media/series1/episode.mp4",
|
||||
"/media/s/episode.mp4",
|
||||
"/media/foo",
|
||||
"/media/bar/"
|
||||
]
|
||||
|
||||
for filepath <- bad_filepaths do
|
||||
assert {:error, :indeterminable} = Helpers.series_directory_from_media_filepath(filepath)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,22 +4,7 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
||||
alias Pinchflat.Metadata.MetadataParser, as: Parser
|
||||
|
||||
setup do
|
||||
json_filepath =
|
||||
Path.join([
|
||||
File.cwd!(),
|
||||
"test",
|
||||
"support",
|
||||
"files",
|
||||
"media_metadata.json"
|
||||
])
|
||||
|
||||
{:ok, file_body} = File.read(json_filepath)
|
||||
{:ok, parsed_json} = Phoenix.json_library().decode(file_body)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
metadata: parsed_json
|
||||
}}
|
||||
{:ok, %{metadata: render_parsed_metadata(:media_metadata)}}
|
||||
end
|
||||
|
||||
describe "parse_for_media_item/1 when testing media metadata" do
|
||||
@@ -109,6 +94,15 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
||||
assert String.ends_with?(result.thumbnail_filepath, ".webp")
|
||||
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
|
||||
metadata = Map.put(metadata, "thumbnails", %{})
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
defmodule Pinchflat.Metadata.NfoBuilderTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
alias Pinchflat.Metadata.NfoBuilder
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
setup do
|
||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
||||
|
||||
on_exit(fn -> File.rm!(filepath) end)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
metadata: render_parsed_metadata(:media_metadata),
|
||||
filepath: filepath
|
||||
}}
|
||||
end
|
||||
|
||||
describe "build_and_store_for_media_item/2" do
|
||||
test "returns the filepath", %{metadata: metadata, filepath: filepath} do
|
||||
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
|
||||
|
||||
assert File.exists?(result)
|
||||
end
|
||||
|
||||
test "builds an NFO file", %{metadata: metadata, filepath: filepath} do
|
||||
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
|
||||
nfo = File.read!(result)
|
||||
|
||||
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
|
||||
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_and_store_for_source/2" do
|
||||
test "returns the filepath", %{metadata: metadata, filepath: filepath} do
|
||||
result = NfoBuilder.build_and_store_for_source(filepath, metadata)
|
||||
|
||||
assert File.exists?(result)
|
||||
end
|
||||
|
||||
test "builds an NFO file", %{metadata: metadata, filepath: filepath} do
|
||||
result = NfoBuilder.build_and_store_for_source(filepath, metadata)
|
||||
nfo = File.read!(result)
|
||||
|
||||
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
|
||||
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
defmodule Pinchflat.Metadata.SourceImageParserTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
alias Pinchflat.Metadata.SourceImageParser
|
||||
|
||||
@base_dir Application.compile_env(:pinchflat, :tmpfile_directory)
|
||||
|
||||
describe "store_source_images/2" do
|
||||
test "returns a map of image types and locations" do
|
||||
metadata = render_parsed_metadata(:channel_source_metadata)
|
||||
|
||||
expected = %{
|
||||
banner_filepath: "#{@base_dir}/banner.jpg",
|
||||
fanart_filepath: "#{@base_dir}/fanart.jpg",
|
||||
poster_filepath: "#{@base_dir}/poster.jpg"
|
||||
}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
|
||||
end
|
||||
|
||||
test "returns the avatar_uncropped as the poster" do
|
||||
metadata = %{
|
||||
"thumbnails" => [
|
||||
%{"id" => "avatar_uncropped", "filepath" => "/app/test/support/files/channel_photos/a.0.jpg"}
|
||||
]
|
||||
}
|
||||
|
||||
expected = %{
|
||||
poster_filepath: "#{@base_dir}/poster.jpg"
|
||||
}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
|
||||
end
|
||||
|
||||
test "returns the banner_uncropped as the fanart" do
|
||||
metadata = %{
|
||||
"thumbnails" => [
|
||||
%{"id" => "banner_uncropped", "filepath" => "/app/test/support/files/channel_photos/a.0.jpg"}
|
||||
]
|
||||
}
|
||||
|
||||
expected = %{
|
||||
fanart_filepath: "#{@base_dir}/fanart.jpg"
|
||||
}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
|
||||
end
|
||||
|
||||
test "doesn't return a banner if no suitable images are found" do
|
||||
metadata = %{
|
||||
"thumbnails" => [
|
||||
%{
|
||||
"id" => "1",
|
||||
"filepath" => "foo.jpg",
|
||||
"width" => 100,
|
||||
"height" => 100
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expected = %{}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
|
||||
end
|
||||
|
||||
test "doesn't blow up if empty metadata is passed" do
|
||||
metadata = %{}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||
end
|
||||
|
||||
test "doesn't blow up if no thumbnails are passed" do
|
||||
metadata = %{"thumbnails" => []}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,262 @@
|
||||
defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
||||
use Pinchflat.DataCase
|
||||
import Mox
|
||||
import Pinchflat.SourcesFixtures
|
||||
import Pinchflat.ProfilesFixtures
|
||||
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||
|
||||
@source_details_ot "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
|
||||
@metadata_ot "playlist:%()j"
|
||||
|
||||
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 "won't call itself in an infinite loop" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||
_url, _opts, ot when ot == @metadata_ot -> {:ok, "{}"}
|
||||
end)
|
||||
|
||||
source = source_fixture()
|
||||
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
|
||||
assert [] = all_enqueued(worker: SourceMetadataStorageWorker)
|
||||
end
|
||||
|
||||
test "does not blow up if the record doesn't exist" do
|
||||
assert :ok = perform_job(SourceMetadataStorageWorker, %{id: 0})
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 when testing metadata storage" do
|
||||
test "sets metadata location for source" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||
_url, _opts, ot when ot == @metadata_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"})
|
||||
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||
_url, _opts, ot when ot == @metadata_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
|
||||
|
||||
describe "perform/1 when testing source image downloading" do
|
||||
test "downloads and stores source images" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
{:ok, render_metadata(:channel_source_metadata)}
|
||||
end)
|
||||
|
||||
profile = media_profile_fixture(%{download_source_images: true})
|
||||
source = source_fixture(media_profile_id: profile.id)
|
||||
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
source = Repo.reload(source)
|
||||
|
||||
assert source.fanart_filepath
|
||||
assert source.poster_filepath
|
||||
assert source.banner_filepath
|
||||
|
||||
assert File.exists?(source.fanart_filepath)
|
||||
assert File.exists?(source.poster_filepath)
|
||||
assert File.exists?(source.banner_filepath)
|
||||
|
||||
Sources.delete_source(source, delete_files: true)
|
||||
end
|
||||
|
||||
test "does not store source images if the profile is not set to" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
{:ok, render_metadata(:channel_source_metadata)}
|
||||
end)
|
||||
|
||||
profile = media_profile_fixture(%{download_source_images: false})
|
||||
source = source_fixture(media_profile_id: profile.id)
|
||||
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
source = Repo.reload(source)
|
||||
|
||||
refute source.fanart_filepath
|
||||
refute source.poster_filepath
|
||||
refute source.banner_filepath
|
||||
end
|
||||
|
||||
test "does not store source images if the series directory cannot be determined" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
{:ok, render_metadata(:channel_source_metadata)}
|
||||
end)
|
||||
|
||||
profile = media_profile_fixture(%{download_source_images: true})
|
||||
source = source_fixture(media_profile_id: profile.id)
|
||||
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
source = Repo.reload(source)
|
||||
|
||||
refute source.fanart_filepath
|
||||
refute source.poster_filepath
|
||||
refute source.banner_filepath
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 when determining the series_directory" do
|
||||
test "sets the series directory based on the returned media filepath" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
source = source_fixture(%{series_directory: nil})
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
source = Repo.reload(source)
|
||||
|
||||
assert source.series_directory
|
||||
end
|
||||
|
||||
test "does not set the series directory if it cannot be determined" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
source = source_fixture(%{series_directory: nil})
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
source = Repo.reload(source)
|
||||
|
||||
refute source.series_directory
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 when storing the series NFO" do
|
||||
test "stores the NFO if specified" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
profile = media_profile_fixture(%{download_nfo: true})
|
||||
source = source_fixture(%{nfo_filepath: nil, media_profile_id: profile.id})
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
source = Repo.reload(source)
|
||||
|
||||
assert source.nfo_filepath
|
||||
assert source.nfo_filepath == Path.join([source.series_directory, "tvshow.nfo"])
|
||||
assert File.exists?(source.nfo_filepath)
|
||||
|
||||
File.rm!(source.nfo_filepath)
|
||||
end
|
||||
|
||||
test "does not store the NFO if not specified" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
profile = media_profile_fixture(%{download_nfo: false})
|
||||
source = source_fixture(%{nfo_filepath: nil, media_profile_id: profile.id})
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
source = Repo.reload(source)
|
||||
|
||||
refute source.nfo_filepath
|
||||
end
|
||||
|
||||
test "does not store the NFO if the series directory cannot be determined" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
profile = media_profile_fixture(%{download_nfo: true})
|
||||
source = source_fixture(%{nfo_filepath: nil, media_profile_id: profile.id})
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
source = Repo.reload(source)
|
||||
|
||||
refute source.nfo_filepath
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,11 +26,11 @@ defmodule Pinchflat.ProfilesTest do
|
||||
|
||||
describe "create_media_profile/1" 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 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
|
||||
|
||||
test "creation with invalid data returns error changeset" do
|
||||
@@ -44,14 +44,14 @@ defmodule Pinchflat.ProfilesTest do
|
||||
|
||||
update_attrs = %{
|
||||
name: "some updated name",
|
||||
output_path_template: "some updated output_path_template"
|
||||
output_path_template: "new_output_template.{{ ext }}"
|
||||
}
|
||||
|
||||
assert {:ok, %MediaProfile{} = media_profile} =
|
||||
Profiles.update_media_profile(media_profile, update_attrs)
|
||||
|
||||
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
|
||||
|
||||
test "updating with invalid data returns error changeset" do
|
||||
@@ -132,5 +132,41 @@ defmodule Pinchflat.ProfilesTest do
|
||||
media_profile = media_profile_fixture()
|
||||
assert %Ecto.Changeset{} = Profiles.change_media_profile(media_profile)
|
||||
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
|
||||
|
||||
@@ -101,7 +101,10 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||
|
||||
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, "MediaCollectionIndexingWorker"))
|
||||
end
|
||||
|
||||
assert_changed([from: 0, to: 1], task_count_fetcher, fn ->
|
||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||
@@ -158,5 +161,9 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||
end)
|
||||
end
|
||||
|
||||
test "does not blow up if the record doesn't exist" do
|
||||
assert :ok = perform_job(MediaCollectionIndexingWorker, %{id: 0})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -149,7 +149,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
||||
|
||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||
|
||||
assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id)
|
||||
assert [] = Tasks.list_tasks_for(media_item)
|
||||
end
|
||||
|
||||
test "it doesn't blow up if a media item cannot be coerced into a struct", %{source: source} do
|
||||
@@ -290,5 +290,22 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
||||
assert Repo.aggregate(MediaItem, :count, :id) == 3
|
||||
assert [_, _, _] = all_enqueued(worker: MediaDownloadWorker)
|
||||
end
|
||||
|
||||
test "does not blow up if the file returns invalid json", %{source: source} do
|
||||
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
|
||||
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
|
||||
filepath = Keyword.get(addl_opts, :output_filepath)
|
||||
File.write(filepath, "INVALID")
|
||||
|
||||
# Need to add a delay to ensure the file watcher has time to read the file
|
||||
:timer.sleep(watcher_poll_interval * 2)
|
||||
# We know we're testing the file watcher since the syncronous call will only
|
||||
# return an empty string (creating no records)
|
||||
{:ok, ""}
|
||||
end)
|
||||
|
||||
assert [] = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,16 +8,33 @@ defmodule Pinchflat.SourcesTest do
|
||||
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||
alias Pinchflat.Downloading.DownloadingHelpers
|
||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
||||
|
||||
@invalid_source_attrs %{name: nil, collection_id: nil}
|
||||
|
||||
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
|
||||
test "it returns all sources" do
|
||||
source = source_fixture()
|
||||
@@ -41,7 +58,7 @@ defmodule Pinchflat.SourcesTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_source/1" do
|
||||
describe "create_source/2" do
|
||||
test "creates a source and adds name + ID from runner response for channels" do
|
||||
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||
|
||||
@@ -220,9 +237,40 @@ defmodule Pinchflat.SourcesTest do
|
||||
|
||||
assert source.index_frequency_minutes == 0
|
||||
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
|
||||
|
||||
describe "update_source/2" do
|
||||
describe "create_source/2 when testing options" do
|
||||
test "run_post_commit_tasks: false won't enqueue post-commit tasks" do
|
||||
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||
|
||||
valid_attrs = %{
|
||||
media_profile_id: media_profile_fixture().id,
|
||||
original_url: "https://www.youtube.com/channel/abc123"
|
||||
}
|
||||
|
||||
assert {:ok, %Source{}} = Sources.create_source(valid_attrs, run_post_commit_tasks: false)
|
||||
|
||||
refute_enqueued(worker: MediaCollectionIndexingWorker)
|
||||
refute_enqueued(worker: SourceMetadataStorageWorker)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_source/3" do
|
||||
test "updates with valid data updates the source" do
|
||||
source = source_fixture()
|
||||
update_attrs = %{collection_name: "some updated name"}
|
||||
@@ -384,6 +432,29 @@ defmodule Pinchflat.SourcesTest do
|
||||
|
||||
assert source.index_frequency_minutes == 0
|
||||
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
|
||||
|
||||
describe "update_source/3 when testing options" do
|
||||
test "run_post_commit_tasks: false won't enqueue post-commit tasks" do
|
||||
source = source_fixture(%{fast_index: false, download_media: false, index_frequency_minutes: -1})
|
||||
update_attrs = %{fast_index: true, download_media: true, index_frequency_minutes: 100}
|
||||
|
||||
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs, run_post_commit_tasks: false)
|
||||
|
||||
refute_enqueued(worker: MediaCollectionIndexingWorker)
|
||||
refute_enqueued(worker: SourceMetadataStorageWorker)
|
||||
refute_enqueued(worker: MediaDownloadWorker)
|
||||
refute_enqueued(worker: FastIndexingWorker)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_source/2" do
|
||||
@@ -421,6 +492,32 @@ defmodule Pinchflat.SourcesTest do
|
||||
assert {:ok, %Source{}} = Sources.delete_source(source)
|
||||
assert File.exists?(media_item.media_filepath)
|
||||
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)
|
||||
refute File.exists?(updated_source.metadata.metadata_filepath)
|
||||
end
|
||||
|
||||
test "does not delete the source's non-metadata files" do
|
||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:nfo)
|
||||
source = source_fixture(%{nfo_filepath: filepath})
|
||||
|
||||
assert {:ok, _} = Sources.delete_source(source)
|
||||
assert File.exists?(filepath)
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_source/2 when deleting files" do
|
||||
@@ -442,6 +539,15 @@ defmodule Pinchflat.SourcesTest do
|
||||
|
||||
refute File.exists?(media_item.media_filepath)
|
||||
end
|
||||
|
||||
test "deletes the source's non-metadata files" do
|
||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:nfo)
|
||||
source = source_fixture(%{nfo_filepath: filepath})
|
||||
|
||||
assert {:ok, _} = Sources.delete_source(source, delete_files: true)
|
||||
|
||||
refute File.exists?(filepath)
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_source/3" do
|
||||
@@ -452,70 +558,6 @@ defmodule Pinchflat.SourcesTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_source_from_url/2" do
|
||||
test "it returns a changeset" do
|
||||
stub(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||
source = source_fixture()
|
||||
|
||||
assert %Ecto.Changeset{} = Sources.change_source_from_url(source, %{})
|
||||
end
|
||||
|
||||
test "it does not fetch source details if the original_url isn't in the changeset" do
|
||||
expect(YtDlpRunnerMock, :run, 0, &channel_mock/3)
|
||||
|
||||
changeset = Sources.change_source_from_url(%Source{}, %{name: "some updated name"})
|
||||
|
||||
assert %Ecto.Changeset{} = changeset
|
||||
end
|
||||
|
||||
test "it fetches source details if the original_url is in the changeset" do
|
||||
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||
|
||||
changeset =
|
||||
Sources.change_source_from_url(%Source{}, %{
|
||||
original_url: "https://www.youtube.com/channel/abc123"
|
||||
})
|
||||
|
||||
assert %Ecto.Changeset{} = changeset
|
||||
end
|
||||
|
||||
test "it adds source details to the changeset, keeping the orignal details" do
|
||||
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||
|
||||
media_profile = media_profile_fixture()
|
||||
media_profile_id = media_profile.id
|
||||
|
||||
changeset =
|
||||
Sources.change_source_from_url(%Source{}, %{
|
||||
original_url: "https://www.youtube.com/channel/abc123",
|
||||
media_profile_id: media_profile.id
|
||||
})
|
||||
|
||||
assert %Ecto.Changeset{} = changeset
|
||||
assert String.starts_with?(changeset.changes.collection_id, "some_channel_id_")
|
||||
|
||||
assert %{
|
||||
collection_name: "some channel name",
|
||||
media_profile_id: ^media_profile_id,
|
||||
original_url: "https://www.youtube.com/channel/abc123"
|
||||
} = changeset.changes
|
||||
end
|
||||
|
||||
test "it adds an error to the changeset if the runner fails" do
|
||||
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot ->
|
||||
{:error, "some error", 1}
|
||||
end)
|
||||
|
||||
changeset =
|
||||
Sources.change_source_from_url(%Source{}, %{
|
||||
original_url: "https://www.youtube.com/channel/abc123"
|
||||
})
|
||||
|
||||
assert %Ecto.Changeset{} = changeset
|
||||
assert errors_on(changeset).original_url == ["could not fetch source details from URL"]
|
||||
end
|
||||
end
|
||||
|
||||
defp playlist_mock(_url, _opts, _ot) do
|
||||
{
|
||||
:ok,
|
||||
|
||||
@@ -36,53 +36,59 @@ defmodule Pinchflat.TasksTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_tasks_for/4" do
|
||||
describe "list_tasks_for/3" do
|
||||
test "it lets you specify which record type/ID to join on" do
|
||||
task = task_fixture()
|
||||
source = source_fixture()
|
||||
task = task_fixture(source_id: source.id)
|
||||
|
||||
assert Tasks.list_tasks_for(:source_id, task.source_id) == [task]
|
||||
assert Tasks.list_tasks_for(source, nil, [:available]) == [task]
|
||||
end
|
||||
|
||||
test "it lets you specify which job states to include" do
|
||||
task = task_fixture()
|
||||
source = source_fixture()
|
||||
task = task_fixture(source_id: source.id)
|
||||
|
||||
assert Tasks.list_tasks_for(:source_id, task.source_id, nil, [:available]) == [task]
|
||||
assert Tasks.list_tasks_for(:source_id, task.source_id, nil, [:cancelled]) == []
|
||||
assert Tasks.list_tasks_for(source, nil, [:available]) == [task]
|
||||
assert Tasks.list_tasks_for(source, nil, [:cancelled]) == []
|
||||
end
|
||||
|
||||
test "it lets you specify which worker to include" do
|
||||
task = task_fixture()
|
||||
source = source_fixture()
|
||||
task = task_fixture(source_id: source.id)
|
||||
|
||||
assert Tasks.list_tasks_for(:source_id, task.source_id, "TestJobWorker") == [task]
|
||||
assert Tasks.list_tasks_for(:source_id, task.source_id, "FooBarWorker") == []
|
||||
assert Tasks.list_tasks_for(source, "TestJobWorker") == [task]
|
||||
assert Tasks.list_tasks_for(source, "FooBarWorker") == []
|
||||
end
|
||||
|
||||
test "it includes all workers if no worker is specified" do
|
||||
task = task_fixture()
|
||||
source = source_fixture()
|
||||
task = task_fixture(source_id: source.id)
|
||||
|
||||
assert Tasks.list_tasks_for(:source_id, task.source_id, nil) == [task]
|
||||
assert Tasks.list_tasks_for(source, nil) == [task]
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_pending_tasks_for/3" do
|
||||
test "it lists pending tasks" do
|
||||
task = task_fixture()
|
||||
source = source_fixture()
|
||||
task = task_fixture(source_id: source.id)
|
||||
|
||||
assert Tasks.list_pending_tasks_for(:source_id, task.source_id) == [task]
|
||||
assert Tasks.list_pending_tasks_for(source) == [task]
|
||||
end
|
||||
|
||||
test "it does not list non-pending tasks" do
|
||||
task = Repo.preload(task_fixture(), :job)
|
||||
task = Repo.preload(task_fixture(), [:job, :source])
|
||||
:ok = Oban.cancel_job(task.job)
|
||||
|
||||
assert Tasks.list_pending_tasks_for(:source_id, task.source_id) == []
|
||||
assert Tasks.list_pending_tasks_for(task.source) == []
|
||||
end
|
||||
|
||||
test "it lets you specify which worker to include" do
|
||||
task = task_fixture()
|
||||
source = source_fixture()
|
||||
task = task_fixture(source_id: source.id)
|
||||
|
||||
assert Tasks.list_pending_tasks_for(:source_id, task.source_id, "TestJobWorker") == [task]
|
||||
assert Tasks.list_pending_tasks_for(:source_id, task.source_id, "FooBarWorker") == []
|
||||
assert Tasks.list_pending_tasks_for(source, "TestJobWorker") == [task]
|
||||
assert Tasks.list_pending_tasks_for(source, "FooBarWorker") == []
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
|
||||
assert_receive {:handler, filename}
|
||||
assert String.ends_with?(filename, ".json")
|
||||
end
|
||||
|
||||
test "gracefully handles partially failed responses" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
|
||||
{:ok, "INVALID\n\n" <> source_attributes_return_fixture() <> "\nINVALID\n"}
|
||||
end)
|
||||
|
||||
assert {:ok, [%Media{media_id: "video1"}, %Media{media_id: "video2"}, %Media{media_id: "video3"}]} =
|
||||
MediaCollection.get_media_attributes_for_collection(@channel_url)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_source_details/1" do
|
||||
@@ -88,7 +97,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
|
||||
test "it passes the expected args to the backend runner" do
|
||||
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
|
||||
assert opts == [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1]
|
||||
assert ot == "%(.{channel,channel_id,playlist_id,playlist_title})j"
|
||||
assert ot == "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
|
||||
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
@@ -108,4 +117,49 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
|
||||
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_details(@channel_url)
|
||||
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
|
||||
|
||||
test "allows you to pass additional opts" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
|
||||
assert opts == [playlist_items: 0, real_opt: :yup]
|
||||
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = MediaCollection.get_source_metadata(@channel_url, real_opt: :yup)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,10 +8,10 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
||||
alias Pinchflat.Repo
|
||||
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 %{
|
||||
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}
|
||||
|
||||
@@ -81,7 +81,7 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
||||
media_profile: media_profile
|
||||
} do
|
||||
conn = get(conn, ~p"/media_profiles/#{media_profile}/edit")
|
||||
assert html_response(conn, 200) =~ "Edit Media Profile"
|
||||
assert html_response(conn, 200) =~ "Editing \"#{media_profile.name}\""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -98,7 +98,7 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
||||
|
||||
test "renders errors when data is invalid", %{conn: conn, media_profile: media_profile} do
|
||||
conn = put(conn, ~p"/media_profiles/#{media_profile}", media_profile: @invalid_attrs)
|
||||
assert html_response(conn, 200) =~ "Edit Media Profile"
|
||||
assert html_response(conn, 200) =~ "Editing \"#{media_profile.name}\""
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ defmodule PinchflatWeb.SourceControllerTest do
|
||||
|
||||
test "renders form for editing chosen source", %{conn: conn, source: source} do
|
||||
conn = get(conn, ~p"/sources/#{source}/edit")
|
||||
assert html_response(conn, 200) =~ "Edit Source"
|
||||
assert html_response(conn, 200) =~ "Editing \"#{source.custom_name}\""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -114,7 +114,7 @@ defmodule PinchflatWeb.SourceControllerTest do
|
||||
invalid_attrs: invalid_attrs
|
||||
} do
|
||||
conn = put(conn, ~p"/sources/#{source}", source: invalid_attrs)
|
||||
assert html_response(conn, 200) =~ "Edit Source"
|
||||
assert html_response(conn, 200) =~ "Editing \"#{source.custom_name}\""
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,237 @@
|
||||
{
|
||||
"id": "TheUselessTrials",
|
||||
"channel": "TheUselessTrials",
|
||||
"channel_id": "UCEi9yL4vhQlLafWRsAgAr3w",
|
||||
"title": "TheUselessTrials",
|
||||
"availability": null,
|
||||
"channel_follower_count": 35000,
|
||||
"description": "My name is Max and I'm a Street Trials bike rider with a passion for making videos. My channel features everything from learning and experiments all the way to just plain bike action. The first video series I started is called Quick New Trick and it featured me trying to learn new bike skills as quickly as possible, to show you how you can learn them as well.\n\nHope you enjoy the videos! See you in the comments section.",
|
||||
"tags": [
|
||||
"Useless Trials",
|
||||
"TUT",
|
||||
"UT",
|
||||
"Max Fiergolla",
|
||||
"maxfiergolla",
|
||||
"Street Trial Bikes",
|
||||
"Bike Tricks",
|
||||
"How To",
|
||||
"bike trial",
|
||||
"TRIAL BIKE",
|
||||
"quick new trick",
|
||||
"bike hacks",
|
||||
"trials bike",
|
||||
"bike trials",
|
||||
"TRIALS",
|
||||
"street trial",
|
||||
"trial street",
|
||||
"trials biking",
|
||||
"bike tricks",
|
||||
"bmx tricks",
|
||||
"mtb",
|
||||
"mtb tricks",
|
||||
"danny macaskill",
|
||||
"martyn ashton",
|
||||
"macaskill",
|
||||
"bike stunts",
|
||||
"mountainbike stunts",
|
||||
"mountainbiking",
|
||||
"inspired bicycles",
|
||||
"imaginate",
|
||||
"fahrrad tricks",
|
||||
"fahrradtricks",
|
||||
"fahrrad stunts"
|
||||
],
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 88,
|
||||
"width": 320,
|
||||
"preference": -10,
|
||||
"id": "0",
|
||||
"resolution": "320x88",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.0.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
|
||||
"height": 180,
|
||||
"width": 320,
|
||||
"preference": -10,
|
||||
"id": "1",
|
||||
"resolution": "320x180",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.1.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 175,
|
||||
"width": 640,
|
||||
"preference": -10,
|
||||
"id": "2",
|
||||
"resolution": "640x175",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.2.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
|
||||
"height": 480,
|
||||
"width": 854,
|
||||
"preference": -10,
|
||||
"id": "3",
|
||||
"resolution": "854x480",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.3.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 263,
|
||||
"width": 960,
|
||||
"preference": -10,
|
||||
"id": "4",
|
||||
"resolution": "960x263",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.4.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 175,
|
||||
"width": 1060,
|
||||
"preference": -10,
|
||||
"id": "5",
|
||||
"resolution": "1060x175",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.5.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 188,
|
||||
"width": 1138,
|
||||
"preference": -10,
|
||||
"id": "6",
|
||||
"resolution": "1138x188",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.6.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 351,
|
||||
"width": 1280,
|
||||
"preference": -10,
|
||||
"id": "7",
|
||||
"resolution": "1280x351",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.7.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
|
||||
"height": 720,
|
||||
"width": 1280,
|
||||
"preference": -10,
|
||||
"id": "8",
|
||||
"resolution": "1280x720",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.8.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 395,
|
||||
"width": 1440,
|
||||
"preference": -10,
|
||||
"id": "9",
|
||||
"resolution": "1440x395",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.9.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 283,
|
||||
"width": 1707,
|
||||
"preference": -10,
|
||||
"id": "10",
|
||||
"resolution": "1707x283",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.10.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"preference": -10,
|
||||
"id": "11",
|
||||
"resolution": "1920x1080",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.11.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 351,
|
||||
"width": 2120,
|
||||
"preference": -10,
|
||||
"id": "12",
|
||||
"resolution": "2120x351",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.12.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
|
||||
"height": 1192,
|
||||
"width": 2120,
|
||||
"preference": -10,
|
||||
"id": "13",
|
||||
"resolution": "2120x1192",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.13.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 377,
|
||||
"width": 2276,
|
||||
"preference": -10,
|
||||
"id": "14",
|
||||
"resolution": "2276x377",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.14.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
|
||||
"height": 424,
|
||||
"width": 2560,
|
||||
"preference": -10,
|
||||
"id": "15",
|
||||
"resolution": "2560x424",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.15.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=s0",
|
||||
"id": "banner_uncropped",
|
||||
"preference": -5,
|
||||
"filepath": "/app/test/support/files/channel_photos/a.banner_uncropped.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/ytc/AIdro_kw1KKWzbFmvq6u8WljTbo6QQ318WoAwnjX3AYrjA=s900-c-k-c0x00ffffff-no-rj",
|
||||
"height": 900,
|
||||
"width": 900,
|
||||
"id": "17",
|
||||
"resolution": "900x900",
|
||||
"filepath": "/app/test/support/files/channel_photos/a.17.jpg"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.googleusercontent.com/ytc/AIdro_kw1KKWzbFmvq6u8WljTbo6QQ318WoAwnjX3AYrjA=s0",
|
||||
"id": "avatar_uncropped",
|
||||
"preference": 1,
|
||||
"filepath": "/app/test/support/files/channel_photos/a.avatar_uncropped.jpg"
|
||||
}
|
||||
],
|
||||
"uploader_id": "@TheUselessTrials",
|
||||
"uploader_url": "https://www.youtube.com/@TheUselessTrials",
|
||||
"modified_date": null,
|
||||
"view_count": null,
|
||||
"playlist_count": 2,
|
||||
"uploader": "TheUselessTrials",
|
||||
"channel_url": "https://www.youtube.com/channel/UCEi9yL4vhQlLafWRsAgAr3w",
|
||||
"_type": "playlist",
|
||||
"entries": [],
|
||||
"webpage_url": "https://www.youtube.com/c/TheUselessTrials",
|
||||
"original_url": "https://www.youtube.com/c/TheUselessTrials",
|
||||
"webpage_url_basename": "TheUselessTrials",
|
||||
"webpage_url_domain": "youtube.com",
|
||||
"extractor": "youtube:tab",
|
||||
"extractor_key": "YoutubeTab",
|
||||
"release_year": null,
|
||||
"requested_entries": [],
|
||||
"epoch": 1710800380,
|
||||
"filename": "/app/test/support/files/channel_photos/a.NA",
|
||||
"formats_table": null,
|
||||
"thumbnails_table": "ID Width Height URL\n0 320 88 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n1 320 180 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n2 640 175 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n3 854 480 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n4 960 263 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n5 1060 175 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n6 1138 188 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n7 1280 351 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n8 1280 720 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n9 1440 395 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj\n10 1707 283 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n11 1920 1080 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n12 2120 351 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n13 2120 1192 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj\n14 2276 377 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\n15 2560 424 https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\nbanner_uncropped unknown unknown https://yt3.googleusercontent.com/_yUVmZqHvZZhNOFq2iMUPA82LylawAvYknCrwTE_9_IUQ0htiLPet2FKxE1ArvLAKf0lVsLccQ=s0\n17 900 900 https://yt3.googleusercontent.com/ytc/AIdro_kw1KKWzbFmvq6u8WljTbo6QQ318WoAwnjX3AYrjA=s900-c-k-c0x00ffffff-no-rj\navatar_uncropped unknown unknown https://yt3.googleusercontent.com/ytc/AIdro_kw1KKWzbFmvq6u8WljTbo6QQ318WoAwnjX3AYrjA=s0",
|
||||
"subtitles_table": null,
|
||||
"automatic_captions_table": null,
|
||||
"duration_string": null,
|
||||
"autonumber": 0,
|
||||
"video_autonumber": 0,
|
||||
"resolution": null
|
||||
}
|
||||