184 lines
6.1 KiB
Elixir
184 lines
6.1 KiB
Elixir
defmodule Pinchflat.YtDlp.Media do
|
|
@moduledoc """
|
|
Contains utilities for working with singular pieces of media
|
|
"""
|
|
|
|
@enforce_keys [
|
|
:media_id,
|
|
:title,
|
|
:description,
|
|
:original_url,
|
|
:livestream,
|
|
:short_form_content,
|
|
:uploaded_at,
|
|
:duration_seconds,
|
|
:predicted_media_filepath
|
|
]
|
|
|
|
defstruct [
|
|
:media_id,
|
|
:title,
|
|
:description,
|
|
:original_url,
|
|
:livestream,
|
|
:short_form_content,
|
|
:uploaded_at,
|
|
:duration_seconds,
|
|
:playlist_index,
|
|
:predicted_media_filepath
|
|
]
|
|
|
|
alias __MODULE__
|
|
alias Pinchflat.Utils.FunctionUtils
|
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
|
|
|
@doc """
|
|
Downloads a single piece of media (and possibly its metadata) directly to its
|
|
final destination. Returns the parsed JSON output from yt-dlp.
|
|
|
|
Returns {:ok, map()} | {:error, any, ...}.
|
|
"""
|
|
def download(url, command_opts \\ [], addl_opts \\ []) do
|
|
all_command_opts = [:no_simulate] ++ command_opts
|
|
|
|
with {:ok, output} <- backend_runner().run(url, :download, all_command_opts, "after_move:%()j", addl_opts),
|
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
|
{:ok, parsed_json}
|
|
else
|
|
err -> err
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Determines if the media at the given URL is ready to be downloaded.
|
|
Common examples of non-downloadable media are upcoming or in-progress live streams.
|
|
|
|
Returns {:ok, :downloadable | :ignorable} | {:error, any}
|
|
"""
|
|
def get_downloadable_status(url, addl_opts \\ []) do
|
|
action = :get_downloadable_status
|
|
command_opts = [:simulate, :skip_download]
|
|
|
|
case backend_runner().run(url, action, command_opts, "%(.{live_status})j", addl_opts) do
|
|
{:ok, output} ->
|
|
output
|
|
|> Phoenix.json_library().decode!()
|
|
|> parse_downloadable_status()
|
|
|
|
err ->
|
|
err
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Downloads a thumbnail for a single piece of media. Usually used for
|
|
downloading thumbnails for internal use
|
|
|
|
Returns {:ok, ""} | {:error, any, ...}.
|
|
"""
|
|
def download_thumbnail(url, command_opts \\ [], addl_opts \\ []) do
|
|
all_command_opts = [:no_simulate, :skip_download, :write_thumbnail, convert_thumbnail: "jpg"] ++ command_opts
|
|
|
|
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since
|
|
# we aren't downloading the main media file
|
|
backend_runner().run(url, :download_thumbnail, all_command_opts, "after_move:%()j", addl_opts)
|
|
end
|
|
|
|
@doc """
|
|
Returns a map representing the media at the given URL.
|
|
Optionally takes a list of additional command options to pass to yt-dlp
|
|
or configuration-related options to pass to the runner.
|
|
|
|
Returns {:ok, %Media{}} | {:error, any, ...}.
|
|
"""
|
|
def get_media_attributes(url, command_opts \\ [], addl_opts \\ []) do
|
|
all_command_opts = [:simulate, :skip_download] ++ command_opts
|
|
output_template = indexing_output_template()
|
|
|
|
case backend_runner().run(url, :get_media_attributes, all_command_opts, output_template, addl_opts) do
|
|
{:ok, output} ->
|
|
output
|
|
|> Phoenix.json_library().decode!()
|
|
|> response_to_struct()
|
|
|> FunctionUtils.wrap_ok()
|
|
|
|
err ->
|
|
err
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the output template for yt-dlp's indexing command.
|
|
|
|
NOTE: playlist_index is really only useful for playlists that will never change their order.
|
|
NOTE: I've switched back to `original_url` (from `webpage_url`) since it's started indicating
|
|
if something is a short via the URL again
|
|
"""
|
|
def indexing_output_template do
|
|
"%(.{id,title,live_status,original_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index,filename})j"
|
|
end
|
|
|
|
@doc """
|
|
Transforms a response from yt-dlp into a struct. Interprets the response to
|
|
determine if the media is short-form content.
|
|
|
|
Returns %Media{}.
|
|
"""
|
|
def response_to_struct(response) do
|
|
%Media{
|
|
media_id: response["id"],
|
|
title: response["title"],
|
|
description: response["description"],
|
|
original_url: response["original_url"],
|
|
livestream: !!response["live_status"] && response["live_status"] != "not_live",
|
|
duration_seconds: response["duration"] && round(response["duration"]),
|
|
short_form_content: response["original_url"] && short_form_content?(response),
|
|
uploaded_at: response["upload_date"] && parse_uploaded_at(response),
|
|
playlist_index: response["playlist_index"] || 0,
|
|
predicted_media_filepath: response["filename"]
|
|
}
|
|
end
|
|
|
|
defp short_form_content?(response) do
|
|
if String.contains?(response["original_url"], "/shorts/") do
|
|
true
|
|
else
|
|
# Sometimes shorts are returned without /shorts/ in the URL,
|
|
# so we need to do our best to determine if it's a short. This
|
|
# 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"] <= 180 && response["aspect_ratio"] <= 0.85
|
|
end
|
|
end
|
|
|
|
defp parse_uploaded_at(%{"timestamp" => ts} = response) when is_number(ts) do
|
|
case DateTime.from_unix(ts) do
|
|
{:ok, datetime} -> datetime
|
|
_ -> MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
|
end
|
|
end
|
|
|
|
# This field is needed before inserting into the database, but absence
|
|
# of this field should fail at insert-time rather than here
|
|
defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
|
|
defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
|
|
|
defp parse_downloadable_status(response) do
|
|
case response["live_status"] do
|
|
status when status in ["is_live", "is_upcoming", "post_live"] -> {:ok, :ignorable}
|
|
status when status in ["was_live", "not_live"] -> {:ok, :downloadable}
|
|
# This preserves my tenuous support for non-youtube sources.
|
|
nil -> {:ok, :downloadable}
|
|
_ -> {:error, "Unknown live status: #{response["live_status"]}"}
|
|
end
|
|
end
|
|
|
|
defp backend_runner do
|
|
# This approach lets us mock the command for testing
|
|
Application.get_env(:pinchflat, :yt_dlp_runner)
|
|
end
|
|
end
|