Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6d8e15670 | |||
| 2906ff1b30 | |||
| 0582a7bfd5 | |||
| 3db2e8190f | |||
| 087c9c2d0f | |||
| c046abf36f | |||
| 70dd95211f | |||
| 8491f8b317 | |||
| d0cb782082 | |||
| 7a7e8080cd | |||
| fbe21cb304 | |||
| 0f3329e97d | |||
| 25eb772896 | |||
| cf59bf99cd | |||
| 47e96e3780 | |||
| a7af6a9125 | |||
| 8f9d18dc71 |
@@ -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, []},
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
# Pinchflat (Alpha)
|
||||
<p align="center">
|
||||
<img
|
||||
src="priv/static/images/originals/logo-white-wordmark-with-background.png"
|
||||
alt="Pinchflat Logo by @hernandito"
|
||||
width="700"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sup>
|
||||
<em>logo by <a href="https://github.com/hernandito" target="_blank">@hernandito</a></em>
|
||||
</sup>
|
||||
</p>
|
||||
|
||||
# Your next YouTube media manager
|
||||
|
||||
## Disclaimer
|
||||
|
||||
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
|
||||
|
||||
@@ -25,6 +25,21 @@ config :pinchflat,
|
||||
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
|
||||
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD")
|
||||
|
||||
arch_string = to_string(:erlang.system_info(:system_architecture))
|
||||
|
||||
system_arch =
|
||||
cond do
|
||||
String.contains?(arch_string, "arm") -> "arm"
|
||||
String.contains?(arch_string, "aarch") -> "arm"
|
||||
String.contains?(arch_string, "x86") -> "x86"
|
||||
true -> "unknown"
|
||||
end
|
||||
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
load_extensions: [
|
||||
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
|
||||
]
|
||||
|
||||
if config_env() == :prod do
|
||||
config_path =
|
||||
System.get_env("CONFIG_PATH") ||
|
||||
|
||||
@@ -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,16 +70,19 @@ 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]
|
||||
acc ++ [:embed_thumbnail, convert_thumbnail: "jpg"]
|
||||
|
||||
_ ->
|
||||
acc
|
||||
@@ -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.
|
||||
@@ -65,6 +66,7 @@ defmodule Pinchflat.Media do
|
||||
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|
||||
|> where(^build_format_clauses(media_profile))
|
||||
|> where(^maybe_apply_cutoff_date(source))
|
||||
|> where(^maybe_apply_title_regex(source))
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
end
|
||||
@@ -141,38 +143,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 +189,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 +215,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)
|
||||
@@ -270,6 +248,14 @@ defmodule Pinchflat.Media do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_apply_title_regex(source) do
|
||||
if source.title_filter_regex do
|
||||
dynamic([mi], fragment("regexp_like(?, ?)", mi.title, ^source.title_filter_regex))
|
||||
else
|
||||
dynamic(true)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_format_clauses(media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@ defmodule Pinchflat.SlowIndexing.FileFollowerServer do
|
||||
require Logger
|
||||
|
||||
@poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval)
|
||||
@activity_timeout_ms 60_000
|
||||
@activity_timeout_ms 600_000
|
||||
|
||||
# Client API
|
||||
@doc """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources
|
||||
@@ -31,16 +32,14 @@ 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 """
|
||||
Given a media source, creates (indexes) the media by creating media_items for each
|
||||
media ID in the source. Afterward, kicks off a download task for each pending media
|
||||
item belonging to the source. You can't tell me the method name isn't descriptive!
|
||||
Returns a list of media items or changesets (if the media item couldn't be created).
|
||||
|
||||
Indexing is slow and usually returns a list of all media data at once for record creation.
|
||||
To help with this, we use a file follower to watch the file that yt-dlp writes to
|
||||
@@ -56,12 +55,15 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||
Since indexing returns all media data EVERY TIME, we that that opportunity to update
|
||||
indexing metadata for media items that have already been created.
|
||||
|
||||
Returns [%MediaItem{}, ...]
|
||||
Returns [%MediaItem{} | %Ecto.Changeset{}]
|
||||
"""
|
||||
def index_and_enqueue_download_for_media_items(%Source{} = source) do
|
||||
# See the method definition below for more info on how file watchers work
|
||||
# (important reading if you're not familiar with it)
|
||||
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source)
|
||||
# Reload because the source may have been updated during the (long-running) indexing process
|
||||
# and important settings like `download_media` may have changed.
|
||||
source = Repo.reload!(source)
|
||||
|
||||
result =
|
||||
Enum.map(media_attributes, fn media_attrs ->
|
||||
@@ -119,14 +121,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||
end
|
||||
|
||||
defp create_media_item_and_enqueue_download(source, media_attrs) do
|
||||
# Reload because the source may have been updated during the (long-running) indexing process
|
||||
# and important settings like `download_media` may have changed.
|
||||
source = Repo.reload!(source)
|
||||
|
||||
case Media.create_media_item_from_backend_attrs(source, media_attrs) do
|
||||
{:ok, %MediaItem{} = media_item} ->
|
||||
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,25 +10,33 @@ 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
|
||||
last_indexed_at
|
||||
original_url
|
||||
download_cutoff_date
|
||||
title_filter_regex
|
||||
media_profile_id
|
||||
)a
|
||||
|
||||
# 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
|
||||
@@ -57,9 +65,18 @@ defmodule Pinchflat.Sources.Source do
|
||||
# Only download media items that were published after this date
|
||||
field :download_cutoff_date, :date
|
||||
field :original_url, :string
|
||||
field :title_filter_regex, :string
|
||||
|
||||
field :series_directory, :string
|
||||
field :nfo_filepath, :string
|
||||
field :poster_filepath, :string
|
||||
field :fanart_filepath, :string
|
||||
field :banner_filepath, :string
|
||||
|
||||
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,7 +97,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)
|
||||
|> unique_constraint([:collection_id, :media_profile_id])
|
||||
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@@ -94,4 +111,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
|
||||
@@ -85,8 +86,8 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
description: response["description"],
|
||||
original_url: response["webpage_url"],
|
||||
livestream: response["was_live"],
|
||||
short_form_content: short_form_content?(response),
|
||||
upload_date: parse_upload_date(response["upload_date"])
|
||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
||||
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||
}
|
||||
end
|
||||
|
||||
@@ -99,16 +100,13 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
# WILL returns false positives, but it's a best-effort approach
|
||||
# that should work for most cases. The aspect_ratio check is
|
||||
# based on a gut feeling and may need to be tweaked.
|
||||
#
|
||||
# These don't fail if duration or aspect_ratio are missing
|
||||
# due to Elixir's comparison semantics
|
||||
response["duration"] <= 60 && response["aspect_ratio"] < 0.8
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
|
||||
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
|
||||
<div class="flex flex-grow items-center px-4 py-4 shadow-2 md:px-6 2xl:px-11">
|
||||
<div class="flex items-center">
|
||||
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
|
||||
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
|
||||
<div class="w-65 px-4 py-2 shadow-2 md:px-6">
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
|
||||
<header class="sticky top-0 z-999 flex h-20 w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
|
||||
<div class="flex flex-grow items-center justify-between lg:justify-end px-4 py-4 shadow-2 md:px-6 2xl:px-11">
|
||||
<div class="flex items-center gap-2 sm:gap-4 lg:hidden">
|
||||
<div class="flex items-center gap-2 sm:gap-4 lg:hidden w-2/6">
|
||||
<section class="pr-1">
|
||||
<img src={~p"/images/icon.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-10" />
|
||||
</section>
|
||||
|
||||
<button
|
||||
class="z-99999 block rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden"
|
||||
class="z-99999 block mx-2 rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden"
|
||||
@click.stop="sidebarVisible = !sidebarVisible"
|
||||
>
|
||||
<.icon name="hero-bars-3" />
|
||||
</button>
|
||||
<a class="hidden sm:flex items-center lg:hidden" href="/">
|
||||
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
|
||||
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-meta-4 rounded-md">
|
||||
<div class="bg-meta-4 rounded-md w-4/6 lg:w-3/6 xl:w-2/6">
|
||||
<div class="relative">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
|
||||
<.icon name="hero-magnifying-glass" />
|
||||
@@ -23,7 +23,7 @@
|
||||
name="q"
|
||||
value={@params["q"]}
|
||||
placeholder="Type to search..."
|
||||
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none lg:w-125"
|
||||
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<aside
|
||||
x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'"
|
||||
class={[
|
||||
"-translate-x-full absolute left-0 top-0 z-9999 flex h-screen w-60 flex-col overflow-y-hidden justify-between",
|
||||
"-translate-x-full absolute left-0 top-0 z-9999 flex h-screen w-65 flex-col overflow-y-hidden justify-between",
|
||||
"bg-black duration-300 ease-linear shadow-lg sm:shadow-none dark:bg-boxdark lg:static lg:translate-x-0"
|
||||
]}
|
||||
@click.outside="sidebarVisible = false"
|
||||
>
|
||||
<section>
|
||||
<div class="flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5">
|
||||
<div class="flex items-center justify-between gap-2 px-6 py-4">
|
||||
<a href="/" class="flex items-center">
|
||||
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
|
||||
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
|
||||
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
|
||||
</a>
|
||||
|
||||
<button class="block lg:hidden" @click.stop="sidebarVisible = !sidebarVisible">
|
||||
<button class="block mt-3 lg:hidden" @click.stop="sidebarVisible = !sidebarVisible">
|
||||
<.icon name="hero-arrow-left" class="fill-current" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
|
||||
<nav class="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
|
||||
<nav class="mt-3 px-4 py-4 lg:px-6">
|
||||
<h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">MENU</h3>
|
||||
<div class="flex flex-col justify-between">
|
||||
<ul class="mb-6 flex flex-col gap-1.5">
|
||||
@@ -31,7 +30,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<nav class="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
|
||||
<nav class="px-4 py-4 lg:px-6">
|
||||
<ul class="mb-6 flex flex-col gap-1.5">
|
||||
<.sidebar_item
|
||||
icon="hero-code-bracket"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<%= assigns[:page_title] || "Pinchflat" %>
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-02-29"} />
|
||||
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-03-20"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -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" rounding="rounded-lg">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>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
<.simple_form :let={f} for={@changeset} action={@action}>
|
||||
<.simple_form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
action={@action}
|
||||
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
|
||||
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
|
||||
>
|
||||
<.error :if={@changeset.action}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
General Options
|
||||
</h3>
|
||||
<section class="flex justify-between items-center mt-8">
|
||||
<h3 class=" text-2xl text-black dark:text-white">
|
||||
General Options
|
||||
</h3>
|
||||
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<.input
|
||||
field={f[:custom_name]}
|
||||
@@ -21,6 +32,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 +44,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 +52,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 +65,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"
|
||||
/>
|
||||
|
||||
@@ -62,10 +74,30 @@
|
||||
type="text"
|
||||
label="Download Cutoff Date"
|
||||
placeholder="YYYY-MM-DD"
|
||||
maxlength="10"
|
||||
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
|
||||
title="YYYY-MM-DD"
|
||||
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
|
||||
/>
|
||||
|
||||
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Source</.button>
|
||||
<section x-show="advancedMode">
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Advanced Options
|
||||
</h3>
|
||||
<p class="text-sm mt-2">
|
||||
Tread carefully
|
||||
</p>
|
||||
|
||||
<.input
|
||||
field={f[:title_filter_regex]}
|
||||
type="text"
|
||||
label="Title Filter Regex"
|
||||
placeholder="(?i)^How to Bike$"
|
||||
help="A PCRE-compatible regex. Only media with titles that match this regex will be downloaded. Look up 'SQLean Regex docs' for more"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Source</.button>
|
||||
|
||||
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
|
||||
<.fast_indexing_help />
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :pinchflat,
|
||||
version: "0.1.0-alpha.3",
|
||||
version: "0.1.0-alpha.7",
|
||||
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
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddTitleRegexToSource do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:sources) do
|
||||
add :title_filter_regex, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
defmodule Pinchflat.Repo.Migrations.RemoveUniqueIndexFromSources do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
drop unique_index(:sources, [:collection_id, :media_profile_id])
|
||||
end
|
||||
end
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 164 KiB |
@@ -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)
|
||||
@@ -141,6 +149,14 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
assert :embed_thumbnail in res
|
||||
end
|
||||
|
||||
test "convertes thumbnail to jpg when embed_thumbnail is true", %{media_item: media_item} do
|
||||
media_item = update_media_profile_attribute(media_item, %{embed_thumbnail: true})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert {:convert_thumbnail, "jpg"} in res
|
||||
end
|
||||
|
||||
test "doesn't include these options when not specified", %{media_item: media_item} do
|
||||
media_item = update_media_profile_attribute(media_item, %{embed_thumbnail: false, download_thumbnail: false})
|
||||
|
||||
@@ -181,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
|
||||
@@ -199,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
|
||||
|
||||