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:
Kieran
2024-02-24 13:40:05 -08:00
committed by GitHub
parent e33a46bddc
commit 52c0a1742a
25 changed files with 370 additions and 61 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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
+35
View File
@@ -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
View File
@@ -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 """
+12 -3
View File
@@ -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)
+11 -4
View File
@@ -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
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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
+48 -1
View File
@@ -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
+7 -12
View File
@@ -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
+5
View File
@@ -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)