mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-30 14:57:59 +01:00
Split out media processing tools into PhilomenaMedia namespace
This commit is contained in:
parent
0d6acafc96
commit
c8d696540f
37 changed files with 1266 additions and 541 deletions
|
@ -10,7 +10,7 @@ defmodule Mix.Tasks.UploadToS3 do
|
||||||
}
|
}
|
||||||
|
|
||||||
alias Philomena.Images.Thumbnailer
|
alias Philomena.Images.Thumbnailer
|
||||||
alias Philomena.Objects
|
alias PhilomenaMedia.Objects
|
||||||
alias PhilomenaQuery.Batch
|
alias PhilomenaQuery.Batch
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ defmodule Philomena.Adverts.Uploader do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Philomena.Adverts.Advert
|
alias Philomena.Adverts.Advert
|
||||||
alias Philomena.Uploader
|
alias PhilomenaMedia.Uploader
|
||||||
|
|
||||||
def analyze_upload(advert, params) do
|
def analyze_upload(advert, params) do
|
||||||
Uploader.analyze_upload(advert, "image", params["image"], &Advert.image_changeset/2)
|
Uploader.analyze_upload(advert, "image", params["image"], &Advert.image_changeset/2)
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
defmodule Philomena.Analyzers do
|
|
||||||
@moduledoc """
|
|
||||||
Utilities for analyzing the format and various attributes of uploaded files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
alias Philomena.Mime
|
|
||||||
|
|
||||||
alias Philomena.Analyzers.Gif
|
|
||||||
alias Philomena.Analyzers.Jpeg
|
|
||||||
alias Philomena.Analyzers.Png
|
|
||||||
alias Philomena.Analyzers.Svg
|
|
||||||
alias Philomena.Analyzers.Webm
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns an {:ok, analyzer} tuple, with the analyzer being a module capable
|
|
||||||
of analyzing this content type, or :error.
|
|
||||||
|
|
||||||
To use an analyzer, call the analyze/1 method on it with the path to the
|
|
||||||
file. It will return a map such as the following:
|
|
||||||
|
|
||||||
%{
|
|
||||||
animated?: false,
|
|
||||||
dimensions: {800, 600},
|
|
||||||
duration: 0.0,
|
|
||||||
extension: "png",
|
|
||||||
mime_type: "image/png"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
@spec analyzer(binary()) :: {:ok, module()} | :error
|
|
||||||
def analyzer(content_type)
|
|
||||||
|
|
||||||
def analyzer("image/gif"), do: {:ok, Gif}
|
|
||||||
def analyzer("image/jpeg"), do: {:ok, Jpeg}
|
|
||||||
def analyzer("image/png"), do: {:ok, Png}
|
|
||||||
def analyzer("image/svg+xml"), do: {:ok, Svg}
|
|
||||||
def analyzer("video/webm"), do: {:ok, Webm}
|
|
||||||
def analyzer(_content_type), do: :error
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Attempts a mime check and analysis on the given pathname or Plug.Upload.
|
|
||||||
"""
|
|
||||||
@spec analyze(Plug.Upload.t() | String.t()) :: {:ok, map()} | :error
|
|
||||||
def analyze(%Plug.Upload{path: path}), do: analyze(path)
|
|
||||||
|
|
||||||
def analyze(path) when is_binary(path) do
|
|
||||||
with {:ok, mime} <- Mime.file(path),
|
|
||||||
{:ok, analyzer} <- analyzer(mime) do
|
|
||||||
{:ok, analyzer.analyze(path)}
|
|
||||||
else
|
|
||||||
error ->
|
|
||||||
error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def analyze(_path), do: :error
|
|
||||||
end
|
|
|
@ -4,7 +4,7 @@ defmodule Philomena.Badges.Uploader do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Philomena.Badges.Badge
|
alias Philomena.Badges.Badge
|
||||||
alias Philomena.Uploader
|
alias PhilomenaMedia.Uploader
|
||||||
|
|
||||||
def analyze_upload(badge, params) do
|
def analyze_upload(badge, params) do
|
||||||
Uploader.analyze_upload(badge, "image", params["image"], &Badge.image_changeset/2)
|
Uploader.analyze_upload(badge, "image", params["image"], &Badge.image_changeset/2)
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
defmodule Philomena.Filename do
|
|
||||||
@moduledoc """
|
|
||||||
Utilities for building arbitrary filenames for uploaded files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@spec build(String.t()) :: String.t()
|
|
||||||
def build(extension) do
|
|
||||||
[
|
|
||||||
time_identifier(DateTime.utc_now()),
|
|
||||||
"/",
|
|
||||||
UUID.uuid1(),
|
|
||||||
".",
|
|
||||||
extension
|
|
||||||
]
|
|
||||||
|> Enum.join()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp time_identifier(time) do
|
|
||||||
Enum.join([time.year, time.month, time.day], "/")
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -36,9 +36,9 @@ defmodule Philomena.ImageIntensities do
|
||||||
{:error, %Ecto.Changeset{}}
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def create_image_intensity(image, attrs \\ %{}) do
|
def create_image_intensity(image, attrs \\ %PhilomenaMedia.Intensities{}) do
|
||||||
%ImageIntensity{image_id: image.id}
|
%ImageIntensity{image_id: image.id}
|
||||||
|> ImageIntensity.changeset(attrs)
|
|> ImageIntensity.changeset(Map.from_struct(attrs))
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ defmodule Philomena.ImageIntensities do
|
||||||
"""
|
"""
|
||||||
def update_image_intensity(%ImageIntensity{} = image_intensity, attrs) do
|
def update_image_intensity(%ImageIntensity{} = image_intensity, attrs) do
|
||||||
image_intensity
|
image_intensity
|
||||||
|> ImageIntensity.changeset(attrs)
|
|> ImageIntensity.changeset(Map.from_struct(attrs))
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,15 +3,16 @@ defmodule Philomena.Images.Thumbnailer do
|
||||||
Prevewing and thumbnailing logic for Images.
|
Prevewing and thumbnailing logic for Images.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Processors
|
||||||
|
alias PhilomenaMedia.Analyzers
|
||||||
|
alias PhilomenaMedia.Uploader
|
||||||
|
alias PhilomenaMedia.Objects
|
||||||
|
alias PhilomenaMedia.Sha512
|
||||||
|
|
||||||
alias Philomena.DuplicateReports
|
alias Philomena.DuplicateReports
|
||||||
alias Philomena.ImageIntensities
|
alias Philomena.ImageIntensities
|
||||||
alias Philomena.ImagePurgeWorker
|
alias Philomena.ImagePurgeWorker
|
||||||
alias Philomena.Images.Image
|
alias Philomena.Images.Image
|
||||||
alias Philomena.Processors
|
|
||||||
alias Philomena.Analyzers
|
|
||||||
alias Philomena.Uploader
|
|
||||||
alias Philomena.Objects
|
|
||||||
alias Philomena.Sha512
|
|
||||||
alias Philomena.Repo
|
alias Philomena.Repo
|
||||||
|
|
||||||
@versions [
|
@versions [
|
||||||
|
|
|
@ -5,7 +5,7 @@ defmodule Philomena.Images.Uploader do
|
||||||
|
|
||||||
alias Philomena.Images.Thumbnailer
|
alias Philomena.Images.Thumbnailer
|
||||||
alias Philomena.Images.Image
|
alias Philomena.Images.Image
|
||||||
alias Philomena.Uploader
|
alias PhilomenaMedia.Uploader
|
||||||
|
|
||||||
def analyze_upload(image, params) do
|
def analyze_upload(image, params) do
|
||||||
Uploader.analyze_upload(image, "image", params["image"], &Image.image_changeset/2)
|
Uploader.analyze_upload(image, "image", params["image"], &Image.image_changeset/2)
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
defmodule Philomena.Intensities do
|
|
||||||
@doc """
|
|
||||||
Gets the corner intensities of the given image file.
|
|
||||||
The image file must be in the PNG or JPEG format.
|
|
||||||
"""
|
|
||||||
@spec file(String.t()) :: {:ok, map()} | :error
|
|
||||||
def file(input) do
|
|
||||||
System.cmd("image-intensities", [input])
|
|
||||||
|> case do
|
|
||||||
{output, 0} ->
|
|
||||||
[nw, ne, sw, se] =
|
|
||||||
output
|
|
||||||
|> String.trim()
|
|
||||||
|> String.split("\t")
|
|
||||||
|> Enum.map(&String.to_float/1)
|
|
||||||
|
|
||||||
{:ok, %{nw: nw, ne: ne, sw: sw, se: se}}
|
|
||||||
|
|
||||||
_error ->
|
|
||||||
:error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,37 +0,0 @@
|
||||||
defmodule Philomena.Mime do
|
|
||||||
@type mime :: String.t()
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets the mime type of the given pathname.
|
|
||||||
"""
|
|
||||||
@spec file(String.t()) :: {:ok, mime()} | :error
|
|
||||||
def file(path) do
|
|
||||||
System.cmd("file", ["-b", "--mime-type", path])
|
|
||||||
|> case do
|
|
||||||
{output, 0} ->
|
|
||||||
true_mime(String.trim(output))
|
|
||||||
|
|
||||||
_error ->
|
|
||||||
:error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Provides the "true" content type of this file.
|
|
||||||
|
|
||||||
Some files are identified incorrectly as a mime type they should not be.
|
|
||||||
These incorrect mime types (and their "corrected") versions are:
|
|
||||||
|
|
||||||
- image/svg -> image/svg+xml
|
|
||||||
- audio/webm -> video/webm
|
|
||||||
"""
|
|
||||||
@spec true_mime(String.t()) :: {:ok, mime()}
|
|
||||||
def true_mime("image/svg"), do: {:ok, "image/svg+xml"}
|
|
||||||
def true_mime("audio/webm"), do: {:ok, "video/webm"}
|
|
||||||
|
|
||||||
def true_mime(mime)
|
|
||||||
when mime in ~W(image/gif image/jpeg image/png image/svg+xml video/webm),
|
|
||||||
do: {:ok, mime}
|
|
||||||
|
|
||||||
def true_mime(mime), do: {:unsupported_mime, mime}
|
|
||||||
end
|
|
|
@ -1,148 +0,0 @@
|
||||||
defmodule Philomena.Objects do
|
|
||||||
@moduledoc """
|
|
||||||
Replication wrapper for object storage backends.
|
|
||||||
"""
|
|
||||||
alias Philomena.Mime
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
#
|
|
||||||
# Fetch a key from the storage backend and
|
|
||||||
# write it into the destination file.
|
|
||||||
#
|
|
||||||
# sobelow_skip ["Traversal.FileModule"]
|
|
||||||
@spec download_file(String.t(), String.t()) :: any()
|
|
||||||
def download_file(key, file_path) do
|
|
||||||
contents =
|
|
||||||
backends()
|
|
||||||
|> Enum.find_value(fn opts ->
|
|
||||||
ExAws.S3.get_object(opts[:bucket], key)
|
|
||||||
|> ExAws.request(opts[:config_overrides])
|
|
||||||
|> case do
|
|
||||||
{:ok, result} -> result
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
File.write!(file_path, contents.body)
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# Upload a file using a single API call, writing the
|
|
||||||
# contents from the given path to storage.
|
|
||||||
#
|
|
||||||
# sobelow_skip ["Traversal.FileModule"]
|
|
||||||
@spec put(String.t(), String.t()) :: any()
|
|
||||||
def put(key, file_path) do
|
|
||||||
{_, mime} = Mime.file(file_path)
|
|
||||||
contents = File.read!(file_path)
|
|
||||||
|
|
||||||
run_all(fn opts ->
|
|
||||||
ExAws.S3.put_object(opts[:bucket], key, contents, content_type: mime)
|
|
||||||
|> ExAws.request!(opts[:config_overrides])
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# Upload a file using multiple API calls, writing the
|
|
||||||
# contents from the given path to storage.
|
|
||||||
#
|
|
||||||
@spec upload(String.t(), String.t()) :: any()
|
|
||||||
def upload(key, file_path) do
|
|
||||||
# Workaround for API rate limit issues on R2
|
|
||||||
put(key, file_path)
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copies a key from the source to the destination,
|
|
||||||
# overwriting the destination object if its exists.
|
|
||||||
#
|
|
||||||
@spec copy(String.t(), String.t()) :: any()
|
|
||||||
def copy(source_key, dest_key) do
|
|
||||||
# Potential workaround for inconsistent PutObjectCopy on R2
|
|
||||||
#
|
|
||||||
# run_all(fn opts->
|
|
||||||
# ExAws.S3.put_object_copy(opts[:bucket], dest_key, opts[:bucket], source_key)
|
|
||||||
# |> ExAws.request!(opts[:config_overrides])
|
|
||||||
# end)
|
|
||||||
|
|
||||||
try do
|
|
||||||
file_path = Briefly.create!()
|
|
||||||
download_file(source_key, file_path)
|
|
||||||
upload(dest_key, file_path)
|
|
||||||
catch
|
|
||||||
_kind, _value -> Logger.warning("Failed to copy #{source_key} -> #{dest_key}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# Removes the key from storage.
|
|
||||||
#
|
|
||||||
@spec delete(String.t()) :: any()
|
|
||||||
def delete(key) do
|
|
||||||
run_all(fn opts ->
|
|
||||||
ExAws.S3.delete_object(opts[:bucket], key)
|
|
||||||
|> ExAws.request!(opts[:config_overrides])
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
#
|
|
||||||
# Removes all given keys from storage.
|
|
||||||
#
|
|
||||||
@spec delete_multiple([String.t()]) :: any()
|
|
||||||
def delete_multiple(keys) do
|
|
||||||
run_all(fn opts ->
|
|
||||||
ExAws.S3.delete_multiple_objects(opts[:bucket], keys)
|
|
||||||
|> ExAws.request!(opts[:config_overrides])
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp run_all(wrapped) do
|
|
||||||
fun = fn opts ->
|
|
||||||
try do
|
|
||||||
wrapped.(opts)
|
|
||||||
:ok
|
|
||||||
catch
|
|
||||||
_kind, _value -> :error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
backends()
|
|
||||||
|> Task.async_stream(fun, timeout: :infinity)
|
|
||||||
|> Enum.any?(fn {_, v} -> v == :error end)
|
|
||||||
|> case do
|
|
||||||
true ->
|
|
||||||
Logger.warning("Failed to operate on all backends")
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp backends do
|
|
||||||
primary_opts() ++ replica_opts()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp primary_opts do
|
|
||||||
[
|
|
||||||
%{
|
|
||||||
config_overrides: Application.fetch_env!(:philomena, :s3_primary_options),
|
|
||||||
bucket: Application.fetch_env!(:philomena, :s3_primary_bucket)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
defp replica_opts do
|
|
||||||
replica_bucket = Application.get_env(:philomena, :s3_secondary_bucket)
|
|
||||||
|
|
||||||
if not is_nil(replica_bucket) do
|
|
||||||
[
|
|
||||||
%{
|
|
||||||
config_overrides: Application.fetch_env!(:philomena, :s3_secondary_options),
|
|
||||||
bucket: replica_bucket
|
|
||||||
}
|
|
||||||
]
|
|
||||||
else
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,78 +0,0 @@
|
||||||
defmodule Philomena.Processors do
|
|
||||||
@moduledoc """
|
|
||||||
Utilities for processing uploads.
|
|
||||||
|
|
||||||
Processors have 3 methods available:
|
|
||||||
|
|
||||||
- process/3:
|
|
||||||
Takes an analysis, file path, and version list and generates an
|
|
||||||
"edit script" that represents how to store this file according to the
|
|
||||||
given version list. See Philomena.Images.Thumbnailer for more
|
|
||||||
information on how this works.
|
|
||||||
|
|
||||||
- post_process/2:
|
|
||||||
Takes an analysis and file path and performs optimizations on the
|
|
||||||
upload. See Philomena.Images.Thumbnailer for more information on how this
|
|
||||||
works.
|
|
||||||
|
|
||||||
- intensities/2:
|
|
||||||
Takes an analysis and file path and generates an intensities map
|
|
||||||
appropriate for use by Philomena.DuplicateReports.
|
|
||||||
"""
|
|
||||||
|
|
||||||
alias Philomena.Processors.Gif
|
|
||||||
alias Philomena.Processors.Jpeg
|
|
||||||
alias Philomena.Processors.Png
|
|
||||||
alias Philomena.Processors.Svg
|
|
||||||
alias Philomena.Processors.Webm
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns a processor, with the processor being a module capable
|
|
||||||
of processing this content type, or nil.
|
|
||||||
"""
|
|
||||||
@spec processor(String.t()) :: module() | nil
|
|
||||||
def processor(content_type)
|
|
||||||
|
|
||||||
def processor("image/gif"), do: Gif
|
|
||||||
def processor("image/jpeg"), do: Jpeg
|
|
||||||
def processor("image/png"), do: Png
|
|
||||||
def processor("image/svg+xml"), do: Svg
|
|
||||||
def processor("video/webm"), do: Webm
|
|
||||||
def processor(_content_type), do: nil
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Takes a MIME type and version list and generates a list of versions to be
|
|
||||||
generated (e.g., ["thumb.png"]). List contents differ based on file type.
|
|
||||||
"""
|
|
||||||
@spec versions(String.t(), keyword) :: [String.t()]
|
|
||||||
def versions(mime_type, valid_sizes) do
|
|
||||||
processor(mime_type).versions(valid_sizes)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Takes an analyzer, file path, and version list and runs the appropriate
|
|
||||||
processor's process/3.
|
|
||||||
"""
|
|
||||||
@spec process(map(), String.t(), keyword) :: map()
|
|
||||||
def process(analysis, file, versions) do
|
|
||||||
processor(analysis.mime_type).process(analysis, file, versions)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Takes an analyzer and file path and runs the appropriate processor's
|
|
||||||
post_process/2.
|
|
||||||
"""
|
|
||||||
@spec post_process(map(), String.t()) :: map()
|
|
||||||
def post_process(analysis, file) do
|
|
||||||
processor(analysis.mime_type).post_process(analysis, file)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Takes an analyzer and file path and runs the appropriate processor's
|
|
||||||
intensities/2.
|
|
||||||
"""
|
|
||||||
@spec intensities(map(), String.t()) :: map()
|
|
||||||
def intensities(analysis, file) do
|
|
||||||
processor(analysis.mime_type).intensities(analysis, file)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -4,7 +4,7 @@ defmodule Philomena.Tags.Uploader do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Philomena.Tags.Tag
|
alias Philomena.Tags.Tag
|
||||||
alias Philomena.Uploader
|
alias PhilomenaMedia.Uploader
|
||||||
|
|
||||||
def analyze_upload(tag, params) do
|
def analyze_upload(tag, params) do
|
||||||
Uploader.analyze_upload(tag, "image", params["image"], &Tag.image_changeset/2)
|
Uploader.analyze_upload(tag, "image", params["image"], &Tag.image_changeset/2)
|
||||||
|
|
|
@ -1,125 +0,0 @@
|
||||||
defmodule Philomena.Uploader do
|
|
||||||
@moduledoc """
|
|
||||||
Upload and processing callback logic for image files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
alias Philomena.Filename
|
|
||||||
alias Philomena.Analyzers
|
|
||||||
alias Philomena.Objects
|
|
||||||
alias Philomena.Sha512
|
|
||||||
import Ecto.Changeset
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Performs analysis of the passed Plug.Upload, and invokes a changeset
|
|
||||||
callback on the model or changeset passed in with attributes set on
|
|
||||||
the field_name.
|
|
||||||
"""
|
|
||||||
@spec analyze_upload(any(), String.t(), Plug.Upload.t(), (any(), map() -> Ecto.Changeset.t())) ::
|
|
||||||
Ecto.Changeset.t()
|
|
||||||
def analyze_upload(model_or_changeset, field_name, upload_parameter, changeset_fn) do
|
|
||||||
with {:ok, analysis} <- Analyzers.analyze(upload_parameter),
|
|
||||||
analysis <- extra_attributes(analysis, upload_parameter) do
|
|
||||||
removed =
|
|
||||||
model_or_changeset
|
|
||||||
|> change()
|
|
||||||
|> get_field(field(field_name))
|
|
||||||
|
|
||||||
attributes =
|
|
||||||
%{
|
|
||||||
"name" => analysis.name,
|
|
||||||
"width" => analysis.width,
|
|
||||||
"height" => analysis.height,
|
|
||||||
"size" => analysis.size,
|
|
||||||
"format" => analysis.extension,
|
|
||||||
"mime_type" => analysis.mime_type,
|
|
||||||
"duration" => analysis.duration,
|
|
||||||
"aspect_ratio" => analysis.aspect_ratio,
|
|
||||||
"orig_sha512_hash" => analysis.sha512,
|
|
||||||
"sha512_hash" => analysis.sha512,
|
|
||||||
"is_animated" => analysis.animated?
|
|
||||||
}
|
|
||||||
|> prefix_attributes(field_name)
|
|
||||||
|> Map.put(field_name, analysis.new_name)
|
|
||||||
|> Map.put(upload_key(field_name), upload_parameter.path)
|
|
||||||
|> Map.put(remove_key(field_name), removed)
|
|
||||||
|
|
||||||
changeset_fn.(model_or_changeset, attributes)
|
|
||||||
else
|
|
||||||
{:unsupported_mime, mime} ->
|
|
||||||
attributes = prefix_attributes(%{"mime_type" => mime}, field_name)
|
|
||||||
changeset_fn.(model_or_changeset, attributes)
|
|
||||||
|
|
||||||
_error ->
|
|
||||||
changeset_fn.(model_or_changeset, %{})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Writes the file to permanent storage. This should be the second-to-last step
|
|
||||||
in the transaction.
|
|
||||||
"""
|
|
||||||
@spec persist_upload(any(), String.t(), String.t()) :: any()
|
|
||||||
def persist_upload(model, file_root, field_name) do
|
|
||||||
source = Map.get(model, field(upload_key(field_name)))
|
|
||||||
dest = Map.get(model, field(field_name))
|
|
||||||
target = Path.join(file_root, dest)
|
|
||||||
|
|
||||||
persist_file(target, source)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Persist an arbitrary file to storage at the given path with the correct
|
|
||||||
content type and permissions.
|
|
||||||
"""
|
|
||||||
def persist_file(path, file) do
|
|
||||||
Objects.upload(path, file)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Removes the old file from permanent storage. This should be the last step in
|
|
||||||
the transaction.
|
|
||||||
"""
|
|
||||||
@spec unpersist_old_upload(any(), String.t(), String.t()) :: any()
|
|
||||||
def unpersist_old_upload(model, file_root, field_name) do
|
|
||||||
model
|
|
||||||
|> Map.get(field(remove_key(field_name)))
|
|
||||||
|> try_remove(file_root)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extra_attributes(analysis, %Plug.Upload{path: path, filename: filename}) do
|
|
||||||
{width, height} = analysis.dimensions
|
|
||||||
aspect_ratio = aspect_ratio(width, height)
|
|
||||||
|
|
||||||
stat = File.stat!(path)
|
|
||||||
sha512 = Sha512.file(path)
|
|
||||||
new_name = Filename.build(analysis.extension)
|
|
||||||
|
|
||||||
analysis
|
|
||||||
|> Map.put(:size, stat.size)
|
|
||||||
|> Map.put(:name, filename)
|
|
||||||
|> Map.put(:width, width)
|
|
||||||
|> Map.put(:height, height)
|
|
||||||
|> Map.put(:sha512, sha512)
|
|
||||||
|> Map.put(:new_name, new_name)
|
|
||||||
|> Map.put(:aspect_ratio, aspect_ratio)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp aspect_ratio(_, 0), do: 0.0
|
|
||||||
defp aspect_ratio(w, h), do: w / h
|
|
||||||
|
|
||||||
defp try_remove("", _file_root), do: nil
|
|
||||||
defp try_remove(nil, _file_root), do: nil
|
|
||||||
|
|
||||||
defp try_remove(file, file_root) do
|
|
||||||
Objects.delete(Path.join(file_root, file))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp prefix_attributes(map, prefix),
|
|
||||||
do: Map.new(map, fn {key, value} -> {"#{prefix}_#{key}", value} end)
|
|
||||||
|
|
||||||
defp upload_key(field_name), do: "uploaded_#{field_name}"
|
|
||||||
|
|
||||||
defp remove_key(field_name), do: "removed_#{field_name}"
|
|
||||||
|
|
||||||
defp field(field_name), do: String.to_existing_atom(field_name)
|
|
||||||
end
|
|
|
@ -4,7 +4,7 @@ defmodule Philomena.Users.Uploader do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Philomena.Users.User
|
alias Philomena.Users.User
|
||||||
alias Philomena.Uploader
|
alias PhilomenaMedia.Uploader
|
||||||
|
|
||||||
def analyze_upload(user, params) do
|
def analyze_upload(user, params) do
|
||||||
Uploader.analyze_upload(user, "avatar", params["avatar"], &User.avatar_changeset/2)
|
Uploader.analyze_upload(user, "avatar", params["avatar"], &User.avatar_changeset/2)
|
||||||
|
|
71
lib/philomena_media/analyzers.ex
Normal file
71
lib/philomena_media/analyzers.ex
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule PhilomenaMedia.Analyzers do
|
||||||
|
@moduledoc """
|
||||||
|
Utilities for analyzing the format and various attributes of uploaded files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers.{Gif, Jpeg, Png, Svg, Webm}
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
alias PhilomenaMedia.Mime
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `{:ok, analyzer}` tuple, with the analyzer being a module capable
|
||||||
|
of analyzing this media type, or `:error`.
|
||||||
|
|
||||||
|
The allowed MIME types are:
|
||||||
|
- `image/gif`
|
||||||
|
- `image/jpeg`
|
||||||
|
- `image/png`
|
||||||
|
- `image/svg+xml`
|
||||||
|
- `video/webm`
|
||||||
|
|
||||||
|
> #### Info {: .info}
|
||||||
|
>
|
||||||
|
> This is an interface intended for use when the MIME type is already known.
|
||||||
|
> Using an analyzer not matched to the file may cause unexpected results.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
{:ok, analyzer} = PhilomenaMedia.Analyzers.analyzer("image/png")
|
||||||
|
:error = PhilomenaMedia.Analyzers.analyzer("application/octet-stream")
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec analyzer(Mime.t()) :: {:ok, module()} | :error
|
||||||
|
def analyzer(content_type)
|
||||||
|
|
||||||
|
def analyzer("image/gif"), do: {:ok, Gif}
|
||||||
|
def analyzer("image/jpeg"), do: {:ok, Jpeg}
|
||||||
|
def analyzer("image/png"), do: {:ok, Png}
|
||||||
|
def analyzer("image/svg+xml"), do: {:ok, Svg}
|
||||||
|
def analyzer("video/webm"), do: {:ok, Webm}
|
||||||
|
def analyzer(_content_type), do: :error
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Attempts a MIME type check and analysis on the given path or `m:Plug.Upload`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
file = "image_file.png"
|
||||||
|
{:ok, %Result{...}} = Analyzers.analyze(file)
|
||||||
|
|
||||||
|
file = %Plug.Upload{...}
|
||||||
|
{:ok, %Result{...}} = Analyzers.analyze(file)
|
||||||
|
|
||||||
|
file = "text_file.txt"
|
||||||
|
:error = Analyzers.analyze(file)
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec analyze(Plug.Upload.t() | Path.t()) :: {:ok, Result.t()} | :error
|
||||||
|
def analyze(%Plug.Upload{path: path}), do: analyze(path)
|
||||||
|
|
||||||
|
def analyze(path) when is_binary(path) do
|
||||||
|
with {:ok, mime} <- Mime.file(path),
|
||||||
|
{:ok, analyzer} <- analyzer(mime) do
|
||||||
|
{:ok, analyzer.analyze(path)}
|
||||||
|
else
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def analyze(_path), do: :error
|
||||||
|
end
|
5
lib/philomena_media/analyzers/analyzer.ex
Normal file
5
lib/philomena_media/analyzers/analyzer.ex
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
defmodule PhilomenaMedia.Analyzers.Analyzer do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@callback analyze(Path.t()) :: PhilomenaMedia.Analyzers.Result.t()
|
||||||
|
end
|
|
@ -1,8 +1,16 @@
|
||||||
defmodule Philomena.Analyzers.Gif do
|
defmodule PhilomenaMedia.Analyzers.Gif do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers.Analyzer
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
|
||||||
|
@behaviour Analyzer
|
||||||
|
|
||||||
|
@spec analyze(Path.t()) :: Result.t()
|
||||||
def analyze(file) do
|
def analyze(file) do
|
||||||
stats = stats(file)
|
stats = stats(file)
|
||||||
|
|
||||||
%{
|
%Result{
|
||||||
extension: "gif",
|
extension: "gif",
|
||||||
mime_type: "image/gif",
|
mime_type: "image/gif",
|
||||||
animated?: stats.animated?,
|
animated?: stats.animated?,
|
|
@ -1,8 +1,16 @@
|
||||||
defmodule Philomena.Analyzers.Jpeg do
|
defmodule PhilomenaMedia.Analyzers.Jpeg do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers.Analyzer
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
|
||||||
|
@behaviour Analyzer
|
||||||
|
|
||||||
|
@spec analyze(Path.t()) :: Result.t()
|
||||||
def analyze(file) do
|
def analyze(file) do
|
||||||
stats = stats(file)
|
stats = stats(file)
|
||||||
|
|
||||||
%{
|
%Result{
|
||||||
extension: "jpg",
|
extension: "jpg",
|
||||||
mime_type: "image/jpeg",
|
mime_type: "image/jpeg",
|
||||||
animated?: false,
|
animated?: false,
|
|
@ -1,8 +1,16 @@
|
||||||
defmodule Philomena.Analyzers.Png do
|
defmodule PhilomenaMedia.Analyzers.Png do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers.Analyzer
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
|
||||||
|
@behaviour Analyzer
|
||||||
|
|
||||||
|
@spec analyze(Path.t()) :: Result.t()
|
||||||
def analyze(file) do
|
def analyze(file) do
|
||||||
stats = stats(file)
|
stats = stats(file)
|
||||||
|
|
||||||
%{
|
%Result{
|
||||||
extension: "png",
|
extension: "png",
|
||||||
mime_type: "image/png",
|
mime_type: "image/png",
|
||||||
animated?: stats.animated?,
|
animated?: stats.animated?,
|
36
lib/philomena_media/analyzers/result.ex
Normal file
36
lib/philomena_media/analyzers/result.ex
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
defmodule PhilomenaMedia.Analyzers.Result do
|
||||||
|
@moduledoc """
|
||||||
|
The analysis result.
|
||||||
|
|
||||||
|
- `:animated?` - whether the media file is animated
|
||||||
|
- `:dimensions` - the maximum dimensions of the media file, as `{width, height}`
|
||||||
|
- `:duration` - the maximum duration of the media file, or 0 if not applicable
|
||||||
|
- `:extension` - the file extension the media file should take, based on its contents
|
||||||
|
- `:mime_type` - the MIME type the media file should take, based on its contents
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
%Result{
|
||||||
|
animated?: false,
|
||||||
|
dimensions: {800, 600},
|
||||||
|
duration: 0.0,
|
||||||
|
extension: "png",
|
||||||
|
mime_type: "image/png"
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
animated?: boolean(),
|
||||||
|
dimensions: {integer(), integer()},
|
||||||
|
duration: float(),
|
||||||
|
extension: String.t(),
|
||||||
|
mime_type: String.t()
|
||||||
|
}
|
||||||
|
|
||||||
|
defstruct animated?: false,
|
||||||
|
dimensions: {0, 0},
|
||||||
|
duration: 0.0,
|
||||||
|
extension: "",
|
||||||
|
mime_type: "application/octet-stream"
|
||||||
|
end
|
|
@ -1,8 +1,16 @@
|
||||||
defmodule Philomena.Analyzers.Svg do
|
defmodule PhilomenaMedia.Analyzers.Svg do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers.Analyzer
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
|
||||||
|
@behaviour Analyzer
|
||||||
|
|
||||||
|
@spec analyze(Path.t()) :: Result.t()
|
||||||
def analyze(file) do
|
def analyze(file) do
|
||||||
stats = stats(file)
|
stats = stats(file)
|
||||||
|
|
||||||
%{
|
%Result{
|
||||||
extension: "svg",
|
extension: "svg",
|
||||||
mime_type: "image/svg+xml",
|
mime_type: "image/svg+xml",
|
||||||
animated?: false,
|
animated?: false,
|
|
@ -1,8 +1,16 @@
|
||||||
defmodule Philomena.Analyzers.Webm do
|
defmodule PhilomenaMedia.Analyzers.Webm do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers.Analyzer
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
|
||||||
|
@behaviour Analyzer
|
||||||
|
|
||||||
|
@spec analyze(Path.t()) :: Result.t()
|
||||||
def analyze(file) do
|
def analyze(file) do
|
||||||
stats = stats(file)
|
stats = stats(file)
|
||||||
|
|
||||||
%{
|
%Result{
|
||||||
extension: "webm",
|
extension: "webm",
|
||||||
mime_type: "video/webm",
|
mime_type: "video/webm",
|
||||||
animated?: stats.animated?,
|
animated?: stats.animated?,
|
36
lib/philomena_media/filename.ex
Normal file
36
lib/philomena_media/filename.ex
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
defmodule PhilomenaMedia.Filename do
|
||||||
|
@moduledoc """
|
||||||
|
Utilities for building arbitrary filenames for uploaded files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type extension :: String.t()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
This function builds a replacement "filename key" based on the supplied file extension.
|
||||||
|
|
||||||
|
Names are generated in the form `year/month/day/uuid.ext`. It is recommended to avoid
|
||||||
|
providing user-controlled file-extensions to this function; select them from a list of
|
||||||
|
known extensions instead.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Filename.build("png")
|
||||||
|
"2024/1/1/0bce8eea-17e0-11ef-b7d4-0242ac120006.png"
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec build(extension()) :: String.t()
|
||||||
|
def build(extension) do
|
||||||
|
[
|
||||||
|
time_identifier(DateTime.utc_now()),
|
||||||
|
"/",
|
||||||
|
UUID.uuid1(),
|
||||||
|
".",
|
||||||
|
extension
|
||||||
|
]
|
||||||
|
|> Enum.join()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp time_identifier(time) do
|
||||||
|
Enum.join([time.year, time.month, time.day], "/")
|
||||||
|
end
|
||||||
|
end
|
68
lib/philomena_media/intensities.ex
Normal file
68
lib/philomena_media/intensities.ex
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
defmodule PhilomenaMedia.Intensities do
|
||||||
|
@moduledoc """
|
||||||
|
Corner intensities are a simple mechanism for automatic image deduplication,
|
||||||
|
designed for a time when computer vision was an expensive technology and
|
||||||
|
resources were scarce.
|
||||||
|
|
||||||
|
Each image is divided into quadrants; image with odd numbers of pixels
|
||||||
|
on either dimension overlap quadrants by one pixel. The luma (brightness)
|
||||||
|
value corresponding each the pixel is computed according to BTU.709 primaries,
|
||||||
|
and its value is added to a sum for each quadrant. Finally, the value is divided
|
||||||
|
by the number of pixels in the quadrant to produce an average. The minimum luma
|
||||||
|
value of any pixel is 0, and the maximum is 255, so an average will be between
|
||||||
|
these values. Transparent pixels are composited on black before processing.
|
||||||
|
|
||||||
|
By using a range search in the database, this produces a reverse image search which
|
||||||
|
suffers no dimensionality issues, is exceptionally fast to evaluate, and is independent
|
||||||
|
of image dimensions, with poor precision and a poor-to-fair accuracy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
nw: float(),
|
||||||
|
ne: float(),
|
||||||
|
sw: float(),
|
||||||
|
se: float()
|
||||||
|
}
|
||||||
|
|
||||||
|
defstruct nw: 0.0,
|
||||||
|
ne: 0.0,
|
||||||
|
sw: 0.0,
|
||||||
|
se: 0.0
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the corner intensities of the given image file.
|
||||||
|
|
||||||
|
The image file must be in the PNG or JPEG format.
|
||||||
|
|
||||||
|
> #### Info {: .info}
|
||||||
|
>
|
||||||
|
> Clients should prefer to use `m:PhilomenaMedia.Processors.intensities/2`, as it handles
|
||||||
|
> media files of any type supported by this library, not just PNG or JPEG.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Intensities.file("image.png")
|
||||||
|
{:ok, %Intensities{nw: 111.689148, ne: 116.228048, sw: 93.268433, se: 104.630064}}
|
||||||
|
|
||||||
|
iex> Intensities.file("nonexistent.jpg")
|
||||||
|
:error
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec file(Path.t()) :: {:ok, t()} | :error
|
||||||
|
def file(input) do
|
||||||
|
System.cmd("image-intensities", [input])
|
||||||
|
|> case do
|
||||||
|
{output, 0} ->
|
||||||
|
[nw, ne, sw, se] =
|
||||||
|
output
|
||||||
|
|> String.trim()
|
||||||
|
|> String.split("\t")
|
||||||
|
|> Enum.map(&String.to_float/1)
|
||||||
|
|
||||||
|
{:ok, %__MODULE__{nw: nw, ne: ne, sw: sw, se: se}}
|
||||||
|
|
||||||
|
_error ->
|
||||||
|
:error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
67
lib/philomena_media/mime.ex
Normal file
67
lib/philomena_media/mime.ex
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
defmodule PhilomenaMedia.Mime do
|
||||||
|
@moduledoc """
|
||||||
|
Utilities for determining the MIME type of a file via parsing.
|
||||||
|
|
||||||
|
Many MIME type libraries assume the MIME type of the file by reading file extensions.
|
||||||
|
This is inherently unreliable, as many websites disguise the content types of files with
|
||||||
|
specific names for cost or bandwidth saving reasons. As processing depends on correctly
|
||||||
|
identifying the type of a file, parsing the file contents is necessary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type t :: String.t()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the MIME type of the given pathname.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Mime.file("image.png")
|
||||||
|
{:ok, "image/png"}
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Mime.file("file.txt")
|
||||||
|
{:unsupported_mime, "text/plain"}
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Mime.file("nonexistent.file")
|
||||||
|
:error
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec file(Path.t()) :: {:ok, t()} | {:unsupported_mime, t()} | :error
|
||||||
|
def file(path) do
|
||||||
|
System.cmd("file", ["-b", "--mime-type", path])
|
||||||
|
|> case do
|
||||||
|
{output, 0} ->
|
||||||
|
true_mime(String.trim(output))
|
||||||
|
|
||||||
|
_error ->
|
||||||
|
:error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Provides the "true" MIME type of this file.
|
||||||
|
|
||||||
|
Some files are identified as a type they should not be based on how they are used by
|
||||||
|
this library. These MIME types (and their "corrected") versions are:
|
||||||
|
|
||||||
|
- `image/svg` -> `image/svg+xml`
|
||||||
|
- `audio/webm` -> `video/webm`
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Mime.file("image.svg")
|
||||||
|
"image/svg+xml"
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Mime.file("audio.webm")
|
||||||
|
"video/webm"
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec true_mime(String.t()) :: {:ok, t()} | {:unsupported_mime, t()}
|
||||||
|
def true_mime("image/svg"), do: {:ok, "image/svg+xml"}
|
||||||
|
def true_mime("audio/webm"), do: {:ok, "video/webm"}
|
||||||
|
|
||||||
|
def true_mime(mime)
|
||||||
|
when mime in ~W(image/gif image/jpeg image/png image/svg+xml video/webm),
|
||||||
|
do: {:ok, mime}
|
||||||
|
|
||||||
|
def true_mime(mime), do: {:unsupported_mime, mime}
|
||||||
|
end
|
236
lib/philomena_media/objects.ex
Normal file
236
lib/philomena_media/objects.ex
Normal file
|
@ -0,0 +1,236 @@
|
||||||
|
defmodule PhilomenaMedia.Objects do
|
||||||
|
@moduledoc """
|
||||||
|
Replication wrapper for object storage backends.
|
||||||
|
|
||||||
|
While cloud services can be an inexpensive way to access large amounts of storage, they
|
||||||
|
are inherently less available than local file-based storage. For this reason, it is generally
|
||||||
|
recommended to maintain a secondary storage provider, such as in the
|
||||||
|
[3-2-1 backup strategy](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/).
|
||||||
|
|
||||||
|
Functions in this module replicate operations on both the primary and secondary storage
|
||||||
|
providers. Alternatively, a mode with only a primary storage provider is supported.
|
||||||
|
|
||||||
|
This module assumes storage endpoints are S3-compatible and can be communicated with via the
|
||||||
|
`m:ExAws` module. This does not preclude the usage of local file-based storage, which can be
|
||||||
|
accomplished with the [`s3proxy` project](https://github.com/gaul/s3proxy). The development
|
||||||
|
repository provides an example of `s3proxy` in use.
|
||||||
|
|
||||||
|
Bucket names should be set with configuration on `s3_primary_bucket` and `s3_secondary_bucket`.
|
||||||
|
If `s3_secondary_bucket` is not set, then only the primary will be used. However, the primary
|
||||||
|
bucket name must always be set.
|
||||||
|
|
||||||
|
These are read from environment variables at runtime by Philomena.
|
||||||
|
|
||||||
|
# S3/Object store config
|
||||||
|
config :philomena, :s3_primary_bucket, System.fetch_env!("S3_BUCKET")
|
||||||
|
config :philomena, :s3_secondary_bucket, System.get_env("ALT_S3_BUCKET")
|
||||||
|
|
||||||
|
Additional options (e.g. controlling the remote endpoint used) may be set with
|
||||||
|
`s3_primary_options` and `s3_secondary_options` keys. This allows you to use a provider other
|
||||||
|
than AWS, like [Cloudflare R2](https://developers.cloudflare.com/r2/).
|
||||||
|
|
||||||
|
These are read from environment variables at runtime by Philomena.
|
||||||
|
|
||||||
|
config :philomena, :s3_primary_options,
|
||||||
|
region: System.get_env("S3_REGION", "us-east-1"),
|
||||||
|
scheme: System.fetch_env!("S3_SCHEME"),
|
||||||
|
host: System.fetch_env!("S3_HOST"),
|
||||||
|
port: System.fetch_env!("S3_PORT"),
|
||||||
|
access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
|
||||||
|
secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY"),
|
||||||
|
http_opts: [timeout: 180_000, recv_timeout: 180_000]
|
||||||
|
|
||||||
|
"""
|
||||||
|
alias PhilomenaMedia.Mime
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@type key :: String.t()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetch a key from the storage backend and write it into the destination path.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
key = "2024/1/1/5/full.png"
|
||||||
|
Objects.download_file(key, file_path)
|
||||||
|
|
||||||
|
"""
|
||||||
|
# sobelow_skip ["Traversal.FileModule"]
|
||||||
|
@spec download_file(key(), Path.t()) :: :ok
|
||||||
|
def download_file(key, file_path) do
|
||||||
|
contents =
|
||||||
|
backends()
|
||||||
|
|> Enum.find_value(fn opts ->
|
||||||
|
ExAws.S3.get_object(opts[:bucket], key)
|
||||||
|
|> ExAws.request(opts[:config_overrides])
|
||||||
|
|> case do
|
||||||
|
{:ok, result} -> result
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
File.write!(file_path, contents.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Upload a file using a single API call, writing the contents from the given path to storage.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
key = "2024/1/1/5/full.png"
|
||||||
|
Objects.put(key, file_path)
|
||||||
|
|
||||||
|
"""
|
||||||
|
# sobelow_skip ["Traversal.FileModule"]
|
||||||
|
@spec put(key(), Path.t()) :: :ok
|
||||||
|
def put(key, file_path) do
|
||||||
|
{_, mime} = Mime.file(file_path)
|
||||||
|
contents = File.read!(file_path)
|
||||||
|
|
||||||
|
run_all(fn opts ->
|
||||||
|
ExAws.S3.put_object(opts[:bucket], key, contents, content_type: mime)
|
||||||
|
|> ExAws.request!(opts[:config_overrides])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Upload a file using multiple API calls, writing the contents from the given path to storage.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
key = "2024/1/1/5/full.png"
|
||||||
|
Objects.upload(key, file_path)
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec upload(key(), Path.t()) :: :ok
|
||||||
|
def upload(key, file_path) do
|
||||||
|
# Workaround for API rate limit issues on R2
|
||||||
|
put(key, file_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Copies a key from the source to the destination, overwriting the destination object if its exists.
|
||||||
|
|
||||||
|
> #### Warning {: .warning}
|
||||||
|
>
|
||||||
|
> `copy/2` does not use the `PutObjectCopy` S3 request. It downloads the file and uploads it again.
|
||||||
|
> This may use more disk space than expected if the file is large.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
source_key = "2024/1/1/5/full.png"
|
||||||
|
dest_key = "2024/1/1/5-a5323e542e0f/full.png"
|
||||||
|
Objects.copy(source_key, dest_key)
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec copy(key(), key()) :: :ok
|
||||||
|
def copy(source_key, dest_key) do
|
||||||
|
# Potential workaround for inconsistent PutObjectCopy on R2
|
||||||
|
#
|
||||||
|
# run_all(fn opts->
|
||||||
|
# ExAws.S3.put_object_copy(opts[:bucket], dest_key, opts[:bucket], source_key)
|
||||||
|
# |> ExAws.request!(opts[:config_overrides])
|
||||||
|
# end)
|
||||||
|
|
||||||
|
try do
|
||||||
|
file_path = Briefly.create!()
|
||||||
|
download_file(source_key, file_path)
|
||||||
|
upload(dest_key, file_path)
|
||||||
|
catch
|
||||||
|
_kind, _value -> Logger.warning("Failed to copy #{source_key} -> #{dest_key}")
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Removes the key from storage.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
key = "2024/1/1/5/full.png"
|
||||||
|
Objects.delete(key)
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec delete(key()) :: :ok
|
||||||
|
def delete(key) do
|
||||||
|
run_all(fn opts ->
|
||||||
|
ExAws.S3.delete_object(opts[:bucket], key)
|
||||||
|
|> ExAws.request!(opts[:config_overrides])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Removes all given keys from storage.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
keys = [
|
||||||
|
"2024/1/1/5/full.png",
|
||||||
|
"2024/1/1/5/small.png",
|
||||||
|
"2024/1/1/5/thumb.png",
|
||||||
|
"2024/1/1/5/thumb_tiny.png"
|
||||||
|
]
|
||||||
|
Objects.delete_multiple(keys)
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec delete_multiple([key()]) :: :ok
|
||||||
|
def delete_multiple(keys) do
|
||||||
|
run_all(fn opts ->
|
||||||
|
ExAws.S3.delete_multiple_objects(opts[:bucket], keys)
|
||||||
|
|> ExAws.request!(opts[:config_overrides])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_all(wrapped) do
|
||||||
|
fun = fn opts ->
|
||||||
|
try do
|
||||||
|
wrapped.(opts)
|
||||||
|
:ok
|
||||||
|
catch
|
||||||
|
_kind, _value -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
backends()
|
||||||
|
|> Task.async_stream(fun, timeout: :infinity)
|
||||||
|
|> Enum.any?(fn {_, v} -> v == :error end)
|
||||||
|
|> case do
|
||||||
|
true ->
|
||||||
|
Logger.warning("Failed to operate on all backends")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp backends do
|
||||||
|
primary_opts() ++ replica_opts()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp primary_opts do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
config_overrides: Application.fetch_env!(:philomena, :s3_primary_options),
|
||||||
|
bucket: Application.fetch_env!(:philomena, :s3_primary_bucket)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp replica_opts do
|
||||||
|
replica_bucket = Application.get_env(:philomena, :s3_secondary_bucket)
|
||||||
|
|
||||||
|
if not is_nil(replica_bucket) do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
config_overrides: Application.fetch_env!(:philomena, :s3_secondary_options),
|
||||||
|
bucket: replica_bucket
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
202
lib/philomena_media/processors.ex
Normal file
202
lib/philomena_media/processors.ex
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
defmodule PhilomenaMedia.Processors do
|
||||||
|
@moduledoc """
|
||||||
|
Utilities for processing uploads.
|
||||||
|
|
||||||
|
Processors have 4 functions available:
|
||||||
|
|
||||||
|
- `versions/1`:
|
||||||
|
Takes a version list and generates a list of files which the processor will generate
|
||||||
|
during the scope of `process/3`.
|
||||||
|
|
||||||
|
- `process/3`:
|
||||||
|
Takes an analysis result, file path, and version list and generates an "edit script" that
|
||||||
|
represents how to store this file according to the given version list. See
|
||||||
|
`m:Philomena.Images.Thumbnailer` for a usage example.
|
||||||
|
|
||||||
|
- `post_process/2`:
|
||||||
|
Takes an analysis result and file path and performs optimizations on the upload. See
|
||||||
|
`m:Philomena.Images.Thumbnailer` for a usage example.
|
||||||
|
|
||||||
|
- `intensities/2`:
|
||||||
|
Takes an analysis result and file path and generates corner intensities, performing.
|
||||||
|
any conversion necessary before processing. See `m:PhilomenaMedia.Intensities`
|
||||||
|
for more information.
|
||||||
|
|
||||||
|
## Version lists
|
||||||
|
|
||||||
|
`process/3` and `post_process/2` take _version lists_ as input. A version list is a structure
|
||||||
|
like the following, which contains pairs of _version names_ and _dimensions_:
|
||||||
|
|
||||||
|
[
|
||||||
|
thumb_tiny: {50, 50},
|
||||||
|
thumb_small: {150, 150},
|
||||||
|
thumb: {250, 250},
|
||||||
|
small: {320, 240},
|
||||||
|
medium: {800, 600},
|
||||||
|
large: {1280, 1024},
|
||||||
|
tall: {1024, 4096}
|
||||||
|
]
|
||||||
|
|
||||||
|
When calling these functions, it is recommended prefilter the version list based on the media
|
||||||
|
dimensions to avoid generating unnecessary versions which are larger than the original file.
|
||||||
|
See `m:Philomena.Images.Thumbnailer` for an example.
|
||||||
|
|
||||||
|
## Edit scripts
|
||||||
|
|
||||||
|
`process/3` and `post_process/2` return _edit scripts_. An edit script is a list where each
|
||||||
|
entry may be one of the following:
|
||||||
|
|
||||||
|
{:thumbnails, [copy_requests]}
|
||||||
|
{:replace_original, path}
|
||||||
|
{:intensities, intensities}
|
||||||
|
|
||||||
|
Within the thumbnail request, a copy request is defined with the following structure:
|
||||||
|
|
||||||
|
{:copy, path, version_filename}
|
||||||
|
|
||||||
|
See the respective functions for more information about their return values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
alias PhilomenaMedia.Intensities
|
||||||
|
alias PhilomenaMedia.Processors.{Gif, Jpeg, Png, Svg, Webm}
|
||||||
|
alias PhilomenaMedia.Mime
|
||||||
|
|
||||||
|
# The name of a version, like :large
|
||||||
|
@type version_name :: atom()
|
||||||
|
|
||||||
|
@type dimensions :: {integer(), integer()}
|
||||||
|
@type version_list :: [{version_name(), dimensions()}]
|
||||||
|
|
||||||
|
# The file name of a processed version, like "large.png"
|
||||||
|
@type version_filename :: String.t()
|
||||||
|
|
||||||
|
# A single file to be copied to satisfy a request for a version name
|
||||||
|
@type copy_request :: {:copy, Path.t(), version_filename()}
|
||||||
|
|
||||||
|
# A list of thumbnail versions to copy into place
|
||||||
|
@type thumbnails :: {:thumbnails, [copy_request()]}
|
||||||
|
|
||||||
|
# Replace the original file to strip metadata or losslessly optimize
|
||||||
|
@type replace_original :: {:replace_original, Path.t()}
|
||||||
|
|
||||||
|
# Apply the computed corner intensities
|
||||||
|
@type intensities :: {:intensities, Intensities.t()}
|
||||||
|
|
||||||
|
# An edit script, representing the changes to apply to the storage backend
|
||||||
|
# after successful processing
|
||||||
|
@type edit_script :: [thumbnails() | replace_original() | intensities()]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a processor, with the processor being a module capable
|
||||||
|
of processing this content type, or nil.
|
||||||
|
|
||||||
|
The allowed MIME types are:
|
||||||
|
- `image/gif`
|
||||||
|
- `image/jpeg`
|
||||||
|
- `image/png`
|
||||||
|
- `image/svg+xml`
|
||||||
|
- `video/webm`
|
||||||
|
|
||||||
|
> #### Info {: .info}
|
||||||
|
>
|
||||||
|
> This is an interface intended for use when the MIME type is already known.
|
||||||
|
> Using a processor not matched to the file may cause unexpected results.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Processors.processor("image/png")
|
||||||
|
PhilomenaMedia.Processors.Png
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Processors.processor("application/octet-stream")
|
||||||
|
nil
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec processor(Mime.t()) :: module() | nil
|
||||||
|
def processor(content_type)
|
||||||
|
|
||||||
|
def processor("image/gif"), do: Gif
|
||||||
|
def processor("image/jpeg"), do: Jpeg
|
||||||
|
def processor("image/png"), do: Png
|
||||||
|
def processor("image/svg+xml"), do: Svg
|
||||||
|
def processor("video/webm"), do: Webm
|
||||||
|
def processor(_content_type), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Takes a MIME type and filtered version list and generates a list of version files to be
|
||||||
|
generated by `process/2`. List contents may differ based on file type.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Processors.versions("image/png", [thumb_tiny: {50, 50}])
|
||||||
|
["thumb_tiny.png"]
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Processors.versions("video/webm", [thumb_tiny: {50, 50}])
|
||||||
|
["full.mp4", "rendered.png", "thumb_tiny.webm", "thumb_tiny.mp4", "thumb_tiny.gif"]
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec versions(Mime.t(), version_list()) :: [version_name()]
|
||||||
|
def versions(mime_type, valid_sizes) do
|
||||||
|
processor(mime_type).versions(valid_sizes)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Takes an analyzer result, file path, and version list and runs the appropriate processor's
|
||||||
|
`process/3`, processing the media.
|
||||||
|
|
||||||
|
Returns an edit script to apply changes. Depending on the media type, this make take a long
|
||||||
|
time to execute.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Processors.process(%Result{...}, "image.png", [thumb_tiny: {50, 50}])
|
||||||
|
[
|
||||||
|
intensities: %Intensities{...},
|
||||||
|
thumbnails: [
|
||||||
|
{:copy, "/tmp/briefly-5764/vSHsM3kn7k4yvrvZH.png", "thumb_tiny.png"}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec process(Result.t(), Path.t(), version_list()) :: edit_script()
|
||||||
|
def process(analysis, file, versions) do
|
||||||
|
processor(analysis.mime_type).process(analysis, file, versions)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Takes an analyzer result and file path and runs the appropriate processor's `post_process/2`,
|
||||||
|
performing long-running optimizations on the media source file.
|
||||||
|
|
||||||
|
Returns an edit script to apply changes. Depending on the media type, this make take a long
|
||||||
|
time to execute. This may also be an empty list, if there are no changes to perform.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Processors.post_process(%Result{...}, "image.gif", [thumb_tiny: {50, 50}])
|
||||||
|
[replace_original: "/tmp/briefly-5764/cyZSQnmL59XDRoPoaDxr.gif"]
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec post_process(Result.t(), Path.t()) :: edit_script()
|
||||||
|
def post_process(analysis, file) do
|
||||||
|
processor(analysis.mime_type).post_process(analysis, file)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Takes an analyzer result and file path and runs the appropriate processor's `intensities/2`,
|
||||||
|
returning the corner intensities.
|
||||||
|
|
||||||
|
This allows for generating intensities for file types that are not directly supported by
|
||||||
|
`m:PhilomenaMedia.Intensities`, and should be the preferred function to call when intensities
|
||||||
|
are needed.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> PhilomenaMedia.Processors.intensities(%Result{...}, "video.webm")
|
||||||
|
%Intensities{nw: 111.689148, ne: 116.228048, sw: 93.268433, se: 104.630064}
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
|
||||||
|
def intensities(analysis, file) do
|
||||||
|
processor(analysis.mime_type).intensities(analysis, file)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,12 +1,21 @@
|
||||||
defmodule Philomena.Processors.Gif do
|
defmodule PhilomenaMedia.Processors.Gif do
|
||||||
alias Philomena.Intensities
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Intensities
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
alias PhilomenaMedia.Processors.Processor
|
||||||
|
alias PhilomenaMedia.Processors
|
||||||
|
|
||||||
|
@behaviour Processor
|
||||||
|
|
||||||
|
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
|
||||||
def versions(sizes) do
|
def versions(sizes) do
|
||||||
sizes
|
sizes
|
||||||
|> Enum.map(fn {name, _} -> "#{name}.gif" end)
|
|> Enum.map(fn {name, _} -> "#{name}.gif" end)
|
||||||
|> Kernel.++(["full.webm", "full.mp4", "rendered.png"])
|
|> Kernel.++(["full.webm", "full.mp4", "rendered.png"])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
|
||||||
def process(analysis, file, versions) do
|
def process(analysis, file, versions) do
|
||||||
duration = analysis.duration
|
duration = analysis.duration
|
||||||
preview = preview(duration, file)
|
preview = preview(duration, file)
|
||||||
|
@ -17,16 +26,18 @@ defmodule Philomena.Processors.Gif do
|
||||||
scaled = Enum.flat_map(versions, &scale(palette, file, &1))
|
scaled = Enum.flat_map(versions, &scale(palette, file, &1))
|
||||||
videos = generate_videos(file)
|
videos = generate_videos(file)
|
||||||
|
|
||||||
%{
|
[
|
||||||
intensities: intensities,
|
intensities: intensities,
|
||||||
thumbnails: scaled ++ videos ++ [{:copy, preview, "rendered.png"}]
|
thumbnails: scaled ++ videos ++ [{:copy, preview, "rendered.png"}]
|
||||||
}
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
|
||||||
def post_process(_analysis, file) do
|
def post_process(_analysis, file) do
|
||||||
%{replace_original: optimize(file)}
|
[replace_original: optimize(file)]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
|
||||||
def intensities(analysis, file) do
|
def intensities(analysis, file) do
|
||||||
{:ok, intensities} = Intensities.file(preview(analysis.duration, file))
|
{:ok, intensities} = Intensities.file(preview(analysis.duration, file))
|
||||||
intensities
|
intensities
|
|
@ -1,10 +1,19 @@
|
||||||
defmodule Philomena.Processors.Jpeg do
|
defmodule PhilomenaMedia.Processors.Jpeg do
|
||||||
alias Philomena.Intensities
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Intensities
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
alias PhilomenaMedia.Processors.Processor
|
||||||
|
alias PhilomenaMedia.Processors
|
||||||
|
|
||||||
|
@behaviour Processor
|
||||||
|
|
||||||
|
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
|
||||||
def versions(sizes) do
|
def versions(sizes) do
|
||||||
Enum.map(sizes, fn {name, _} -> "#{name}.jpg" end)
|
Enum.map(sizes, fn {name, _} -> "#{name}.jpg" end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
|
||||||
def process(_analysis, file, versions) do
|
def process(_analysis, file, versions) do
|
||||||
stripped = optimize(strip(file))
|
stripped = optimize(strip(file))
|
||||||
|
|
||||||
|
@ -12,15 +21,17 @@ defmodule Philomena.Processors.Jpeg do
|
||||||
|
|
||||||
scaled = Enum.flat_map(versions, &scale(stripped, &1))
|
scaled = Enum.flat_map(versions, &scale(stripped, &1))
|
||||||
|
|
||||||
%{
|
[
|
||||||
replace_original: stripped,
|
replace_original: stripped,
|
||||||
intensities: intensities,
|
intensities: intensities,
|
||||||
thumbnails: scaled
|
thumbnails: scaled
|
||||||
}
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_process(_analysis, _file), do: %{}
|
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
|
||||||
|
def post_process(_analysis, _file), do: []
|
||||||
|
|
||||||
|
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
|
||||||
def intensities(_analysis, file) do
|
def intensities(_analysis, file) do
|
||||||
{:ok, intensities} = Intensities.file(file)
|
{:ok, intensities} = Intensities.file(file)
|
||||||
intensities
|
intensities
|
|
@ -1,10 +1,19 @@
|
||||||
defmodule Philomena.Processors.Png do
|
defmodule PhilomenaMedia.Processors.Png do
|
||||||
alias Philomena.Intensities
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Intensities
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
alias PhilomenaMedia.Processors.Processor
|
||||||
|
alias PhilomenaMedia.Processors
|
||||||
|
|
||||||
|
@behaviour Processor
|
||||||
|
|
||||||
|
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
|
||||||
def versions(sizes) do
|
def versions(sizes) do
|
||||||
Enum.map(sizes, fn {name, _} -> "#{name}.png" end)
|
Enum.map(sizes, fn {name, _} -> "#{name}.png" end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
|
||||||
def process(analysis, file, versions) do
|
def process(analysis, file, versions) do
|
||||||
animated? = analysis.animated?
|
animated? = analysis.animated?
|
||||||
|
|
||||||
|
@ -12,21 +21,23 @@ defmodule Philomena.Processors.Png do
|
||||||
|
|
||||||
scaled = Enum.flat_map(versions, &scale(file, animated?, &1))
|
scaled = Enum.flat_map(versions, &scale(file, animated?, &1))
|
||||||
|
|
||||||
%{
|
[
|
||||||
intensities: intensities,
|
intensities: intensities,
|
||||||
thumbnails: scaled
|
thumbnails: scaled
|
||||||
}
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
|
||||||
def post_process(analysis, file) do
|
def post_process(analysis, file) do
|
||||||
if analysis.animated? do
|
if analysis.animated? do
|
||||||
# libpng has trouble with animations, so skip optimization
|
# libpng has trouble with animations, so skip optimization
|
||||||
%{}
|
[]
|
||||||
else
|
else
|
||||||
%{replace_original: optimize(file)}
|
[replace_original: optimize(file)]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
|
||||||
def intensities(_analysis, file) do
|
def intensities(_analysis, file) do
|
||||||
{:ok, intensities} = Intensities.file(file)
|
{:ok, intensities} = Intensities.file(file)
|
||||||
intensities
|
intensities
|
21
lib/philomena_media/processors/processor.ex
Normal file
21
lib/philomena_media/processors/processor.ex
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule PhilomenaMedia.Processors.Processor do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
alias PhilomenaMedia.Processors
|
||||||
|
alias PhilomenaMedia.Intensities
|
||||||
|
|
||||||
|
# Generate a list of version filenames for the given version list.
|
||||||
|
@callback versions(Processors.version_list()) :: [Processors.version_filename()]
|
||||||
|
|
||||||
|
# Process the media at the given path against the given version list, and return an
|
||||||
|
# edit script with the resulting files
|
||||||
|
@callback process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
|
||||||
|
|
||||||
|
# Perform post-processing optimization tasks on the file, to reduce its size
|
||||||
|
# and strip non-essential metadata
|
||||||
|
@callback post_process(Result.t(), Path.t()) :: Processors.edit_script()
|
||||||
|
|
||||||
|
# Generate corner intensities for the given path
|
||||||
|
@callback intensities(Result.t(), Path.t()) :: Intensities.t()
|
||||||
|
end
|
|
@ -1,12 +1,21 @@
|
||||||
defmodule Philomena.Processors.Svg do
|
defmodule PhilomenaMedia.Processors.Svg do
|
||||||
alias Philomena.Intensities
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Intensities
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
alias PhilomenaMedia.Processors.Processor
|
||||||
|
alias PhilomenaMedia.Processors
|
||||||
|
|
||||||
|
@behaviour Processor
|
||||||
|
|
||||||
|
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
|
||||||
def versions(sizes) do
|
def versions(sizes) do
|
||||||
sizes
|
sizes
|
||||||
|> Enum.map(fn {name, _} -> "#{name}.png" end)
|
|> Enum.map(fn {name, _} -> "#{name}.png" end)
|
||||||
|> Kernel.++(["rendered.png", "full.png"])
|
|> Kernel.++(["rendered.png", "full.png"])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
|
||||||
def process(_analysis, file, versions) do
|
def process(_analysis, file, versions) do
|
||||||
preview = preview(file)
|
preview = preview(file)
|
||||||
|
|
||||||
|
@ -15,14 +24,16 @@ defmodule Philomena.Processors.Svg do
|
||||||
scaled = Enum.flat_map(versions, &scale(preview, &1))
|
scaled = Enum.flat_map(versions, &scale(preview, &1))
|
||||||
full = [{:copy, preview, "full.png"}]
|
full = [{:copy, preview, "full.png"}]
|
||||||
|
|
||||||
%{
|
[
|
||||||
intensities: intensities,
|
intensities: intensities,
|
||||||
thumbnails: scaled ++ full ++ [{:copy, preview, "rendered.png"}]
|
thumbnails: scaled ++ full ++ [{:copy, preview, "rendered.png"}]
|
||||||
}
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_process(_analysis, _file), do: %{}
|
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
|
||||||
|
def post_process(_analysis, _file), do: []
|
||||||
|
|
||||||
|
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
|
||||||
def intensities(_analysis, file) do
|
def intensities(_analysis, file) do
|
||||||
{:ok, intensities} = Intensities.file(preview(file))
|
{:ok, intensities} = Intensities.file(preview(file))
|
||||||
intensities
|
intensities
|
|
@ -1,7 +1,15 @@
|
||||||
defmodule Philomena.Processors.Webm do
|
defmodule PhilomenaMedia.Processors.Webm do
|
||||||
alias Philomena.Intensities
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Intensities
|
||||||
|
alias PhilomenaMedia.Analyzers.Result
|
||||||
|
alias PhilomenaMedia.Processors.Processor
|
||||||
|
alias PhilomenaMedia.Processors
|
||||||
import Bitwise
|
import Bitwise
|
||||||
|
|
||||||
|
@behaviour Processor
|
||||||
|
|
||||||
|
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
|
||||||
def versions(sizes) do
|
def versions(sizes) do
|
||||||
webm_versions = Enum.map(sizes, fn {name, _} -> "#{name}.webm" end)
|
webm_versions = Enum.map(sizes, fn {name, _} -> "#{name}.webm" end)
|
||||||
mp4_versions = Enum.map(sizes, fn {name, _} -> "#{name}.mp4" end)
|
mp4_versions = Enum.map(sizes, fn {name, _} -> "#{name}.mp4" end)
|
||||||
|
@ -14,6 +22,7 @@ defmodule Philomena.Processors.Webm do
|
||||||
["full.mp4", "rendered.png"] ++ webm_versions ++ mp4_versions ++ gif_versions
|
["full.mp4", "rendered.png"] ++ webm_versions ++ mp4_versions ++ gif_versions
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
|
||||||
def process(analysis, file, versions) do
|
def process(analysis, file, versions) do
|
||||||
dimensions = analysis.dimensions
|
dimensions = analysis.dimensions
|
||||||
duration = analysis.duration
|
duration = analysis.duration
|
||||||
|
@ -27,15 +36,17 @@ defmodule Philomena.Processors.Webm do
|
||||||
scaled = Enum.flat_map(versions, &scale(stripped, palette, duration, dimensions, &1))
|
scaled = Enum.flat_map(versions, &scale(stripped, palette, duration, dimensions, &1))
|
||||||
mp4 = [{:copy, mp4, "full.mp4"}]
|
mp4 = [{:copy, mp4, "full.mp4"}]
|
||||||
|
|
||||||
%{
|
[
|
||||||
replace_original: stripped,
|
replace_original: stripped,
|
||||||
intensities: intensities,
|
intensities: intensities,
|
||||||
thumbnails: scaled ++ mp4 ++ [{:copy, preview, "rendered.png"}]
|
thumbnails: scaled ++ mp4 ++ [{:copy, preview, "rendered.png"}]
|
||||||
}
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_process(_analysis, _file), do: %{}
|
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
|
||||||
|
def post_process(_analysis, _file), do: []
|
||||||
|
|
||||||
|
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
|
||||||
def intensities(analysis, file) do
|
def intensities(analysis, file) do
|
||||||
{:ok, intensities} = Intensities.file(preview(analysis.duration, file))
|
{:ok, intensities} = Intensities.file(preview(analysis.duration, file))
|
||||||
intensities
|
intensities
|
|
@ -1,6 +1,21 @@
|
||||||
defmodule Philomena.Sha512 do
|
defmodule PhilomenaMedia.Sha512 do
|
||||||
|
@moduledoc """
|
||||||
|
Streaming SHA-512 processor.
|
||||||
|
"""
|
||||||
|
|
||||||
@chunk_size 10_485_760
|
@chunk_size 10_485_760
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate the SHA2-512 hash of the file at the given path as a string.
|
||||||
|
|
||||||
|
The file is processed in 10MiB chunks.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> Sha512.file("image.png")
|
||||||
|
"97fd5243cd39e225f1478097acae71fbbff7f3027b24f0e6a8e06a0d7d3e6861cd05691d7470c76e7dfc4eb30459a906918d5ba0d144184fff02b8e34bd9ecf8"
|
||||||
|
|
||||||
|
"""
|
||||||
@spec file(Path.t()) :: String.t()
|
@spec file(Path.t()) :: String.t()
|
||||||
def file(path) do
|
def file(path) do
|
||||||
hash_ref = :crypto.hash_init(:sha512)
|
hash_ref = :crypto.hash_init(:sha512)
|
360
lib/philomena_media/uploader.ex
Normal file
360
lib/philomena_media/uploader.ex
Normal file
|
@ -0,0 +1,360 @@
|
||||||
|
defmodule PhilomenaMedia.Uploader do
|
||||||
|
@moduledoc """
|
||||||
|
Upload and processing callback logic for media files.
|
||||||
|
|
||||||
|
To use the uploader, the target schema must be modified to add at least the
|
||||||
|
following fields, assuming the name of the field to write to the database is `foo`:
|
||||||
|
|
||||||
|
field :foo, :string
|
||||||
|
field :uploaded_foo, :string, virtual: true
|
||||||
|
field :removed_foo, :string, virtual: true
|
||||||
|
|
||||||
|
The schema should also define a changeset function which casts the file parameters. This may be
|
||||||
|
the default changeset function, or a function specialized to accept only the file parameters. A
|
||||||
|
minimal schema must cast at least the following to successfully upload and replace files:
|
||||||
|
|
||||||
|
def foo_changeset(schema, attrs) do
|
||||||
|
cast(schema, attrs, [:foo, :uploaded_foo, :removed_foo])
|
||||||
|
end
|
||||||
|
|
||||||
|
Additional fields may be added to perform validations. For example, specifying a field name
|
||||||
|
`foo_mime_type` allows the creation of a MIME type filter in the changeset:
|
||||||
|
|
||||||
|
def foo_changeset(schema, attrs) do
|
||||||
|
schema
|
||||||
|
|> cast(attrs, [:foo, :foo_mime_type, :uploaded_foo, :removed_foo])
|
||||||
|
|> validate_required([:foo, :foo_mime_type])
|
||||||
|
|> validate_inclusion(:foo_mime_type, ["image/svg+xml"])
|
||||||
|
end
|
||||||
|
|
||||||
|
See `analyze_upload/4` for more information about what fields may be validated in this
|
||||||
|
fashion.
|
||||||
|
|
||||||
|
Generally, you should expect to create a `Schemas.Uploader` module, which defines functions as
|
||||||
|
follows, pointing to `m:PhilomenaMedia.Uploader`. Assuming the target field name is `"foo"`, then:
|
||||||
|
|
||||||
|
defmodule Philomena.Schemas.Uploader do
|
||||||
|
alias Philomena.Schemas.Schema
|
||||||
|
alias PhilomenaMedia.Uploader
|
||||||
|
|
||||||
|
@field_name "foo"
|
||||||
|
|
||||||
|
def analyze_upload(schema, params) do
|
||||||
|
Uploader.analyze_upload(schema, @field_name, params[@field_name], &Schema.foo_changeset/2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def persist_upload(schema) do
|
||||||
|
Uploader.persist_upload(schema, schema_file_root(), @field_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unpersist_old_upload(schema) do
|
||||||
|
Uploader.unpersist_old_upload(schema, schema_file_root(), @field_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schema_file_root do
|
||||||
|
Application.get_env(:philomena, :schema_file_root)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
A typical context usage may then look like:
|
||||||
|
|
||||||
|
alias Philomena.Schemas.Schema
|
||||||
|
alias Philomena.Schemas.Uploader
|
||||||
|
|
||||||
|
@spec create_schema(map()) :: {:ok, Schema.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def create_schema(attrs) do
|
||||||
|
%Schema{}
|
||||||
|
|> Uploader.analyze_upload(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
|> case do
|
||||||
|
{:ok, schema} ->
|
||||||
|
Uploader.persist_upload(schema)
|
||||||
|
|
||||||
|
{:ok, schema}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec update_schema(Schema.t(), map()) :: {:ok, Schema.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def update_schema(%Schema{} = schema, attrs) do
|
||||||
|
schema
|
||||||
|
|> Uploader.analyze_upload(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, schema} ->
|
||||||
|
Uploader.persist_upload(schema)
|
||||||
|
Uploader.unpersist_old_upload(schema)
|
||||||
|
|
||||||
|
{:ok, schema}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
This forwards to the core `m:PhilomenaMedia.Uploader` logic with information about the file root.
|
||||||
|
|
||||||
|
The file root is the location at which files of the given schema type are located under
|
||||||
|
the storage path. For example, the file root for the Adverts schema may be
|
||||||
|
`/srv/philomena/priv/s3/philomena/adverts` in development with the file backend,
|
||||||
|
and just `adverts` in production with the S3 backend.
|
||||||
|
|
||||||
|
It is not recommended to perform persist or unpersist operations in the scope of an `m:Ecto.Multi`,
|
||||||
|
as they may block indefinitely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias PhilomenaMedia.Analyzers
|
||||||
|
alias PhilomenaMedia.Filename
|
||||||
|
alias PhilomenaMedia.Objects
|
||||||
|
alias PhilomenaMedia.Sha512
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@type schema :: struct()
|
||||||
|
@type schema_or_changeset :: struct() | Ecto.Changeset.t()
|
||||||
|
|
||||||
|
@type field_name :: String.t()
|
||||||
|
@type file_root :: String.t()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Performs analysis of the specified `m:Plug.Upload`, and invokes a changeset callback on the schema
|
||||||
|
or changeset passed in.
|
||||||
|
|
||||||
|
The file name which will be written to is set by the assignment to the schema's `field_name`, and
|
||||||
|
the below attributes are prefixed by the `field_name`.
|
||||||
|
|
||||||
|
Assuming the file is successfully parsed, this will attempt to cast the following
|
||||||
|
attributes into the specified changeset function:
|
||||||
|
* `name` (String) - the name of the file
|
||||||
|
* `width` (integer) - the width of the file
|
||||||
|
* `height` (integer) - the height of the file
|
||||||
|
* `size` (integer) - the size of the file, in bytes
|
||||||
|
* `format` (String) - the file extension, one of `~w(gif jpg png svg webm)`, determined by reading the file
|
||||||
|
* `mime_type` (String) - the file's sniffed MIME type, determined by reading the file
|
||||||
|
* `duration` (float) - the duration of the media file
|
||||||
|
* `aspect_ratio` (float) - width divided by height.
|
||||||
|
* `orig_sha512_hash` (String) - the SHA-512 hash of the file
|
||||||
|
* `sha512_hash` (String) - the SHA-512 hash of the file
|
||||||
|
* `is_animated` (boolean) - whether the file contains animation
|
||||||
|
|
||||||
|
You may design your changeset callback to accept any of these. Here is an example which accepts
|
||||||
|
all of them:
|
||||||
|
|
||||||
|
def foo_changeset(schema, attrs)
|
||||||
|
cast(schema, attrs, [
|
||||||
|
:foo,
|
||||||
|
:foo_name,
|
||||||
|
:foo_width,
|
||||||
|
:foo_height,
|
||||||
|
:foo_size,
|
||||||
|
:foo_format,
|
||||||
|
:foo_mime_type,
|
||||||
|
:foo_duration,
|
||||||
|
:foo_aspect_ratio,
|
||||||
|
:foo_orig_sha512_hash,
|
||||||
|
:foo_sha512_hash,
|
||||||
|
:foo_is_animated,
|
||||||
|
:uploaded_foo,
|
||||||
|
:removed_foo
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
Attributes are prefixed, so assuming a `field_name` of `"foo"`, this would result in
|
||||||
|
the changeset function receiving attributes `"foo_name"`, `"foo_width"`, ... etc.
|
||||||
|
|
||||||
|
Validations on the uploaded media are also possible in the changeset callback. For example,
|
||||||
|
`m:Philomena.Adverts.Advert` performs validations on MIME type and width of its field, named
|
||||||
|
`image`:
|
||||||
|
|
||||||
|
def image_changeset(advert, attrs) do
|
||||||
|
advert
|
||||||
|
|> cast(attrs, [
|
||||||
|
:image,
|
||||||
|
:image_mime_type,
|
||||||
|
:image_size,
|
||||||
|
:image_width,
|
||||||
|
:image_height,
|
||||||
|
:uploaded_image,
|
||||||
|
:removed_image
|
||||||
|
])
|
||||||
|
|> validate_required([:image])
|
||||||
|
|> validate_inclusion(:image_mime_type, ["image/png", "image/jpeg", "image/gif"])
|
||||||
|
|> validate_inclusion(:image_width, 699..729)
|
||||||
|
end
|
||||||
|
|
||||||
|
The key (location to write the persisted file) is passed with the `field_name` attribute into the
|
||||||
|
changeset callback. The key is calculated using the current date, a UUID, and the computed
|
||||||
|
extension. A file uploaded may therefore be given a key such as
|
||||||
|
`2024/1/1/0bce8eea-17e0-11ef-b7d4-0242ac120006.png`. See `PhilomenaMedia.Filename.build/1` for
|
||||||
|
the actual construction.
|
||||||
|
|
||||||
|
This function does not persist an upload to storage.
|
||||||
|
|
||||||
|
See the module documentation for a complete example.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
@spec analyze_upload(Uploader.schema_or_changeset(), map()) :: Ecto.Changeset.t()
|
||||||
|
def analyze_upload(schema, params) do
|
||||||
|
Uploader.analyze_upload(schema, "foo", params["foo"], &Schema.foo_changeset/2)
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec analyze_upload(
|
||||||
|
schema_or_changeset(),
|
||||||
|
field_name(),
|
||||||
|
Plug.Upload.t(),
|
||||||
|
(schema_or_changeset(), map() -> Ecto.Changeset.t())
|
||||||
|
) :: Ecto.Changeset.t()
|
||||||
|
def analyze_upload(schema_or_changeset, field_name, upload_parameter, changeset_fn) do
|
||||||
|
with {:ok, analysis} <- Analyzers.analyze(upload_parameter),
|
||||||
|
analysis <- extra_attributes(analysis, upload_parameter) do
|
||||||
|
removed =
|
||||||
|
schema_or_changeset
|
||||||
|
|> change()
|
||||||
|
|> get_field(field(field_name))
|
||||||
|
|
||||||
|
attributes =
|
||||||
|
%{
|
||||||
|
"name" => analysis.name,
|
||||||
|
"width" => analysis.width,
|
||||||
|
"height" => analysis.height,
|
||||||
|
"size" => analysis.size,
|
||||||
|
"format" => analysis.extension,
|
||||||
|
"mime_type" => analysis.mime_type,
|
||||||
|
"duration" => analysis.duration,
|
||||||
|
"aspect_ratio" => analysis.aspect_ratio,
|
||||||
|
"orig_sha512_hash" => analysis.sha512,
|
||||||
|
"sha512_hash" => analysis.sha512,
|
||||||
|
"is_animated" => analysis.animated?
|
||||||
|
}
|
||||||
|
|> prefix_attributes(field_name)
|
||||||
|
|> Map.put(field_name, analysis.new_name)
|
||||||
|
|> Map.put(upload_key(field_name), upload_parameter.path)
|
||||||
|
|> Map.put(remove_key(field_name), removed)
|
||||||
|
|
||||||
|
changeset_fn.(schema_or_changeset, attributes)
|
||||||
|
else
|
||||||
|
{:unsupported_mime, mime} ->
|
||||||
|
attributes = prefix_attributes(%{"mime_type" => mime}, field_name)
|
||||||
|
changeset_fn.(schema_or_changeset, attributes)
|
||||||
|
|
||||||
|
_error ->
|
||||||
|
changeset_fn.(schema_or_changeset, %{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Writes the file to permanent storage. This should be the second-to-last step
|
||||||
|
before completing a file operation.
|
||||||
|
|
||||||
|
The key (location to write the persisted file) is fetched from the schema by `field_name`.
|
||||||
|
This is then prefixed with the `file_root` specified by the caller. Finally, the file is
|
||||||
|
written to storage.
|
||||||
|
|
||||||
|
See the module documentation for a complete example.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
@spec persist_upload(Schema.t()) :: :ok
|
||||||
|
def persist_upload(schema) do
|
||||||
|
Uploader.persist_upload(schema, schema_file_root(), "foo")
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec persist_upload(schema(), file_root(), field_name()) :: :ok
|
||||||
|
def persist_upload(schema, file_root, field_name) do
|
||||||
|
source = Map.get(schema, field(upload_key(field_name)))
|
||||||
|
dest = Map.get(schema, field(field_name))
|
||||||
|
target = Path.join(file_root, dest)
|
||||||
|
|
||||||
|
persist_file(target, source)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Persist an arbitrary file to storage with the given key.
|
||||||
|
|
||||||
|
> #### Warning {: .warning}
|
||||||
|
>
|
||||||
|
> This is exposed for schemas which do not store their files at at an offset from a file root,
|
||||||
|
> to allow overriding the key. If you do not need to override the key, use
|
||||||
|
> `persist_upload/3` instead.
|
||||||
|
|
||||||
|
The key (location to write the persisted file) and the file path to upload are passed through
|
||||||
|
to `PhilomenaMedia.Objects.upload/2` without modification. See the definition of that function for
|
||||||
|
additional details.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
key = "2024/1/1/5/full.png"
|
||||||
|
Uploader.persist_file(key, file_path)
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec persist_file(Objects.key(), Path.t()) :: :ok
|
||||||
|
def persist_file(key, file_path) do
|
||||||
|
Objects.upload(key, file_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Removes the old file from permanent storage. This should be the last step in
|
||||||
|
completing a file operation.
|
||||||
|
|
||||||
|
The key (location to write the persisted file) is fetched from the schema by `field_name`.
|
||||||
|
This is then prefixed with the `file_root` specified by the caller. Finally, the file is
|
||||||
|
purged from storage.
|
||||||
|
|
||||||
|
See the module documentation for a complete example.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
@spec unpersist_old_upload(Schema.t()) :: :ok
|
||||||
|
def unpersist_old_upload(schema) do
|
||||||
|
Uploader.unpersist_old_upload(schema, schema_file_root(), "foo")
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec unpersist_old_upload(schema(), file_root(), field_name()) :: :ok
|
||||||
|
def unpersist_old_upload(schema, file_root, field_name) do
|
||||||
|
schema
|
||||||
|
|> Map.get(field(remove_key(field_name)))
|
||||||
|
|> try_remove(file_root)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extra_attributes(analysis, %Plug.Upload{path: path, filename: filename}) do
|
||||||
|
{width, height} = analysis.dimensions
|
||||||
|
aspect_ratio = aspect_ratio(width, height)
|
||||||
|
|
||||||
|
stat = File.stat!(path)
|
||||||
|
sha512 = Sha512.file(path)
|
||||||
|
new_name = Filename.build(analysis.extension)
|
||||||
|
|
||||||
|
analysis
|
||||||
|
|> Map.put(:size, stat.size)
|
||||||
|
|> Map.put(:name, filename)
|
||||||
|
|> Map.put(:width, width)
|
||||||
|
|> Map.put(:height, height)
|
||||||
|
|> Map.put(:sha512, sha512)
|
||||||
|
|> Map.put(:new_name, new_name)
|
||||||
|
|> Map.put(:aspect_ratio, aspect_ratio)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp aspect_ratio(_, 0), do: 0.0
|
||||||
|
defp aspect_ratio(w, h), do: w / h
|
||||||
|
|
||||||
|
defp try_remove("", _file_root), do: :ok
|
||||||
|
defp try_remove(nil, _file_root), do: :ok
|
||||||
|
|
||||||
|
defp try_remove(file, file_root) do
|
||||||
|
Objects.delete(Path.join(file_root, file))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prefix_attributes(map, prefix),
|
||||||
|
do: Map.new(map, fn {key, value} -> {"#{prefix}_#{key}", value} end)
|
||||||
|
|
||||||
|
defp upload_key(field_name), do: "uploaded_#{field_name}"
|
||||||
|
|
||||||
|
defp remove_key(field_name), do: "removed_#{field_name}"
|
||||||
|
|
||||||
|
defp field(field_name), do: String.to_existing_atom(field_name)
|
||||||
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
defmodule PhilomenaWeb.ImageReverse do
|
defmodule PhilomenaWeb.ImageReverse do
|
||||||
alias Philomena.Analyzers
|
alias PhilomenaMedia.Analyzers
|
||||||
alias Philomena.Processors
|
alias PhilomenaMedia.Processors
|
||||||
alias Philomena.DuplicateReports
|
alias Philomena.DuplicateReports
|
||||||
alias Philomena.Repo
|
alias Philomena.Repo
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
Loading…
Reference in a new issue