Updated app to store compressed metadata; automatically download thumbnails

This commit is contained in:
Kieran Eglin 2024-02-24 11:56:23 -08:00
parent 1eeec684d9
commit dc264897d6
No known key found for this signature in database
GPG key ID: 193984967FCF432D
15 changed files with 244 additions and 39 deletions

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

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

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

View file

@ -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))

View file

@ -0,0 +1,74 @@
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
"""
# TODO: ensure media metadata is deleted when the media item is deleted
@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

View file

@ -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)

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -13,7 +13,9 @@ defmodule Pinchflat.MediaTest do
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)

View file

@ -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

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