Refactor modules into contexts (#78)
* [WIP] break out a few contexts, start refactoring fast index modules * [WIP] more contexts, this time around slow indexing and downloads * [WIP] got all tests passing * [WIP] Added moduledocs * Built a genserver to rename old jobs on boot * Added a module naming check; moved things around * Fixed specs
This commit is contained in:
1 parent
7fa5c4da63
commit
33baa99aae
66 files changed
+1092
-568
No files matched your search
@@ -0,0 +1,126 @@
|
||||
defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
@moduledoc """
|
||||
Builds the options for yt-dlp to download media based on the given media profile.
|
||||
"""
|
||||
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.OutputPathBuilder
|
||||
|
||||
@doc """
|
||||
Builds the options for yt-dlp to download media based on the given media's profile.
|
||||
|
||||
IDEA: consider adding the ability to pass in a second argument to override
|
||||
these options
|
||||
"""
|
||||
def build(%MediaItem{} = media_item_with_preloads) do
|
||||
media_profile = media_item_with_preloads.source.media_profile
|
||||
|
||||
built_options =
|
||||
default_options() ++
|
||||
subtitle_options(media_profile) ++
|
||||
thumbnail_options(media_profile) ++
|
||||
metadata_options(media_profile) ++
|
||||
quality_options(media_profile) ++
|
||||
output_options(media_item_with_preloads)
|
||||
|
||||
{:ok, built_options}
|
||||
end
|
||||
|
||||
defp default_options do
|
||||
[:no_progress, :windows_filenames]
|
||||
end
|
||||
|
||||
defp subtitle_options(media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
Enum.reduce(mapped_struct, [], fn attr, acc ->
|
||||
case {attr, media_profile} do
|
||||
{{:download_subs, true}, _} ->
|
||||
# Force SRT for now - MAY provide as an option in the future
|
||||
acc ++ [:write_subs, convert_subs: "srt"]
|
||||
|
||||
{{:download_auto_subs, true}, %{download_subs: true}} ->
|
||||
acc ++ [:write_auto_subs]
|
||||
|
||||
{{:embed_subs, true}, %{preferred_resolution: pr}} when pr != :audio ->
|
||||
acc ++ [:embed_subs]
|
||||
|
||||
{{:sub_langs, sub_langs}, %{download_subs: true}} ->
|
||||
acc ++ [sub_langs: sub_langs]
|
||||
|
||||
{{:sub_langs, sub_langs}, %{embed_subs: true}} ->
|
||||
acc ++ [sub_langs: sub_langs]
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp thumbnail_options(media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
Enum.reduce(mapped_struct, [], fn attr, acc ->
|
||||
case attr do
|
||||
{:download_thumbnail, true} ->
|
||||
acc ++ [:write_thumbnail, convert_thumbnail: "jpg"]
|
||||
|
||||
{:embed_thumbnail, true} ->
|
||||
acc ++ [:embed_thumbnail]
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp metadata_options(media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
Enum.reduce(mapped_struct, [], fn attr, acc ->
|
||||
case attr do
|
||||
{:download_metadata, true} -> acc ++ [:write_info_json, :clean_info_json]
|
||||
{:embed_metadata, true} -> acc ++ [:embed_metadata]
|
||||
_ -> acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp quality_options(media_profile) do
|
||||
video_codec_options = "+codec:avc:m4a"
|
||||
|
||||
case media_profile.preferred_resolution do
|
||||
# Also be aware that :audio disabled all embedding options for subtitles
|
||||
:audio -> [:extract_audio, format: "bestaudio[ext=m4a]"]
|
||||
:"360p" -> [format_sort: "res:360,#{video_codec_options}"]
|
||||
:"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)
|
||||
]
|
||||
end
|
||||
|
||||
defp output_options_map(media_item_with_preloads) do
|
||||
source = media_item_with_preloads.source
|
||||
|
||||
%{
|
||||
"source_custom_name" => source.custom_name,
|
||||
"source_collection_type" => source.collection_type
|
||||
}
|
||||
end
|
||||
|
||||
defp base_directory do
|
||||
Application.get_env(:pinchflat, :media_directory)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
defmodule Pinchflat.Downloading.DownloadingHelpers do
|
||||
@moduledoc """
|
||||
Methods for helping download media
|
||||
|
||||
Many of these methods are made to be kickoff or be consumed by workers.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
|
||||
@doc """
|
||||
Starts tasks for downloading media for any of a sources _pending_ media items.
|
||||
Jobs are not enqueued if the source is set to not download media. This will return :ok.
|
||||
|
||||
NOTE: this starts a download for each media item that is pending,
|
||||
not just the ones that were indexed in this job run. This should ensure
|
||||
that any stragglers are caught if, for some reason, they weren't enqueued
|
||||
or somehow got de-queued.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
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)
|
||||
end
|
||||
|
||||
def enqueue_pending_download_tasks(%Source{download_media: false}) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes ALL pending tasks for a source's media items.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
def dequeue_pending_download_tasks(%Source{} = source) do
|
||||
source
|
||||
|> Media.list_pending_media_items_for()
|
||||
|> Enum.each(&Tasks.delete_pending_tasks_for/1)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
@moduledoc false
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :media_fetching,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||
tags: ["media_item", "media_fetching"]
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Downloading.MediaDownloader
|
||||
alias Pinchflat.Filesystem.FilesystemDataWorker
|
||||
|
||||
@impl Oban.Worker
|
||||
@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}
|
||||
"""
|
||||
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
|
||||
media_item =
|
||||
media_item_id
|
||||
|> Media.get_media_item!()
|
||||
|> Repo.preload(:source)
|
||||
|
||||
# If the source is set to not download media, perform a no-op
|
||||
if media_item.source.download_media do
|
||||
download_media_and_schedule_jobs(media_item)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
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}
|
||||
|
||||
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}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
defmodule Pinchflat.Downloading.MediaDownloader do
|
||||
@moduledoc """
|
||||
This is the integration layer for actually downloading media.
|
||||
It takes into account the media profile's settings in order
|
||||
to download the media with the desired options.
|
||||
"""
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Media.MediaItem
|
||||
|
||||
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
|
||||
returned by yt-dlp. Also saves the entire metadata response to the associated
|
||||
media_metadata record.
|
||||
|
||||
NOTE: related methods (like the download worker) won't download if the media item's source
|
||||
is set to not download media. However, I'm not enforcing that here since I need this for testing.
|
||||
This may change in the future but I'm not stressed.
|
||||
|
||||
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||
"""
|
||||
def download_for_media_item(%MediaItem{} = media_item) do
|
||||
item_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
|
||||
|
||||
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()
|
||||
|> Map.merge(%{
|
||||
media_downloaded_at: DateTime.utc_now(),
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
# Don't forgor to use preloaded associations or updates to
|
||||
# associations won't work!
|
||||
Media.update_media_item(item_with_preloads, parsed_attrs)
|
||||
|
||||
err ->
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
# def download_for_source(source, url) do
|
||||
# # Create MI from source and URL
|
||||
# media_item = nil
|
||||
# end
|
||||
|
||||
defp download_with_options(url, item_with_preloads) do
|
||||
{:ok, options} = YtDlpDownloadOptionBuilder.build(item_with_preloads)
|
||||
|
||||
YtDlpMedia.download(url, options)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
defmodule Pinchflat.Downloading.OutputPath.Base do
|
||||
@moduledoc """
|
||||
A base module for parsing rendered strings, designed as a macro to be used
|
||||
in other modules. See https://elixirforum.com/t/help-to-parse-a-template-with-nimbleparsec/47980
|
||||
|
||||
NOTE: if the needs here get any more complicated, look into using a Liquid
|
||||
template parser. No need to reinvent the wheel any more than I already have.
|
||||
|
||||
NOTE: this is effectively tested by the `Pinchflat.Downloading.OutputPath.Parser`'s tests
|
||||
"""
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote location: :keep do
|
||||
import NimbleParsec
|
||||
|
||||
opening_tag = string("{{")
|
||||
closing_tag = string("}}")
|
||||
optional_whitespaces = ascii_string(~c[ \t\n\r], min: 0)
|
||||
|
||||
# Capture everything up to the opening object
|
||||
text =
|
||||
lookahead_not(opening_tag)
|
||||
# ... as long as it's a character
|
||||
|> utf8_char([])
|
||||
# ... and there's at least one character
|
||||
|> times(min: 1)
|
||||
# ... and then convert it to a string
|
||||
|> reduce({List, :to_string, []})
|
||||
# ... finally bag it and tag it
|
||||
|> unwrap_and_tag(:text)
|
||||
|
||||
identifier =
|
||||
utf8_string([?a..?z, ?A..?Z, ?_, ?0..?9], min: 1)
|
||||
|> reduce({Enum, :join, []})
|
||||
|> unwrap_and_tag(:identifier)
|
||||
|
||||
defparsecp(:expression, identifier)
|
||||
|
||||
# when spotting interpolation, ignore the opening tag and any whitespace
|
||||
interpolation =
|
||||
ignore(concat(opening_tag, optional_whitespaces))
|
||||
# ... then parse the expression (identifier)
|
||||
|> parsec(:expression)
|
||||
# ... then ignore any whitespace and the closing tags after the expression
|
||||
|> ignore(concat(optional_whitespaces, closing_tag))
|
||||
# ... once again we bag it and tag it
|
||||
|> unwrap_and_tag(:interpolation)
|
||||
|
||||
defparsec(:do_parse, choice([interpolation, text]) |> repeat() |> eos())
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
defmodule Pinchflat.Downloading.OutputPath.Parser do
|
||||
@moduledoc """
|
||||
Parses liquid-ish-style strings into a rendered string
|
||||
|
||||
Used for turning filepath templates into real filepaths
|
||||
"""
|
||||
|
||||
use Pinchflat.Downloading.OutputPath.Base
|
||||
|
||||
@doc """
|
||||
Parses a string into a rendered string, using the provided variables. Optionally
|
||||
takes a custom fetcher function for handling missing variables.
|
||||
|
||||
Variable identifiers are surrounded by {{ and }}. The variable keys MUST be strings.
|
||||
If an identifier is not found in the provided variables, it will be removed from the string.
|
||||
|
||||
Returns `{:ok, binary()}` or `{:error, binary()}`.
|
||||
"""
|
||||
def parse(string, variables, value_fetch_fn \\ &default_fetcher/2) do
|
||||
# `do_parse` comes from `RenderedString.Base`
|
||||
case do_parse(string) do
|
||||
{:ok, parsed, _, _, _, _} ->
|
||||
{:ok, build_string(parsed, variables, value_fetch_fn)}
|
||||
|
||||
{:error, message, _, _, _, _} ->
|
||||
{:error, message}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_string(parsed, variables, value_fetch_fn) do
|
||||
Enum.reduce(parsed, "", fn element, acc ->
|
||||
case element do
|
||||
{:text, text} -> acc <> text
|
||||
{:interpolation, {:identifier, identifier}} -> acc <> value_fetch_fn.(identifier, variables)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def default_fetcher(identifier, variables) do
|
||||
Map.get(variables, identifier, "")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
defmodule Pinchflat.Downloading.OutputPathBuilder do
|
||||
@moduledoc """
|
||||
Builds yt-dlp-friendly output paths for downloaded media
|
||||
"""
|
||||
|
||||
alias Pinchflat.Downloading.OutputPath.Parser, as: TemplateParser
|
||||
|
||||
@doc """
|
||||
Builds the actual final filepath from a given template. Optionally, you can pass in
|
||||
a map of additional options to be used in the template.
|
||||
|
||||
Translates liquid-style templates into yt-dlp-style templates,
|
||||
leaving yt-dlp syntax intact.
|
||||
"""
|
||||
def build(template_string, additional_template_options \\ %{}) do
|
||||
combined_options = Map.merge(custom_yt_dlp_option_map(), additional_template_options)
|
||||
|
||||
TemplateParser.parse(template_string, combined_options, &identifier_fn/2)
|
||||
end
|
||||
|
||||
# The `nil` case simply wraps the identifier in yt-dlp-style syntax. This assumes that
|
||||
# the identifier is a valid yt-dlp option. The upside is that this gives the user
|
||||
# access to ALL single-word yt-dlp options in the (imo) more friendly/forgiving liquid-style syntax.
|
||||
#
|
||||
# For all "custom" variables, we use the `Map.get/3` function to look up the value in the provided.
|
||||
# See `custom_yt_dlp_option_map` for a list of those.
|
||||
defp identifier_fn(identifier, variables) do
|
||||
case Map.get(variables, identifier) do
|
||||
nil -> "%(#{identifier})S"
|
||||
value -> value
|
||||
end
|
||||
end
|
||||
|
||||
# This isn't the only source for custom options, since they can be passed in my the caller.
|
||||
# `download_option_builder` is the most likely place for other custom options to be added,
|
||||
# but if in doubt just search the codebase for `OutputPathBuilder.build`.
|
||||
defp custom_yt_dlp_option_map do
|
||||
%{
|
||||
# Individual parts of the upload date
|
||||
"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"
|
||||
}
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user