diff --git a/ideas.md b/ideas.md index fabc001..2cb74e3 100644 --- a/ideas.md +++ b/ideas.md @@ -1,3 +1,3 @@ - Write media datbase ID as metadata/to file/whatever so it gives us an option to retroactively match media to the DB down the line. Useful if someone moves the media without informing the UI - - Use a UUID for the media database ID (or at least alongside it) + - Use a UUID for the media database ID (or at least alongside it) - Look into this and its recommended plugins https://hexdocs.pm/ex_check/readme.html diff --git a/lib/pinchflat/downloader/backends/yt_dlp/channel.ex b/lib/pinchflat/downloader/backends/yt_dlp/channel.ex index 97e56a9..6b17447 100644 --- a/lib/pinchflat/downloader/backends/yt_dlp/channel.ex +++ b/lib/pinchflat/downloader/backends/yt_dlp/channel.ex @@ -3,27 +3,26 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.Channel do Contains utilities for working with a channel's videos """ - # TODO: convert to `use` - import Pinchflat.Downloader.Backends.YtDlp.VideoCollection - alias __MODULE__ + use Pinchflat.Downloader.Backends.YtDlp.VideoCollection + alias Pinchflat.Downloader.ChannelDetails - defstruct [:id, :name] + @doc """ + Gets a channel's ID and name from its URL. - def new(id, name) do - %__MODULE__{id: id, name: name} - end + yt-dlp does not _really_ have channel-specific functions, so + instead we're fetching just the first video (using playlist_end: 1) + and parsing the channel ID and name from _its_ metadata + Returns {:ok, %ChannelDetails{}} | {:error, any, ...}. + """ def get_channel_info(channel_url) do opts = [print: "%(.{channel,channel_id})j", playlist_end: 1] - case backend_runner().run(channel_url, opts) do - {:ok, output} -> - result = Phoenix.json_library().decode!(output) - - {:ok, Channel.new(result["channel_id"], result["channel"])} - - res -> - res + with {:ok, output} <- backend_runner().run(channel_url, opts), + {:ok, parsed_json} <- Phoenix.json_library().decode(output) do + {:ok, ChannelDetails.new(parsed_json["channel_id"], parsed_json["channel"])} + else + err -> err end end diff --git a/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex b/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex index b452dfd..1fc2339 100644 --- a/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex +++ b/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex @@ -11,6 +11,8 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunner do @doc """ Runs a yt-dlp command and returns the string output + Returns {:ok, binary()} | {:error, output, status}. + # IDEA: deduplicate command opts, keeping the last one on conflict although possibly not needed (and a LOT easier) if yt-dlp just ignores duplicate options (ie: look into that) diff --git a/lib/pinchflat/downloader/backends/yt_dlp/video.ex b/lib/pinchflat/downloader/backends/yt_dlp/video.ex index a8ac95d..73454a6 100644 --- a/lib/pinchflat/downloader/backends/yt_dlp/video.ex +++ b/lib/pinchflat/downloader/backends/yt_dlp/video.ex @@ -6,13 +6,16 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.Video do @doc """ Downloads a single video (and possible metadata) directly to its final destination. Returns the parsed JSON output from yt-dlp. + + Returns {:ok, map()} | {:error, any, ...}. """ def download(url, command_opts \\ []) do opts = [:no_simulate, print: "%()j"] ++ command_opts - case backend_runner().run(url, opts) do - # TODO: test that I changed this to a ! method - {:ok, output} -> {:ok, Phoenix.json_library().decode!(output)} + with {:ok, output} <- backend_runner().run(url, opts), + {:ok, parsed_json} <- Phoenix.json_library().decode(output) do + {:ok, parsed_json} + else err -> err end end diff --git a/lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex b/lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex index 33f908a..d96feb2 100644 --- a/lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex +++ b/lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex @@ -2,22 +2,27 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollection do @moduledoc """ Contains utilities for working with collections of videos (ie: channels, playlists). - Meant to be included in other modules but can be used on its own. + Meant to be included in other modules but can be used on its own. Channels and playlists + will have many of their own methods, but also share a lot of methods. This module is for + those shared methods. """ - @doc """ - Returns a list of strings representing the video ids in the collection - """ - def get_video_ids(url, command_opts \\ []) do - opts = command_opts ++ [:simulate, :skip_download, print: :id] + defmacro __using__(_) do + quote do + @doc """ + Returns a list of strings representing the video ids in the collection. - case backend_runner().run(url, opts) do - {:ok, output} -> {:ok, String.split(output, "\n", trim: true)} - res -> res + Returns {:ok, [binary()]} | {:error, any, ...}. + """ + def get_video_ids(url, command_opts \\ []) do + runner = Application.get_env(:pinchflat, :yt_dlp_runner) + opts = command_opts ++ [:simulate, :skip_download, print: :id] + + case runner.run(url, opts) do + {:ok, output} -> {:ok, String.split(output, "\n", trim: true)} + res -> res + end + end end end - - defp backend_runner do - Application.get_env(:pinchflat, :yt_dlp_runner) - end end diff --git a/lib/pinchflat/downloader/channel_details.ex b/lib/pinchflat/downloader/channel_details.ex new file mode 100644 index 0000000..529764e --- /dev/null +++ b/lib/pinchflat/downloader/channel_details.ex @@ -0,0 +1,32 @@ +defmodule Pinchflat.Downloader.ChannelDetails do + @moduledoc """ + This is the integration layer for actually working with channels. + + Technically hardcodes the yt-dlp backend for now, but should leave + it open-ish for future expansion (just in case). + """ + @enforce_keys [:id, :name] + defstruct [:id, :name] + + alias Pinchflat.Downloader.Backends.YtDlp.Channel, as: YtDlpChannel + + @doc false + def new(id, name) do + %__MODULE__{id: id, name: name} + end + + @doc """ + Gets a channel's ID and name from its URL, using the given backend. + + Returns {:ok, map()} | {:error, any, ...}. + """ + def get_channel_details(channel_url, backend \\ :yt_dlp) do + channel_module(backend).get_channel_info(channel_url) + end + + defp channel_module(backend) do + case backend do + :yt_dlp -> YtDlpChannel + end + end +end diff --git a/lib/pinchflat/downloader/video_downloader.ex b/lib/pinchflat/downloader/video_downloader.ex index d5166b7..d85e635 100644 --- a/lib/pinchflat/downloader/video_downloader.ex +++ b/lib/pinchflat/downloader/video_downloader.ex @@ -15,6 +15,8 @@ defmodule Pinchflat.Downloader.VideoDownloader do @doc """ Downloads a single video based on the settings in the given media profile. + + Returns {:ok, %ChannelDetails{}} | {:error, any, ...}. """ def download_for_media_profile(url, %MediaProfile{} = media_profile, backend \\ :yt_dlp) do option_builder = option_builder(backend) diff --git a/test/pinchflat/downloader/backends/yt_dlp/channel_test.exs b/test/pinchflat/downloader/backends/yt_dlp/channel_test.exs new file mode 100644 index 0000000..2380577 --- /dev/null +++ b/test/pinchflat/downloader/backends/yt_dlp/channel_test.exs @@ -0,0 +1,44 @@ +defmodule Pinchflat.Downloader.Backends.YtDlp.ChannelTest do + use ExUnit.Case, async: true + import Mox + + alias Pinchflat.Downloader.ChannelDetails + alias Pinchflat.Downloader.Backends.YtDlp.Channel + + @channel_url "https://www.youtube.com/c/TheUselessTrials" + + setup :verify_on_exit! + + describe "get_channel_info/1" do + test "it returns a %ChannelDetails{} with data on success" do + expect(CommandRunnerMock, :run, fn _url, _opts -> + {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} + end) + + assert {:ok, res} = Channel.get_channel_info(@channel_url) + assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = res + end + + test "it passes the expected args to the backend runner" do + expect(CommandRunnerMock, :run, fn @channel_url, opts -> + assert opts == [{:print, "%(.{channel,channel_id})j"}, {:playlist_end, 1}] + + {:ok, "{}"} + end) + + assert {:ok, _} = Channel.get_channel_info(@channel_url) + end + + test "it returns an error if the runner returns an error" do + expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) + + assert {:error, "Big issue", 1} = Channel.get_channel_info(@channel_url) + end + + test "it returns an error if the output is not JSON" do + expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "Not JSON"} end) + + assert {:error, %Jason.DecodeError{}} = Channel.get_channel_info(@channel_url) + end + end +end diff --git a/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs b/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs index b02ee97..3ae667a 100644 --- a/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs +++ b/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs @@ -6,13 +6,17 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do @channel_url "https://www.youtube.com/@TheUselessTrials" + defmodule VideoCollectionUser do + use VideoCollection + end + setup :verify_on_exit! describe "get_video_ids/2" do test "returns a list of video ids with no blank elements" do expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "id1\nid2\n\nid3\n"} end) - assert {:ok, ["id1", "id2", "id3"]} = VideoCollection.get_video_ids(@channel_url) + assert {:ok, ["id1", "id2", "id3"]} = VideoCollectionUser.get_video_ids(@channel_url) end test "it passes the expected default args" do @@ -22,7 +26,7 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do {:ok, ""} end) - assert {:ok, _} = VideoCollection.get_video_ids(@channel_url) + assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url) end test "it passes the expected custom args" do @@ -32,13 +36,13 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do {:ok, ""} end) - assert {:ok, _} = VideoCollection.get_video_ids(@channel_url, [:custom_arg]) + assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url, [:custom_arg]) end test "returns the error straight through when the command fails" do expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) - assert {:error, "Big issue", 1} = VideoCollection.get_video_ids(@channel_url) + assert {:error, "Big issue", 1} = VideoCollectionUser.get_video_ids(@channel_url) end end end diff --git a/test/pinchflat/downloader/backends/yt_dlp/video_test.exs b/test/pinchflat/downloader/backends/yt_dlp/video_test.exs index 42798fa..a71bfff 100644 --- a/test/pinchflat/downloader/backends/yt_dlp/video_test.exs +++ b/test/pinchflat/downloader/backends/yt_dlp/video_test.exs @@ -35,6 +35,12 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoTest do assert {:ok, %{"title" => "Test"}} = Video.download(@video_url) end + test "it returns an error if the output is not JSON" do + expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "Not JSON"} end) + + assert {:error, %Jason.DecodeError{}} = Video.download(@video_url) + end + test "it directly passes along any errors" do expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) diff --git a/test/pinchflat/downloader/channel_details_test.exs b/test/pinchflat/downloader/channel_details_test.exs new file mode 100644 index 0000000..ba56636 --- /dev/null +++ b/test/pinchflat/downloader/channel_details_test.exs @@ -0,0 +1,38 @@ +defmodule Pinchflat.Downloader.ChannelDetailsTest do + use ExUnit.Case, async: true + import Mox + + alias Pinchflat.Downloader.ChannelDetails + + @channel_url "https://www.youtube.com/c/TheUselessTrials" + + setup :verify_on_exit! + + describe "new/2" do + test "it returns a struct with the given values" do + assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = + ChannelDetails.new("UCQH2", "TheUselessTrials") + end + end + + describe "get_channel_details/2" do + test "it passes the expected arguments to the backend" do + expect(CommandRunnerMock, :run, fn @channel_url, opts -> + assert opts == [{:print, "%(.{channel,channel_id})j"}, {:playlist_end, 1}] + + {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} + end) + + assert {:ok, _} = ChannelDetails.get_channel_details(@channel_url) + end + + test "it returns a struct composed of the returned data" do + expect(CommandRunnerMock, :run, fn _url, _opts -> + {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} + end) + + assert {:ok, res} = ChannelDetails.get_channel_details(@channel_url) + assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = res + end + end +end