Compare commits

...

6 Commits

Author SHA1 Message Date
Kieran Eglin a7b0af65e2 Bumped version 2024-05-15 13:24:18 -07:00
Kieran dcf033f10a Added support for ytdlp cache (#245) 2024-05-15 13:17:11 -07:00
Kieran 8d9bd11882 [Housekeeping] Improved logging for all executable calls (#244)
* Improved logging for all CLI calls

* CAptured more details in logging
2024-05-15 12:56:56 -07:00
Kieran bdcb49185a [Enhancement] Adds in-app diagnostics page (#243)
* Added improved sidebar menuing

* Added new view for getting diagnostic data

* Changed default log level to debug

* Disabled false-positive static analysis
2024-05-15 12:27:57 -07:00
Kieran Eglin 1f1cd1cb63 Removed exclaimation mark from upgrade modal text 2024-05-15 12:24:24 -07:00
Kieran d575548226 Bumped RSS feed limit; fixed ordering (#241) 2024-05-15 10:15:00 -07:00
24 changed files with 252 additions and 40 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"Config.HTTPS",
"Config.CSP",
"XSS.ContentType",
"Traversal.SendFile"
"Traversal.SendFile",
"Traversal.SendDownload"
],
ignore_files: [],
version: false
+3 -2
View File
@@ -53,7 +53,7 @@ if config_env() == :prod do
# For testing alternate journal modes (see issue #137)
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"),
@@ -64,7 +64,8 @@ if config_env() == :prod do
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
expose_feed_endpoints: expose_feed_endpoints,
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC"
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC",
log_path: log_path
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
@@ -29,7 +29,6 @@ defmodule Pinchflat.Lifecycle.Notifications.CommandRunner do
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
{output, exit_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
Logger.info("[apprise] response: #{output}")
case exit_code do
0 -> {:ok, String.trim(output)}
@@ -39,7 +39,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
{:ok, executable_path} ->
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
{output, exit_code} =
{_output, _exit_code} =
CliUtils.wrap_cmd(
executable_path,
[to_string(event_type), encoded_data],
@@ -47,7 +47,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
logging_arg_override: "[suppressed]"
)
handle_output(output, exit_code)
:ok
end
end
@@ -55,12 +55,6 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
raise ArgumentError, "Invalid event type: #{inspect(event_type)}"
end
defp handle_output(output, exit_code) do
Logger.debug("Custom lifecycle script exit code: #{exit_code} with output: #{output}")
:ok
end
defp backend_executable do
base_dir = Application.get_env(:pinchflat, :extras_directory)
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
+2 -1
View File
@@ -24,11 +24,12 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
Returns: [%MediaItem{}]
"""
def persisted_media_items_for(source, opts \\ []) do
limit = Keyword.get(opts, :limit, 500)
limit = Keyword.get(opts, :limit, 1_000)
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> order_by(desc: :upload_date)
|> Repo.maybe_limit(limit)
|> Repo.all()
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
+2 -2
View File
@@ -16,12 +16,12 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
Only MediaItems that have been persisted will be included in the feed.
## Options:
- `:limit` - The maximum number of media items to include in the feed. Defaults to 300.
- `:limit` - The maximum number of media items to include in the feed. Defaults to 2,000.
Returns an XML document as a string.
"""
def build(source, opts \\ []) do
limit = Keyword.get(opts, :limit, 300)
limit = Keyword.get(opts, :limit, 2_000)
url_base = Keyword.get(opts, :url_base, PinchflatWeb.Endpoint.url())
media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit)
+11 -1
View File
@@ -28,7 +28,10 @@ defmodule Pinchflat.Utils.CliUtils do
Logger.info("[command_wrapper]: #{command} called with: #{logging_arg_override}")
System.cmd(wrapper_command, actual_command, passthrough_opts)
{output, status} = System.cmd(wrapper_command, actual_command, passthrough_opts)
log_cmd_result(command, logging_arg_override, status, output)
{output, status}
end
@doc """
@@ -71,4 +74,11 @@ defmodule Pinchflat.Utils.CliUtils do
defp parse_option(arg, acc) when is_binary(arg) do
acc ++ [arg]
end
defp log_cmd_result(command, logging_arg_override, status, output) do
log_message = "[command_wrapper]: #{command} called with: #{logging_arg_override} exited: #{status} with: #{output}"
log_level = if status == 0, do: :debug, else: :error
Logger.log(log_level, log_message)
end
end
+7 -3
View File
@@ -28,9 +28,9 @@ defmodule Pinchflat.YtDlp.CommandRunner do
output_filepath = generate_output_filepath(addl_opts)
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
user_configured_opts = cookie_file_options() ++ global_options()
user_configured_opts = cookie_file_options()
# These must stay in exactly this order, hence why I'm giving it its own variable.
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do
@@ -71,7 +71,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end
defp global_options do
[:windows_filenames]
[
:windows_filenames,
:quiet,
cache_dir: Path.join(Application.get_env(:pinchflat, :tmpfile_directory), "yt-dlp-cache")
]
end
defp cookie_file_options do
+1 -1
View File
@@ -24,7 +24,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
# the first video has not released yet (ie: is a premier). We don't care about
# available formats since we're just getting the media details
command_opts = [:simulate, :skip_download, :ignore_no_formats_error]
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings]
output_template = YtDlpMedia.indexing_output_template()
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
+85 -6
View File
@@ -4,28 +4,107 @@ defmodule PinchflatWeb.Layouts do
embed_templates "layouts/*"
embed_templates "layouts/partials/*"
@doc """
Renders a sidebar menu item link
## Examples
<.sidebar_link icon="hero-home" text="Home" href="/" />
"""
attr :icon, :string, required: true
attr :text, :string, required: true
attr :href, :any, required: true
attr :target, :any, default: "_self"
def sidebar_item(assigns) do
# I'm testing out grouping classes here. Tentative order: font, layout, color, animation, state-modifiers
~H"""
<li>
<li class="text-bodydark1">
<.sidebar_link icon={@icon} text={@text} href={@href} target={@target} />
</li>
"""
end
@doc """
Renders a sidebar menu item with a submenu
## Examples
<.sidebar_submenu icon="hero-home" text="Home" current_path="/">
<:submenu icon="hero-home" text="Home" href="/" />
</.sidebar_submenu>
"""
attr :icon, :string, required: true
attr :text, :string, required: true
attr :current_path, :string, required: true
slot :submenu do
attr :icon, :string
attr :text, :string
attr :href, :any
attr :target, :any
end
def sidebar_submenu(assigns) do
initially_selected = Enum.any?(assigns[:submenu], &(&1[:href] == assigns[:current_path]))
assigns = Map.put(assigns, :initially_selected, initially_selected)
~H"""
<li class="text-bodydark1" x-data={"{ selected: #{@initially_selected} }"}>
<span
class={[
"font-medium cursor-pointer",
"group relative flex items-center justify-between rounded-sm px-4 py-2 duration-300 ease-in-out",
"duration-300 ease-in-out",
"hover:bg-meta-4"
]}
x-on:click="selected = !selected"
>
<span class="flex items-center gap-2.5">
<.icon name={@icon} /> <%= @text %>
</span>
<span class="text-bodydark2">
<.icon name="hero-chevron-up" x-bind:class="{ 'rotate-180': selected }" />
</span>
</span>
<ul x-bind:class="selected ? 'block' :'hidden'">
<li :for={menu <- @submenu} class="text-bodydark2">
<.sidebar_link icon={menu[:icon]} text={menu[:text]} href={menu[:href]} target={menu[:target]} class="pl-10" />
</li>
</ul>
</li>
"""
end
@doc """
Renders a sidebar menu item link
## Examples
<.sidebar_link icon="hero-home" text="Home" href="/" />
"""
attr :icon, :string
attr :text, :string, required: true
attr :href, :any, required: true
attr :target, :any, default: "_self"
attr :class, :string, default: ""
def sidebar_link(assigns) do
~H"""
<.link
href={@href}
target={@target}
class={[
"font-medium text-bodydark1",
"font-medium",
"group relative flex items-center gap-2.5 rounded-sm px-4 py-2 duration-300 ease-in-out",
"duration-300 ease-in-out",
"hover:bg-graydark dark:hover:bg-meta-4"
"hover:bg-meta-4",
@class
]}
>
<.icon name={@icon} /> <%= @text %>
<.icon :if={@icon} name={@icon} /> <%= @text %>
</.link>
</li>
"""
end
end
@@ -1,5 +1,5 @@
<div class="flex h-screen overflow-hidden">
<.sidebar />
<.sidebar conn={@conn} />
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<.header params={@conn.params} />
@@ -26,7 +26,14 @@
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
<.sidebar_item icon="hero-cog-6-tooth" text="Settings" href={~p"/settings"} />
<.sidebar_submenu
icon="hero-cog-6-tooth"
text="Config"
current_path={Phoenix.Controller.current_path(@conn)}
>
<:submenu text="Settings" href={~p"/settings"} />
<:submenu text="App Info" href={~p"/app_info"} />
</.sidebar_submenu>
</ul>
</div>
</nav>
@@ -21,7 +21,7 @@
<p class="mt-4">
To unlock Pro, simply type
<.inline_code>got it!</.inline_code>
<.inline_code>got it</.inline_code>
into the text box and press the button.
</p>
@@ -11,7 +11,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
def rss_feed(conn, %{"uuid" => uuid}) do
source = Repo.get_by!(Source, uuid: uuid)
url_base = url(conn, ~p"/")
xml = RssFeedBuilder.build(source, limit: 300, url_base: url_base)
xml = RssFeedBuilder.build(source, limit: 2_000, url_base: url_base)
conn
|> put_resp_content_type("application/rss+xml")
@@ -23,4 +23,20 @@ defmodule PinchflatWeb.Settings.SettingController do
render(conn, "show.html", changeset: changeset)
end
end
def app_info(conn, _params) do
render(conn, "app_info.html")
end
def download_logs(conn, _params) do
log_path = Application.get_env(:pinchflat, :log_path)
if log_path && File.exists?(log_path) do
send_download(conn, {:file, log_path}, filename: "pinchflat-logs-#{Date.utc_today()}.txt")
else
conn
|> put_flash(:error, "Log file couldn't be found")
|> redirect(to: ~p"/app_info")
end
end
end
@@ -18,4 +18,14 @@ defmodule PinchflatWeb.Settings.SettingHTML do
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{classes}" target="_blank">Apprise docs</a> for more information)
end
def diagnostic_info_string do
"""
App Version: #{Application.spec(:pinchflat)[:vsn]}
yt-dlp Version: #{Settings.get!(:yt_dlp_version)}
Apprise Version: #{Settings.get!(:apprise_version)}
System Architecture: #{to_string(:erlang.system_info(:system_architecture))}
Timezone: #{Application.get_env(:pinchflat, :timezone)}
"""
end
end
@@ -0,0 +1,26 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex gap-3 items-center">
<h2 class="text-title-md2 font-bold text-white ml-4">
App Info
</h2>
</div>
</div>
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="max-w-full">
<.button color="bg-primary" rounding="rounded-lg" x-data="{ copied: false }" x-on:click={~s"
copyWithCallbacks(
`#{diagnostic_info_string()}`,
() => copied = true,
() => copied = false
)
"}>
Copy Diagnotic Info
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</.button>
<.link href={~p"/download_logs"}>
<.button color="bg-primary" rounding="rounded-lg" class="ml-4">
Download Logs
</.button>
</.link>
</div>
</div>
+3
View File
@@ -30,7 +30,10 @@ defmodule PinchflatWeb.Router do
resources "/media_profiles", MediaProfiles.MediaProfileController
resources "/search", Searches.SearchController, only: [:show], singleton: true
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
get "/app_info", Settings.SettingController, :app_info
get "/download_logs", Settings.SettingController, :download_logs
resources "/sources", Sources.SourceController do
post "/force_download_pending", Sources.SourceController, :force_download_pending
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "2024.5.14",
version: "2024.5.15",
elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
@@ -0,0 +1,8 @@
defmodule Pinchflat.Repo.Migrations.AddDateIndexesToMediaItems do
use Ecto.Migration
def change do
create(index(:media_items, [:media_downloaded_at]))
create(index(:media_items, [:media_redownloaded_at]))
end
end
@@ -22,6 +22,16 @@ defmodule Pinchflat.Podcasts.PodcastHelpersTest do
assert [] = PodcastHelpers.persisted_media_items_for(source, limit: 0)
end
test "orders by upload date where newest is first" do
source = source_fixture()
oldest = media_item_with_attachments(%{source_id: source.id, upload_date: now_minus(2, :day)})
current = media_item_with_attachments(%{source_id: source.id, upload_date: now()})
older = media_item_with_attachments(%{source_id: source.id, upload_date: now_minus(1, :days)})
assert [^current, ^older, ^oldest] = PodcastHelpers.persisted_media_items_for(source)
end
end
describe "select_cover_image/2" do
@@ -87,6 +87,18 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
assert String.contains?(output, "--windows-filenames")
end
test "runs quietly" do
assert {:ok, output} = Runner.run(@media_url, [], "")
assert String.contains?(output, "--quiet")
end
test "sets the cache directory" do
assert {:ok, output} = Runner.run(@media_url, [], "")
assert String.contains?(output, "--cache-dir /tmp/test/tmpfiles/yt-dlp-cache")
end
end
describe "version/0" do
@@ -20,7 +20,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
test "it passes the expected default args" do
expect(YtDlpRunnerMock, :run, fn _url, opts, ot, _addl_opts ->
assert opts == [:simulate, :skip_download, :ignore_no_formats_error]
assert opts == [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings]
assert ot == Media.indexing_output_template()
{:ok, ""}
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.SettingControllerTest do
use PinchflatWeb.ConnCase
alias Pinchflat.Utils.FilesystemUtils
describe "show settings" do
test "renders the page", %{conn: conn} do
conn = get(conn, ~p"/settings")
@@ -20,4 +22,33 @@ defmodule PinchflatWeb.SettingControllerTest do
assert html_response(conn, 200) =~ update_attrs[:apprise_server]
end
end
describe "app_info" do
test "renders the page", %{conn: conn} do
conn = get(conn, ~p"/app_info")
assert html_response(conn, 200) =~ "App Info"
end
end
describe "download_logs" do
test "downloads logs", %{conn: conn} do
log_path = Path.join([System.tmp_dir!(), "pinchflat", "data", "pinchflat.log"])
FilesystemUtils.write_p(log_path, "test log data")
Application.put_env(:pinchflat, :log_path, log_path)
conn = get(conn, ~p"/download_logs")
assert response(conn, 200) =~ "test log data"
Application.put_env(:pinchflat, :log_path, nil)
end
test "redirects when log file is not found", %{conn: conn} do
conn = get(conn, ~p"/download_logs")
assert redirected_to(conn) == ~p"/app_info"
assert conn.assigns[:flash]["error"] == "Log file couldn't be found"
end
end
end