Improve metadata storage (#37)
* Updated config path to specify metadata storage path * Removed metadata column from DB, replacing it with filepath columns * Updated app to store compressed metadata; automatically download thumbnails * Ensured metadata is deleted when other files are deleted * Updated docs * Added inets to application start so http calls work in prod
This commit is contained in:
@@ -13,6 +13,7 @@ alias Pinchflat.Profiles
|
|||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
|
|
||||||
alias Pinchflat.MediaClient.{SourceDetails, VideoDownloader}
|
alias Pinchflat.MediaClient.{SourceDetails, VideoDownloader}
|
||||||
|
alias Pinchflat.Metadata.{Zipper, ThumbnailFetcher}
|
||||||
|
|
||||||
defmodule IexHelpers do
|
defmodule IexHelpers do
|
||||||
def playlist_url do
|
def playlist_url do
|
||||||
|
|||||||
+3
-1
@@ -14,7 +14,9 @@ config :pinchflat,
|
|||||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||||
yt_dlp_runner: Pinchflat.MediaClient.Backends.YtDlp.CommandRunner,
|
yt_dlp_runner: Pinchflat.MediaClient.Backends.YtDlp.CommandRunner,
|
||||||
media_directory: "/downloads",
|
media_directory: "/downloads",
|
||||||
metadata_directory: Path.join([System.tmp_dir!(), "pinchflat", "metadata"]),
|
# The user may or may not store metadata for their needs, but the app will always store its copy
|
||||||
|
metadata_directory: "/config/metadata",
|
||||||
|
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
||||||
# Setting AUTH_USERNAME and AUTH_PASSWORD implies you want to use basic auth.
|
# Setting AUTH_USERNAME and AUTH_PASSWORD implies you want to use basic auth.
|
||||||
# If either is unset, basic auth will not be used.
|
# If either is unset, basic auth will not be used.
|
||||||
basic_auth_username: System.get_env("AUTH_USERNAME"),
|
basic_auth_username: System.get_env("AUTH_USERNAME"),
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@ import Config
|
|||||||
|
|
||||||
config :pinchflat,
|
config :pinchflat,
|
||||||
media_directory: Path.join([File.cwd!(), "tmp", "videos"]),
|
media_directory: Path.join([File.cwd!(), "tmp", "videos"]),
|
||||||
metadata_directory: Path.join([File.cwd!(), "tmp", "metadata"])
|
metadata_directory: Path.join([File.cwd!(), "tmp", "metadata"]),
|
||||||
|
tmpfile_directory: Path.join([File.cwd!(), "tmp", "tmpfiles"])
|
||||||
|
|
||||||
# Configure your database
|
# Configure your database
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
|
|||||||
+12
-9
@@ -22,19 +22,24 @@ if System.get_env("PHX_SERVER") do
|
|||||||
end
|
end
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
database_path =
|
config_path =
|
||||||
System.get_env("DATABASE_PATH") ||
|
System.get_env("CONFIG_PATH") ||
|
||||||
raise """
|
raise """
|
||||||
environment variable DATABASE_PATH is missing.
|
environment variable CONFIG_PATH is missing.
|
||||||
For example: /etc/pinchflat/pinchflat.db
|
For example: /etc/pinchflat/config
|
||||||
"""
|
"""
|
||||||
|
|
||||||
log_path = System.get_env("LOG_PATH", "log/pinchflat.log")
|
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
|
||||||
|
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
|
||||||
|
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
||||||
|
|
||||||
config :pinchflat, yt_dlp_executable: System.find_executable("yt-dlp")
|
config :pinchflat,
|
||||||
|
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||||
|
metadata_directory: metadata_path,
|
||||||
|
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY")
|
||||||
|
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
database: database_path,
|
database: db_path,
|
||||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||||
|
|
||||||
# The secret key base is used to sign/encrypt cookies and other secrets.
|
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||||
@@ -63,8 +68,6 @@ if config_env() == :prod do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
config :pinchflat, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
|
||||||
|
|
||||||
config :pinchflat, PinchflatWeb.Endpoint,
|
config :pinchflat, PinchflatWeb.Endpoint,
|
||||||
http: [
|
http: [
|
||||||
# Enable IPv6 and bind on all interfaces.
|
# Enable IPv6 and bind on all interfaces.
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@ config :pinchflat,
|
|||||||
# Specifying backend data here makes mocking and local testing SUPER easy
|
# Specifying backend data here makes mocking and local testing SUPER easy
|
||||||
yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
|
yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
|
||||||
media_directory: Path.join([System.tmp_dir!(), "test", "videos"]),
|
media_directory: Path.join([System.tmp_dir!(), "test", "videos"]),
|
||||||
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"])
|
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]),
|
||||||
|
tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"])
|
||||||
|
|
||||||
config :pinchflat, Oban, testing: :manual
|
config :pinchflat, Oban, testing: :manual
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
defmodule Pinchflat.HTTP.HTTPBehaviour do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the behaviour for HTTP clients. Literally just
|
||||||
|
so I can use Mox to create an HTTP mock
|
||||||
|
"""
|
||||||
|
|
||||||
|
@callback get(String.t(), Keyword.t(), Keyword.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
defmodule Pinchflat.HTTP.HTTPClient do
|
||||||
|
@moduledoc """
|
||||||
|
This module provides a simple interface for making HTTP requests.
|
||||||
|
|
||||||
|
Made to be easily swappable with other HTTP clients. If you need more complexity
|
||||||
|
or security, check out HTTPoison or Mint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.HTTP.HTTPBehaviour
|
||||||
|
|
||||||
|
@behaviour HTTPBehaviour
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Makes a GET request to the given URL and returns the response.
|
||||||
|
|
||||||
|
NOTE: I can't really test this with Mox and I can't think of a way to test this
|
||||||
|
that isn't ultimately redundant. I'm just going to leave it untested for now and
|
||||||
|
focus more on testing the consumers of this module.
|
||||||
|
|
||||||
|
Returns {:ok, String.t()} | {:error, String.t()}
|
||||||
|
"""
|
||||||
|
@impl HTTPBehaviour
|
||||||
|
def get(url, headers \\ [], opts \\ []) do
|
||||||
|
case :httpc.request(:get, {url, headers}, [], opts) do
|
||||||
|
{:ok, {{_version, 200, _reason_phrase}, _headers, body}} ->
|
||||||
|
{:ok, body}
|
||||||
|
|
||||||
|
{:ok, {{_version, status_code, reason_phrase}, _headers, _body}} ->
|
||||||
|
{:error, "HTTP request failed with status code #{status_code}: #{reason_phrase}"}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, "HTTP request failed: #{reason}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+33
-4
@@ -9,6 +9,7 @@ defmodule Pinchflat.Media do
|
|||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.Media.MediaMetadata
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of media_items. Returns [%MediaItem{}, ...].
|
Returns the list of media_items. Returns [%MediaItem{}, ...].
|
||||||
@@ -103,6 +104,21 @@ defmodule Pinchflat.Media do
|
|||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.filter(&is_binary/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Produces a flat list of the filesystem paths for a media_item's metadata files.
|
||||||
|
Returns an empty list if the media_item has no metadata.
|
||||||
|
|
||||||
|
Returns [binary()] | []
|
||||||
|
"""
|
||||||
|
def metadata_filepaths(media_item) do
|
||||||
|
metadata = Repo.preload(media_item, :metadata).metadata || %MediaMetadata{}
|
||||||
|
mapped_struct = Map.from_struct(metadata)
|
||||||
|
|
||||||
|
MediaMetadata.filepath_attributes()
|
||||||
|
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||||
|
|> Enum.filter(&is_binary/1)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
|
Creates a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
|
||||||
"""
|
"""
|
||||||
@@ -134,18 +150,31 @@ defmodule Pinchflat.Media do
|
|||||||
@doc """
|
@doc """
|
||||||
Deletes the media_item's associated files. Will leave the media_item in the database.
|
Deletes the media_item's associated files. Will leave the media_item in the database.
|
||||||
|
|
||||||
|
NOTE: this deletes the metadata files as well, but maybe it shouldn't? I'm wondering if
|
||||||
|
the metadata is more a concern of the DB record itself and should be lumped in with those
|
||||||
|
delete operations. But the metadata does come from the download operation of the file.
|
||||||
|
Food for thought but not a priority at the moment.
|
||||||
|
|
||||||
Returns {:ok, %MediaItem{}}
|
Returns {:ok, %MediaItem{}}
|
||||||
"""
|
"""
|
||||||
def delete_attachments(media_item) do
|
def delete_attachments(media_item) do
|
||||||
|
media_item = Repo.preload(media_item, :metadata)
|
||||||
|
|
||||||
media_item
|
media_item
|
||||||
|> media_filepaths()
|
|> media_filepaths()
|
||||||
|
|> Enum.concat(metadata_filepaths(media_item))
|
||||||
|> Enum.each(&File.rm/1)
|
|> Enum.each(&File.rm/1)
|
||||||
|
|
||||||
# Fails if the directory is not empty
|
# rmdir will attempt to delete the directory, but only if it is empty
|
||||||
case File.rmdir(Path.dirname(media_item.media_filepath)) do
|
if media_item.media_filepath do
|
||||||
:ok -> {:ok, media_item}
|
File.rmdir(Path.dirname(media_item.media_filepath))
|
||||||
{:error, :eexist} -> {:ok, media_item}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if media_item.metadata && media_item.metadata.metadata_filepath do
|
||||||
|
File.rmdir(Path.dirname(media_item.metadata.metadata_filepath))
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, media_item}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ defmodule Pinchflat.Media.MediaMetadata do
|
|||||||
|
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
|
|
||||||
|
@allowed_fields ~w(metadata_filepath thumbnail_filepath)a
|
||||||
|
@required_fields ~w(metadata_filepath thumbnail_filepath)a
|
||||||
|
|
||||||
schema "media_metadata" do
|
schema "media_metadata" do
|
||||||
field :client_response, :map
|
field :metadata_filepath, :string
|
||||||
|
field :thumbnail_filepath, :string
|
||||||
|
|
||||||
belongs_to :media_item, MediaItem
|
belongs_to :media_item, MediaItem
|
||||||
|
|
||||||
@@ -21,8 +25,13 @@ defmodule Pinchflat.Media.MediaMetadata do
|
|||||||
@doc false
|
@doc false
|
||||||
def changeset(media_metadata, attrs) do
|
def changeset(media_metadata, attrs) do
|
||||||
media_metadata
|
media_metadata
|
||||||
|> cast(attrs, [:client_response])
|
|> cast(attrs, @allowed_fields)
|
||||||
|> validate_required([:client_response])
|
|> validate_required(@required_fields)
|
||||||
|> unique_constraint([:media_item_id])
|
|> unique_constraint([:media_item_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def filepath_attributes do
|
||||||
|
~w(metadata_filepath thumbnail_filepath)a
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunner do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp generate_json_output_path do
|
defp generate_json_output_path do
|
||||||
metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
|
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
filepath = Path.join([metadata_directory, "#{StringUtils.random_string(64)}.json"])
|
filepath = Path.join([tmpfile_directory, "#{StringUtils.random_string(64)}.json"])
|
||||||
|
|
||||||
# Ensure the file can be created and written to BEFORE we run the `yt-dlp` command
|
# Ensure the file can be created and written to BEFORE we run the `yt-dlp` command
|
||||||
:ok = File.mkdir_p!(Path.dirname(filepath))
|
:ok = File.mkdir_p!(Path.dirname(filepath))
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataFileHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
Provides methods for creating/downloading/storing related metadata
|
||||||
|
out-of-band of the normal yt-dlp backend process.
|
||||||
|
|
||||||
|
The idea is that I don't want to craft a complicated yt-dlp command,
|
||||||
|
instead focusing on downloading the video as the user wants it then
|
||||||
|
I can use the result of that here to grab the additional information
|
||||||
|
needed
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Compresses and stores metadata for a media item, returning the filepath.
|
||||||
|
|
||||||
|
Returns binary()
|
||||||
|
"""
|
||||||
|
def compress_and_store_metadata_for(database_record, metadata_map) do
|
||||||
|
filepath = generate_filepath_for(database_record, "metadata.json.gz")
|
||||||
|
{:ok, json} = Phoenix.json_library().encode(metadata_map)
|
||||||
|
|
||||||
|
File.mkdir_p!(Path.dirname(filepath))
|
||||||
|
:ok = File.write(filepath, json, [:compressed])
|
||||||
|
|
||||||
|
filepath
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Reads and decodes compressed metadata from a filepath.
|
||||||
|
|
||||||
|
Returns {:ok, map()} | {:error, any}
|
||||||
|
"""
|
||||||
|
def read_compressed_metadata(filepath) do
|
||||||
|
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all))
|
||||||
|
|
||||||
|
Phoenix.json_library().decode(json)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Downloads and stores a thumbnail for a media item, returning the filepath.
|
||||||
|
|
||||||
|
Returns binary()
|
||||||
|
"""
|
||||||
|
def download_and_store_thumbnail_for(database_record, metadata_map) do
|
||||||
|
thumbnail_url = metadata_map["thumbnail"]
|
||||||
|
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
|
||||||
|
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
|
||||||
|
|
||||||
|
File.mkdir_p!(Path.dirname(filepath))
|
||||||
|
:ok = File.write(filepath, thumbnail_blob)
|
||||||
|
|
||||||
|
filepath
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_thumbnail_from_url(url) do
|
||||||
|
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
||||||
|
{:ok, body} = http_client.get(url, [], body_format: :binary)
|
||||||
|
|
||||||
|
body
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_filepath_for(database_record, filename) do
|
||||||
|
metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
|
||||||
|
record_table_name = database_record.__meta__.source
|
||||||
|
|
||||||
|
Path.join([
|
||||||
|
metadata_directory,
|
||||||
|
record_table_name,
|
||||||
|
to_string(database_record.id),
|
||||||
|
filename
|
||||||
|
])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -16,13 +16,7 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataParser do
|
|||||||
Returns map()
|
Returns map()
|
||||||
"""
|
"""
|
||||||
def parse_for_media_item(metadata) do
|
def parse_for_media_item(metadata) do
|
||||||
metadata_attrs = %{
|
Map.new()
|
||||||
metadata: %{
|
|
||||||
client_response: metadata
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata_attrs
|
|
||||||
|> Map.merge(parse_media_metadata(metadata))
|
|> Map.merge(parse_media_metadata(metadata))
|
||||||
|> Map.merge(parse_subtitle_metadata(metadata))
|
|> Map.merge(parse_subtitle_metadata(metadata))
|
||||||
|> Map.merge(parse_thumbnail_metadata(metadata))
|
|> Map.merge(parse_thumbnail_metadata(metadata))
|
||||||
@@ -38,7 +32,6 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataParser do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp parse_subtitle_metadata(metadata) do
|
defp parse_subtitle_metadata(metadata) do
|
||||||
# IDEA: if needed, consider filtering out subtitles that don't exist on-disk
|
|
||||||
subtitle_filepaths =
|
subtitle_filepaths =
|
||||||
(metadata["requested_subtitles"] || %{})
|
(metadata["requested_subtitles"] || %{})
|
||||||
|> Enum.map(fn {lang, attrs} -> [lang, attrs["filepath"]] end)
|
|> Enum.map(fn {lang, attrs} -> [lang, attrs["filepath"]] end)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
|
|||||||
alias Pinchflat.MediaClient.Backends.YtDlp.Video, as: YtDlpVideo
|
alias Pinchflat.MediaClient.Backends.YtDlp.Video, as: YtDlpVideo
|
||||||
alias Pinchflat.Profiles.Options.YtDlp.DownloadOptionBuilder, as: YtDlpDownloadOptionBuilder
|
alias Pinchflat.Profiles.Options.YtDlp.DownloadOptionBuilder, as: YtDlpDownloadOptionBuilder
|
||||||
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: YtDlpMetadataParser
|
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: YtDlpMetadataParser
|
||||||
|
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataFileHelpers, as: YtDlpMetadataHelpers
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Downloads a video for a media item, updating the media item based on the metadata
|
Downloads a video for a media item, updating the media item based on the metadata
|
||||||
@@ -34,12 +35,18 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
|
|||||||
|
|
||||||
case download_for_media_profile(media_item.original_url, media_profile, backend) do
|
case download_for_media_profile(media_item.original_url, media_profile, backend) do
|
||||||
{:ok, parsed_json} ->
|
{:ok, parsed_json} ->
|
||||||
parser = metadata_parser(backend)
|
{parser, helpers} = metadata_parsers(backend)
|
||||||
|
|
||||||
parsed_attrs =
|
parsed_attrs =
|
||||||
parsed_json
|
parsed_json
|
||||||
|> parser.parse_for_media_item()
|
|> parser.parse_for_media_item()
|
||||||
|> Map.merge(%{media_downloaded_at: DateTime.utc_now()})
|
|> Map.merge(%{
|
||||||
|
media_downloaded_at: DateTime.utc_now(),
|
||||||
|
metadata: %{
|
||||||
|
metadata_filepath: helpers.compress_and_store_metadata_for(media_item, parsed_json),
|
||||||
|
thumbnail_filepath: helpers.download_and_store_thumbnail_for(media_item, parsed_json)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
# Don't forgor to use preloaded associations or updates to
|
# Don't forgor to use preloaded associations or updates to
|
||||||
# associations won't work!
|
# associations won't work!
|
||||||
@@ -70,9 +77,9 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp metadata_parser(backend) do
|
defp metadata_parsers(backend) do
|
||||||
case backend do
|
case backend do
|
||||||
:yt_dlp -> YtDlpMetadataParser
|
:yt_dlp -> {YtDlpMetadataParser, YtDlpMetadataHelpers}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def application do
|
def application do
|
||||||
[
|
[
|
||||||
mod: {Pinchflat.Application, []},
|
mod: {Pinchflat.Application, []},
|
||||||
extra_applications: [:logger, :runtime_tools]
|
extra_applications: [:logger, :runtime_tools, :inets]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddMetadataFilepathToMediaMetadata do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:media_metadata) do
|
||||||
|
add :metadata_filepath, :string, null: false
|
||||||
|
add :thumbnail_filepath, :string, null: false
|
||||||
|
|
||||||
|
remove :client_response, :json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -99,8 +99,7 @@ RUN chown nobody /config /downloads
|
|||||||
|
|
||||||
# set runner ENV
|
# set runner ENV
|
||||||
ENV MIX_ENV="prod"
|
ENV MIX_ENV="prod"
|
||||||
ENV DATABASE_PATH="/config/db/pinchflat.db"
|
ENV CONFIG_PATH="/config"
|
||||||
ENV LOG_PATH="/config/logs/pinchflat.log"
|
|
||||||
ENV PORT=8945
|
ENV PORT=8945
|
||||||
ENV RUN_CONTEXT="selfhosted"
|
ENV RUN_CONTEXT="selfhosted"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataFileHelpersTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
import Mox
|
||||||
|
import Pinchflat.MediaFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataFileHelpers, as: Helpers
|
||||||
|
|
||||||
|
setup do
|
||||||
|
media_item = media_item_fixture()
|
||||||
|
|
||||||
|
{:ok, %{media_item: media_item}}
|
||||||
|
end
|
||||||
|
|
||||||
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
describe "compress_and_store_metadata_for/2" do
|
||||||
|
test "returns the filepath", %{media_item: media_item} do
|
||||||
|
metadata_map = %{"foo" => "bar"}
|
||||||
|
|
||||||
|
filepath = Helpers.compress_and_store_metadata_for(media_item, metadata_map)
|
||||||
|
|
||||||
|
assert filepath =~ ~r{/media_items/#{media_item.id}/metadata.json.gz}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates folder structure based on passed record", %{media_item: media_item} do
|
||||||
|
metadata_map = %{"foo" => "bar"}
|
||||||
|
|
||||||
|
filepath = Helpers.compress_and_store_metadata_for(media_item, metadata_map)
|
||||||
|
|
||||||
|
assert File.exists?(Path.dirname(filepath))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores it as compressed JSON", %{media_item: media_item} do
|
||||||
|
metadata_map = %{"foo" => "bar"}
|
||||||
|
|
||||||
|
filepath = Helpers.compress_and_store_metadata_for(media_item, metadata_map)
|
||||||
|
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all))
|
||||||
|
|
||||||
|
assert json == Phoenix.json_library().encode!(metadata_map)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "read_compressed_metadata/1" do
|
||||||
|
test "returns the compressed and decoded metadata", %{media_item: media_item} do
|
||||||
|
metadata_map = %{"foo" => "bar"}
|
||||||
|
|
||||||
|
filepath = Helpers.compress_and_store_metadata_for(media_item, metadata_map)
|
||||||
|
{:ok, decoded_json} = Helpers.read_compressed_metadata(filepath)
|
||||||
|
|
||||||
|
assert decoded_json == metadata_map
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "download_and_store_thumbnail_for/2" do
|
||||||
|
setup do
|
||||||
|
# This tests that the HTTP endpoint is being called with every test
|
||||||
|
expect(HTTPClientMock, :get, fn url, _headers, _opts ->
|
||||||
|
assert url =~ "example.com"
|
||||||
|
|
||||||
|
{:ok, "thumbnail data"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
metadata = %{"thumbnail" => "example.com/thumbnail.jpg"}
|
||||||
|
|
||||||
|
{:ok, %{metadata: metadata}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns the filepath", %{media_item: media_item, metadata: metadata} do
|
||||||
|
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||||
|
|
||||||
|
assert filepath =~ ~r{/media_items/#{media_item.id}/thumbnail.jpg}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates folder structure based on passed record", %{media_item: media_item, metadata: metadata} do
|
||||||
|
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||||
|
|
||||||
|
assert File.exists?(Path.dirname(filepath))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the filename and extension is based on the URL", %{media_item: media_item} do
|
||||||
|
metadata = %{"thumbnail" => "example.com/maxres.webp"}
|
||||||
|
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||||
|
|
||||||
|
assert Path.basename(filepath) == "maxres.webp"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
defmodule Pinchflat.MediaClient.Backends.YtDlp.MediaParserTest do
|
defmodule Pinchflat.MediaClient.Backends.YtDlp.MediaParserTest do
|
||||||
use ExUnit.Case, async: true
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: Parser
|
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: Parser
|
||||||
|
|
||||||
@@ -41,12 +41,6 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.MediaParserTest do
|
|||||||
|
|
||||||
assert is_binary(result.description)
|
assert is_binary(result.description)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it returns the metadata as a map", %{metadata: metadata} do
|
|
||||||
result = Parser.parse_for_media_item(metadata)
|
|
||||||
|
|
||||||
assert result.metadata.client_response == metadata
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "parse_for_media_item/1 when testing subtitle metadata" do
|
describe "parse_for_media_item/1 when testing subtitle metadata" do
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
defmodule Pinchflat.MediaClient.Backends.YtDlp.OutputPathBuilderTest do
|
defmodule Pinchflat.MediaClient.Backends.YtDlp.OutputPathBuilderTest do
|
||||||
use ExUnit.Case, async: true
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder
|
alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
|
defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
|
||||||
use ExUnit.Case, async: true
|
use Pinchflat.DataCase
|
||||||
import Mox
|
import Mox
|
||||||
import Pinchflat.SourcesFixtures
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ defmodule Pinchflat.MediaClient.VideoDownloaderTest do
|
|||||||
[:metadata, source: :media_profile]
|
[:metadata, source: :media_profile]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
{:ok, %{media_item: media_item}}
|
{:ok, %{media_item: media_item}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -29,15 +33,16 @@ defmodule Pinchflat.MediaClient.VideoDownloaderTest do
|
|||||||
assert {:ok, _} = VideoDownloader.download_for_media_item(media_item)
|
assert {:ok, _} = VideoDownloader.download_for_media_item(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it saves the metadata to the database", %{media_item: media_item} do
|
test "it saves the metadata filepatha to the database", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert is_nil(media_item.metadata)
|
assert is_nil(media_item.metadata)
|
||||||
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
|
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
|
||||||
assert updated_media_item.metadata
|
|
||||||
assert is_map(updated_media_item.metadata.client_response)
|
assert updated_media_item.metadata.metadata_filepath =~ "media_items/#{media_item.id}/metadata.json.gz"
|
||||||
|
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "errors are passed through", %{media_item: media_item} do
|
test "errors are passed through", %{media_item: media_item} do
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
defmodule Pinchflat.MediaTest do
|
defmodule Pinchflat.MediaTest do
|
||||||
use Pinchflat.DataCase
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Mox
|
||||||
import Pinchflat.TasksFixtures
|
import Pinchflat.TasksFixtures
|
||||||
import Pinchflat.MediaFixtures
|
import Pinchflat.MediaFixtures
|
||||||
import Pinchflat.ProfilesFixtures
|
import Pinchflat.ProfilesFixtures
|
||||||
@@ -8,12 +9,17 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
|
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataFileHelpers
|
||||||
|
|
||||||
|
setup :verify_on_exit!
|
||||||
|
|
||||||
@invalid_attrs %{title: nil, media_id: nil, media_filepath: nil}
|
@invalid_attrs %{title: nil, media_id: nil, media_filepath: nil}
|
||||||
|
|
||||||
describe "schema" do
|
describe "schema" do
|
||||||
test "media_metadata is deleted when media_item is deleted" do
|
test "media_metadata is deleted when media_item is deleted" do
|
||||||
media_item = media_item_fixture(%{metadata: %{client_response: %{foo: "bar"}}})
|
media_item =
|
||||||
|
media_item_fixture(%{metadata: %{metadata_filepath: "/metadata.json.gz", thumbnail_filepath: "/thumbnail.jpg"}})
|
||||||
|
|
||||||
metadata = media_item.metadata
|
metadata = media_item.metadata
|
||||||
assert {:ok, %MediaItem{}} = Media.delete_media_item(media_item)
|
assert {:ok, %MediaItem{}} = Media.delete_media_item(media_item)
|
||||||
|
|
||||||
@@ -259,6 +265,27 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "metadata_filepaths" do
|
||||||
|
test "returns filepaths in a flat list" do
|
||||||
|
filepaths = %{
|
||||||
|
metadata_filepath: "/metadata.json.gz",
|
||||||
|
thumbnail_filepath: "/thumbnail.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
media_item = media_item_fixture(%{metadata: filepaths})
|
||||||
|
|
||||||
|
assert Media.metadata_filepaths(media_item) == [
|
||||||
|
"/metadata.json.gz",
|
||||||
|
"/thumbnail.jpg"
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns an empty list when there is no metadata" do
|
||||||
|
media_item = media_item_fixture()
|
||||||
|
assert Media.metadata_filepaths(media_item) == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "create_media_item/1" do
|
describe "create_media_item/1" do
|
||||||
test "creating with valid data creates a media_item" do
|
test "creating with valid data creates a media_item" do
|
||||||
valid_attrs = %{
|
valid_attrs = %{
|
||||||
@@ -328,6 +355,26 @@ defmodule Pinchflat.MediaTest do
|
|||||||
refute File.exists?(media_item.media_filepath)
|
refute File.exists?(media_item.media_filepath)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "deletes the media item's metadata files" do
|
||||||
|
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||||
|
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
||||||
|
|
||||||
|
update_attrs = %{
|
||||||
|
metadata: %{
|
||||||
|
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||||
|
thumbnail_filepath:
|
||||||
|
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, %{
|
||||||
|
"thumbnail" => "https://example.com/thumbnail.jpg"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, updated_media_item} = Media.update_media_item(media_item, update_attrs)
|
||||||
|
|
||||||
|
assert {:ok, _} = Media.delete_attachments(updated_media_item)
|
||||||
|
refute File.exists?(updated_media_item.metadata.metadata_filepath)
|
||||||
|
end
|
||||||
|
|
||||||
test "does not delete the media item" do
|
test "does not delete the media item" do
|
||||||
media_item = media_item_with_attachments()
|
media_item = media_item_with_attachments()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ defmodule Pinchflat.Workers.VideoDownloadWorkerTest do
|
|||||||
[:metadata, source: :media_profile]
|
[:metadata, source: :media_profile]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
{:ok, %{media_item: media_item}}
|
{:ok, %{media_item: media_item}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -31,18 +31,13 @@ defmodule Pinchflat.MediaFixtures do
|
|||||||
Generate a media_item with metadata.
|
Generate a media_item with metadata.
|
||||||
"""
|
"""
|
||||||
def media_item_with_metadata(attrs \\ %{}) do
|
def media_item_with_metadata(attrs \\ %{}) do
|
||||||
json_filepath =
|
merged_attrs =
|
||||||
Path.join([
|
Map.merge(attrs, %{
|
||||||
File.cwd!(),
|
metadata: %{
|
||||||
"test",
|
metadata_filepath: Application.get_env(:pinchflat, :metadata_directory) <> "/metadata.json.gz",
|
||||||
"support",
|
thumbnail_filepath: Application.get_env(:pinchflat, :metadata_directory) <> "/thumbnail.jpg"
|
||||||
"files",
|
}
|
||||||
"media_metadata.json"
|
})
|
||||||
])
|
|
||||||
|
|
||||||
{:ok, file_body} = File.read(json_filepath)
|
|
||||||
{:ok, parsed_json} = Phoenix.json_library().decode(file_body)
|
|
||||||
merged_attrs = Map.merge(attrs, %{metadata: %{client_respinse: parsed_json}})
|
|
||||||
|
|
||||||
media_item_fixture(merged_attrs)
|
media_item_fixture(merged_attrs)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
Mox.defmock(YtDlpRunnerMock, for: Pinchflat.MediaClient.Backends.BackendCommandRunner)
|
Mox.defmock(YtDlpRunnerMock, for: Pinchflat.MediaClient.Backends.BackendCommandRunner)
|
||||||
Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock)
|
Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock)
|
||||||
|
|
||||||
|
Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour)
|
||||||
|
Application.put_env(:pinchflat, :http_client, HTTPClientMock)
|
||||||
|
|
||||||
ExUnit.start()
|
ExUnit.start()
|
||||||
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)
|
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)
|
||||||
Faker.start()
|
Faker.start()
|
||||||
@@ -8,7 +11,9 @@ Faker.start()
|
|||||||
ExUnit.after_suite(fn _ ->
|
ExUnit.after_suite(fn _ ->
|
||||||
File.rm_rf!(Application.get_env(:pinchflat, :media_directory))
|
File.rm_rf!(Application.get_env(:pinchflat, :media_directory))
|
||||||
File.rm_rf!(Application.get_env(:pinchflat, :metadata_directory))
|
File.rm_rf!(Application.get_env(:pinchflat, :metadata_directory))
|
||||||
|
File.rm_rf!(Application.get_env(:pinchflat, :tmpfile_directory))
|
||||||
|
|
||||||
File.mkdir_p!(Application.get_env(:pinchflat, :media_directory))
|
File.mkdir_p!(Application.get_env(:pinchflat, :media_directory))
|
||||||
File.mkdir_p!(Application.get_env(:pinchflat, :metadata_directory))
|
File.mkdir_p!(Application.get_env(:pinchflat, :metadata_directory))
|
||||||
|
File.mkdir_p!(Application.get_env(:pinchflat, :tmpfile_directory))
|
||||||
end)
|
end)
|
||||||
|
|||||||
Reference in New Issue
Block a user