Finished first draft of methods to do with querying channels
This commit is contained in:
parent
cdd708f813
commit
94ca11001b
11 changed files with 171 additions and 36 deletions
2
ideas.md
2
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
|
- 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
|
- Look into this and its recommended plugins https://hexdocs.pm/ex_check/readme.html
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,26 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.Channel do
|
||||||
Contains utilities for working with a channel's videos
|
Contains utilities for working with a channel's videos
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# TODO: convert to `use`
|
use Pinchflat.Downloader.Backends.YtDlp.VideoCollection
|
||||||
import Pinchflat.Downloader.Backends.YtDlp.VideoCollection
|
alias Pinchflat.Downloader.ChannelDetails
|
||||||
alias __MODULE__
|
|
||||||
|
|
||||||
defstruct [:id, :name]
|
@doc """
|
||||||
|
Gets a channel's ID and name from its URL.
|
||||||
|
|
||||||
def new(id, name) do
|
yt-dlp does not _really_ have channel-specific functions, so
|
||||||
%__MODULE__{id: id, name: name}
|
instead we're fetching just the first video (using playlist_end: 1)
|
||||||
end
|
and parsing the channel ID and name from _its_ metadata
|
||||||
|
|
||||||
|
Returns {:ok, %ChannelDetails{}} | {:error, any, ...}.
|
||||||
|
"""
|
||||||
def get_channel_info(channel_url) do
|
def get_channel_info(channel_url) do
|
||||||
opts = [print: "%(.{channel,channel_id})j", playlist_end: 1]
|
opts = [print: "%(.{channel,channel_id})j", playlist_end: 1]
|
||||||
|
|
||||||
case backend_runner().run(channel_url, opts) do
|
with {:ok, output} <- backend_runner().run(channel_url, opts),
|
||||||
{:ok, output} ->
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
result = Phoenix.json_library().decode!(output)
|
{:ok, ChannelDetails.new(parsed_json["channel_id"], parsed_json["channel"])}
|
||||||
|
else
|
||||||
{:ok, Channel.new(result["channel_id"], result["channel"])}
|
err -> err
|
||||||
|
|
||||||
res ->
|
|
||||||
res
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunner do
|
||||||
@doc """
|
@doc """
|
||||||
Runs a yt-dlp command and returns the string output
|
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
|
# IDEA: deduplicate command opts, keeping the last one on conflict
|
||||||
although possibly not needed (and a LOT easier) if yt-dlp
|
although possibly not needed (and a LOT easier) if yt-dlp
|
||||||
just ignores duplicate options (ie: look into that)
|
just ignores duplicate options (ie: look into that)
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,16 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.Video do
|
||||||
@doc """
|
@doc """
|
||||||
Downloads a single video (and possible metadata) directly to its
|
Downloads a single video (and possible metadata) directly to its
|
||||||
final destination. Returns the parsed JSON output from yt-dlp.
|
final destination. Returns the parsed JSON output from yt-dlp.
|
||||||
|
|
||||||
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def download(url, command_opts \\ []) do
|
def download(url, command_opts \\ []) do
|
||||||
opts = [:no_simulate, print: "%()j"] ++ command_opts
|
opts = [:no_simulate, print: "%()j"] ++ command_opts
|
||||||
|
|
||||||
case backend_runner().run(url, opts) do
|
with {:ok, output} <- backend_runner().run(url, opts),
|
||||||
# TODO: test that I changed this to a ! method
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
{:ok, output} -> {:ok, Phoenix.json_library().decode!(output)}
|
{:ok, parsed_json}
|
||||||
|
else
|
||||||
err -> err
|
err -> err
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,27 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollection do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Contains utilities for working with collections of videos (ie: channels, playlists).
|
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 """
|
defmacro __using__(_) do
|
||||||
Returns a list of strings representing the video ids in the collection
|
quote do
|
||||||
"""
|
@doc """
|
||||||
def get_video_ids(url, command_opts \\ []) do
|
Returns a list of strings representing the video ids in the collection.
|
||||||
opts = command_opts ++ [:simulate, :skip_download, print: :id]
|
|
||||||
|
|
||||||
case backend_runner().run(url, opts) do
|
Returns {:ok, [binary()]} | {:error, any, ...}.
|
||||||
{:ok, output} -> {:ok, String.split(output, "\n", trim: true)}
|
"""
|
||||||
res -> res
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
defp backend_runner do
|
|
||||||
Application.get_env(:pinchflat, :yt_dlp_runner)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
32
lib/pinchflat/downloader/channel_details.ex
Normal file
32
lib/pinchflat/downloader/channel_details.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -15,6 +15,8 @@ defmodule Pinchflat.Downloader.VideoDownloader do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Downloads a single video based on the settings in the given media profile.
|
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
|
def download_for_media_profile(url, %MediaProfile{} = media_profile, backend \\ :yt_dlp) do
|
||||||
option_builder = option_builder(backend)
|
option_builder = option_builder(backend)
|
||||||
|
|
|
||||||
44
test/pinchflat/downloader/backends/yt_dlp/channel_test.exs
Normal file
44
test/pinchflat/downloader/backends/yt_dlp/channel_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -6,13 +6,17 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do
|
||||||
|
|
||||||
@channel_url "https://www.youtube.com/@TheUselessTrials"
|
@channel_url "https://www.youtube.com/@TheUselessTrials"
|
||||||
|
|
||||||
|
defmodule VideoCollectionUser do
|
||||||
|
use VideoCollection
|
||||||
|
end
|
||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
||||||
describe "get_video_ids/2" do
|
describe "get_video_ids/2" do
|
||||||
test "returns a list of video ids with no blank elements" 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)
|
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
|
end
|
||||||
|
|
||||||
test "it passes the expected default args" do
|
test "it passes the expected default args" do
|
||||||
|
|
@ -22,7 +26,7 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do
|
||||||
{:ok, ""}
|
{:ok, ""}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _} = VideoCollection.get_video_ids(@channel_url)
|
assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it passes the expected custom args" do
|
test "it passes the expected custom args" do
|
||||||
|
|
@ -32,13 +36,13 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do
|
||||||
{:ok, ""}
|
{:ok, ""}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _} = VideoCollection.get_video_ids(@channel_url, [:custom_arg])
|
assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url, [:custom_arg])
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns the error straight through when the command fails" do
|
test "returns the error straight through when the command fails" do
|
||||||
expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end)
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoTest do
|
||||||
assert {:ok, %{"title" => "Test"}} = Video.download(@video_url)
|
assert {:ok, %{"title" => "Test"}} = Video.download(@video_url)
|
||||||
end
|
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
|
test "it directly passes along any errors" do
|
||||||
expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end)
|
expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end)
|
||||||
|
|
||||||
|
|
|
||||||
38
test/pinchflat/downloader/channel_details_test.exs
Normal file
38
test/pinchflat/downloader/channel_details_test.exs
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue