podbox_ash/lib/podbox/download/storage.ex

237 lines
6.2 KiB
Elixir
Raw Normal View History

2024-05-22 13:17:21 +12:00
defmodule Podbox.Download.Storage do
@moduledoc """
Responsible for storing files on the local filesystem.
"""
defstruct [:asset, :complete_path, :complete_root, :incomplete_root, :incomplete_path, :io]
alias Podbox.{Download.Asset, Download.InvalidStateError, Download.PosixError}
2024-05-22 13:17:21 +12:00
@type t :: %__MODULE__{
asset: nil | Asset.t(),
complete_path: nil | Path.t(),
complete_root: nil | Path.t(),
incomplete_root: nil | Path.t(),
incomplete_path: nil | Path.t(),
io: nil | File.io_device()
}
@doc """
Initialise storage for a specific asset
"""
@spec init(Asset.t(), Keyword.t()) :: t
def init(asset, opts \\ []) do
config =
storage_config()
|> Map.merge(Map.new(opts))
incomplete_path = generate_subpath(config.incomplete_root, asset.id)
complete_path = generate_subpath(config.complete_root, asset.id)
%__MODULE__{
asset: asset,
complete_path: complete_path,
complete_root: config.complete_root,
incomplete_path: incomplete_path,
incomplete_root: config.incomplete_root
}
end
@doc """
Checks and returns the storage state.
"""
@spec status(t) ::
:ok | :error | {:complete, non_neg_integer()} | {:incomplete, non_neg_integer()}
def status(storage) do
complete_exists? = File.exists?(storage.complete_path)
incomplete_exists? = File.exists?(storage.incomplete_path)
cond do
complete_exists? && incomplete_exists? -> :error
complete_exists? -> {:complete, File.stat!(storage.complete_path).size}
incomplete_exists? -> {:incomplete, File.stat!(storage.incomplete_path).size}
true -> :ok
end
end
@doc """
Ensures that the directory needed to store the partial asset is present, and
ready to be written to.
"""
@spec prepare(t) :: {:ok, t} | {:error, any}
def prepare(storage) do
incomplete_dir = Path.dirname(storage.incomplete_path)
with :ok <- handle_posix_failure(incomplete_dir, &File.mkdir_p/1, :mkdir),
{:ok, io} <-
handle_posix_failure(
storage.incomplete_path,
&File.open(&1, [:write]),
{:open, [:write]}
) do
{:ok, %{storage | io: io}}
end
end
@doc """
Write bytes to an incomplete file.
"""
@spec append(t, binary) :: {:ok, t} | {:error, any}
def append(storage, bytes) when not is_nil(storage.io) do
IO.binwrite(storage.io, bytes)
{:ok, storage}
end
@doc """
All contents are written, now move the file to complete.
"""
@spec commit(t) :: {:ok, t} | {:error, any}
def commit(storage) do
complete_dir = Path.dirname(storage.complete_path)
with :ok <- handle_posix_failure(complete_dir, &File.mkdir_p/1, :mkdir),
:ok <-
handle_posix_failure(
storage.incomplete_path,
fn _ -> File.close(storage.io) end,
:close
),
:ok <-
handle_posix_failure(
storage.incomplete_path,
&File.rename(&1, storage.complete_path),
{:move, to: storage.complete_path}
),
:ok <- prune(storage.incomplete_path, storage.incomplete_root) do
{:ok, %{storage | io: nil}}
end
end
@doc """
Remove all artefacts from the filesystem.
"""
@spec destroy(t) :: :ok | {:error, any}
def destroy(storage) when is_nil(storage.io) do
with :ok <- prune(storage.incomplete_path, storage.incomplete_root) do
prune(storage.complete_path, storage.complete_root)
end
end
def destroy(storage) do
with :ok <- File.close(storage.io) do
destroy(%{storage | io: nil})
end
end
@doc """
Read completed file contents.
"""
@spec read_completed(t) :: {:ok, binary} | {:error, any}
def read_completed(storage) do
with :ok <- assert_complete(storage) do
handle_posix_failure(storage.complete_path, &File.read/1, :read)
end
end
defp assert_complete(storage) do
case status(storage) do
{:complete, _} ->
:ok
{:incomplete, _} ->
{:error,
InvalidStateError.exception(
asset_id: storage.asset.id,
current_state: :incomplete,
expected_state: :complete
)}
:error ->
{:error,
InvalidStateError.exception(
asset_id: storage.asset.id,
current_state: :error,
expected_state: :complete
)}
:ok ->
{:error,
InvalidStateError.exception(
asset_id: storage.asset.id,
current_state: :none,
expected_state: :complete
)}
end
end
2024-05-22 13:17:21 +12:00
defp generate_subpath(
root,
<<a::binary-size(1), b::binary-size(1), c::binary-size(1), _::binary>> = id
) do
Path.join(root, [a, b, c, id])
end
defp storage_config do
config =
:podbox
|> Application.get_env(__MODULE__, [])
%{
complete_root: Keyword.fetch!(config, :complete),
incomplete_root: Keyword.fetch!(config, :incomplete)
}
end
defp handle_posix_failure(path, callback, activity) when is_function(callback, 1) do
with {:error, posix} <- callback.(path) do
{:error, PosixError.exception(file_path: path, posix: posix, activity: activity)}
end
end
defp prune(path, down_to) when is_binary(path) do
down_to_count =
down_to
|> Path.split()
|> Enum.count()
to_prune =
path
|> Path.split()
|> Enum.drop(down_to_count)
|> Enum.reverse()
prune(to_prune, down_to)
end
defp prune([], _down_to), do: :ok
defp prune([_ | remaining] = to_prune, down_to) do
path =
to_prune
|> Enum.reverse()
|> then(&[down_to | &1])
|> Path.join()
cond do
File.dir?(path) ->
case File.rmdir(path) do
:ok ->
prune(remaining, down_to)
{:error, :eexist} ->
:ok
{:error, posix} ->
{:error, PosixError.exception(file_path: path, posix: posix, activity: :delete)}
end
File.exists?(path) ->
with :ok <- handle_posix_failure(path, &File.rm/1, :delete) do
prune(remaining, down_to)
end
true ->
prune(remaining, down_to)
end
end
end