Compare commits

...

7 Commits

Author SHA1 Message Date
Kieran Eglin 20547e60e4 Bump version 2024-10-25 11:42:16 -07:00
Kieran 0fd3106cb8 Removed root pw workaround (#437) 2024-10-25 11:17:16 -07:00
Kieran 108a141c65 Improve error messages when adding a non-valid source URL (#436) 2024-10-25 11:01:46 -07:00
Kieran 6a1b7b0160 [Enhancement] Misc. UI updates (#434)
* Format 'Scheduled At' in task table according to user's TZ

* Changed video preview layout to 1 column on all displays

* Made links in descriptions clickable

* Updated Copy JSON button to properly escape newline characters
2024-10-24 12:47:48 -07:00
Kieran 3c8d99196a [Bugfix] Fix off-by-1 error for retention date logic (#432)
* Added a sanity check test to the media context

* Improves logic for handing media item culling dates
2024-10-24 10:26:30 -07:00
Jonas Bergler cae86953a0 document that BASE_ROUTE_PATH requires the prefix to be stripped (#413) 2024-10-22 12:24:34 -07:00
FibreTTP f661747a0c Flip settings submenu chevron (#405) 2024-10-10 14:19:03 -07:00
19 changed files with 96 additions and 55 deletions
+1 -4
View File
@@ -131,9 +131,6 @@ You _must_ ensure the host directories you've mounted are writable by the user r
> [!IMPORTANT] > [!IMPORTANT]
> It's not recommended to run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media. > It's not recommended to run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media.
> [!TIP]
> If you need to run any command as root, you can run `su` from the container's shell as there is no password set for the root user.
### ADVANCED: Storing Pinchflat config directory on a network share ### ADVANCED: Storing Pinchflat config directory on a network share
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares. As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
@@ -155,7 +152,7 @@ If you change this setting and it works well for you, please leave a comment on
| `ENABLE_IPV6` | No | `false` | Setting to _any_ non-blank value will enable IPv6 | | `ENABLE_IPV6` | No | `false` | Setting to _any_ non-blank value will enable IPv6 |
| `JOURNAL_MODE` | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) | | `JOURNAL_MODE` | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) |
| `TZ_DATA_DIR` | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored | | `TZ_DATA_DIR` | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
| `BASE_ROUTE_PATH` | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies | | `BASE_ROUTE_PATH` | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies, but prefix must be stripped. |
| `YT_DLP_WORKER_CONCURRENCY` | No | `2` | The number of concurrent workers that use `yt-dlp` _per queue_. Set to 1 if you're getting IP limited, otherwise don't touch it | | `YT_DLP_WORKER_CONCURRENCY` | No | `2` | The number of concurrent workers that use `yt-dlp` _per queue_. Set to 1 if you're getting IP limited, otherwise don't touch it |
## EFF donations ## EFF donations
-11
View File
@@ -127,17 +127,6 @@ EXPOSE ${PORT}
# Only copy the final release from the build stage # Only copy the final release from the build stage
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./ COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
# NEVER do this if you're running in an environment where you don't trust the user
# (ie: most environments). This is only acceptable in a self-hosted environment.
# The user could just run the whole container as root and bypass this anyway so
# it's not a huge deal.
# This removes the root password to allow users to assume root if needed. This is
# preferrable to running the whole container as root so that the files/directories
# created by the app aren't owned by root and are therefore easier for other users
# and processes to interact with. If you want to just run the whole container as
# root, use --user 0:0 or something.
RUN passwd -d root
HEALTHCHECK --interval=120s --start-period=10s \ HEALTHCHECK --interval=120s --start-period=10s \
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1 CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
@@ -49,6 +49,8 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
end) end)
end end
# NOTE: Since this is a date and not a datetime, we can't add logic to have to-the-minute
# comparison like we can with retention periods. We can only compare to the day.
defp delete_media_items_from_before_cutoff do defp delete_media_items_from_before_cutoff do
deletable_media = deletable_media =
MediaQuery.new() MediaQuery.new()
+3 -3
View File
@@ -88,7 +88,7 @@ defmodule Pinchflat.Media.MediaQuery do
[mi, source], [mi, source],
fragment(""" fragment("""
IFNULL(retention_period_days, 0) > 0 AND IFNULL(retention_period_days, 0) > 0 AND
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at DATETIME(media_downloaded_at, '+' || retention_period_days || ' day') < DATETIME('now')
""") """)
) )
end end
@@ -100,8 +100,8 @@ defmodule Pinchflat.Media.MediaQuery do
# downloaded_at minus the redownload_delay_days is before the upload date # downloaded_at minus the redownload_delay_days is before the upload date
fragment(""" fragment("""
IFNULL(redownload_delay_days, 0) > 0 AND IFNULL(redownload_delay_days, 0) > 0 AND
DATETIME('now', '-' || redownload_delay_days || ' day') > uploaded_at AND DATE('now', '-' || redownload_delay_days || ' day') > DATE(uploaded_at) AND
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < uploaded_at DATE(media_downloaded_at, '-' || redownload_delay_days || ' day') < DATE(uploaded_at)
""") """)
) )
end end
+7 -1
View File
@@ -186,7 +186,13 @@ defmodule Pinchflat.Sources do
{:ok, source_details} -> {:ok, source_details} ->
add_source_details_by_collection_type(source, changeset, source_details) add_source_details_by_collection_type(source, changeset, source_details)
{:error, runner_error, _status_code} -> err ->
runner_error =
case err do
{:error, error_msg, _status_code} -> error_msg
{:error, error_msg} -> error_msg
end
Ecto.Changeset.add_error( Ecto.Changeset.add_error(
changeset, changeset,
:original_url, :original_url,
+1
View File
@@ -84,6 +84,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, format_source_details(parsed_json)} {:ok, format_source_details(parsed_json)}
else else
{:error, %Jason.DecodeError{}} -> {:error, "Error decoding JSON response"}
err -> err err -> err
end end
end end
@@ -62,17 +62,24 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
end end
@doc """ @doc """
Renders a block of text with each line broken into a separate span. Renders a block of text with each line broken into a separate span and links highlighted.
""" """
attr :text, :string, required: true attr :text, :string, required: true
def break_on_newline(assigns) do def render_description(assigns) do
broken_text = formatted_text =
assigns.text Regex.split(~r{https?://\S+}, assigns.text, include_captures: true)
|> String.split("\n", trim: false) |> Enum.map(fn
|> Enum.intersperse(Phoenix.HTML.Tag.tag(:span, class: "inline-block mt-2")) "http" <> _ = url ->
Phoenix.HTML.Tag.content_tag(:a, url, class: "text-blue-500 hover:text-blue-300", href: url, target: "_blank")
assigns = Map.put(assigns, :text, broken_text) text ->
text
|> String.split("\n", trim: false)
|> Enum.intersperse(Phoenix.HTML.Tag.tag(:span, class: "inline-block mt-2"))
end)
assigns = Map.put(assigns, :text, formatted_text)
~H""" ~H"""
<span><%= @text %></span> <span><%= @text %></span>
+1 -1
View File
@@ -64,7 +64,7 @@ defmodule PinchflatWeb.Layouts do
<.icon name={@icon} /> <%= @text %> <.icon name={@icon} /> <%= @text %>
</span> </span>
<span class="text-bodydark2"> <span class="text-bodydark2">
<.icon name="hero-chevron-up" x-bind:class="{ 'rotate-180': selected }" /> <.icon name="hero-chevron-down" x-bind:class="{ 'rotate-180': selected }" />
</span> </span>
</span> </span>
@@ -2,7 +2,7 @@
<:option> <:option>
<span x-data="{ copied: false }" x-on:click={~s" <span x-data="{ copied: false }" x-on:click={~s"
copyWithCallbacks( copyWithCallbacks(
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`, String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`,
() => copied = true, () => copied = true,
() => copied = false () => copied = false
) )
@@ -1,5 +1,5 @@
<%= if media_type(@media_item) == :video do %> <%= if media_type(@media_item) == :video do %>
<video controls class="max-h-96 w-full"> <video controls class="max-h-128 w-full">
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" /> <source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" />
Your browser does not support the video element. Your browser does not support the video element.
</video> </video>
@@ -26,11 +26,12 @@
<:tab title="Media" id="media"> <:tab title="Media" id="media">
<div class="flex flex-col gap-10 dark:text-white"> <div class="flex flex-col gap-10 dark:text-white">
<%= if media_file_exists?(@media_item) do %> <%= if media_file_exists?(@media_item) do %>
<section class="grid grid-cols-1 xl:grid-cols-2 xl:gap-6 mt-6"> <section class="grid grid-cols-1 xl:gap-6 mt-6">
<div> <div>
<.media_preview media_item={@media_item} /> <.media_preview media_item={@media_item} />
</div> </div>
<aside class="mt-4 xl:mt-0"> <aside class="mt-4">
<h2 class="text-xl mb-2"><%= @media_item.title %></h2>
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div> <div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
<div> <div>
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}> <span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
@@ -44,7 +45,7 @@
</span> </span>
</div> </div>
<div :if={@media_item.description} class="mt-4 text-bodydark"> <div :if={@media_item.description} class="mt-4 text-bodydark">
<.break_on_newline text={@media_item.description} /> <.render_description text={@media_item.description} />
</div> </div>
</aside> </aside>
</section> </section>
@@ -70,7 +71,7 @@
<%= task.job.state %> <%= task.job.state %>
</:col> </:col>
<:col :let={task} label="Scheduled At"> <:col :let={task} label="Scheduled At">
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %> <.datetime_in_zone datetime={task.job.scheduled_at} />
</:col> </:col>
</.table> </.table>
<% else %> <% else %>
@@ -2,7 +2,7 @@
<:option> <:option>
<span x-data="{ copied: false }" x-on:click={~s" <span x-data="{ copied: false }" x-on:click={~s"
copyWithCallbacks( copyWithCallbacks(
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`, String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`,
() => copied = true, () => copied = true,
() => copied = false () => copied = false
) )
@@ -17,7 +17,7 @@
<:option> <:option>
<span x-data="{ copied: false }" x-on:click={~s" <span x-data="{ copied: false }" x-on:click={~s"
copyWithCallbacks( copyWithCallbacks(
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`, String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`,
() => copied = true, () => copied = true,
() => copied = false () => copied = false
) )
@@ -67,7 +67,7 @@
<%= task.job.state %> <%= task.job.state %>
</:col> </:col>
<:col :let={task} label="Scheduled At"> <:col :let={task} label="Scheduled At">
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %> <.datetime_in_zone datetime={task.job.scheduled_at} />
</:col> </:col>
</.table> </.table>
<% else %> <% else %>
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2024.10.2", version: "2024.10.25",
elixir: "~> 1.17", elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@@ -46,6 +46,23 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
refute Repo.reload!(old_media_item).media_filepath refute Repo.reload!(old_media_item).media_filepath
end end
test "deletes media files that are on their retention date per the 24-h clock" do
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date(2)
just_over_two_days_ago = now_minus(2, :days) |> DateTime.add(-1, :minute)
just_under_two_days_ago = now_minus(2, :days) |> DateTime.add(1, :minute)
Media.update_media_item(old_media_item, %{media_downloaded_at: just_over_two_days_ago})
Media.update_media_item(new_media_item, %{media_downloaded_at: just_under_two_days_ago})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
refute File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
refute Repo.reload!(old_media_item).media_filepath
end
test "sets culled_at and prevent_download" do test "sets culled_at and prevent_download" do
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date() {_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
@@ -106,6 +123,25 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
refute Repo.reload!(old_media_item).media_filepath refute Repo.reload!(old_media_item).media_filepath
end end
# NOTE: Since this is a date and not a datetime, we can't add logic to have to-the-minute
# comparison like we can with retention periods. We can only compare to the day.
test "doesn't cull media from on or after the cutoff date" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(2)
Media.update_media_item(old_media_item, %{uploaded_at: now_minus(2, :days)})
Media.update_media_item(new_media_item, %{uploaded_at: now_minus(1, :day)})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
assert Repo.reload!(old_media_item).media_filepath
refute Repo.reload!(new_media_item).culled_at
refute Repo.reload!(old_media_item).culled_at
end
test "sets culled_at but not prevent_download" do test "sets culled_at but not prevent_download" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date() {_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date()
@@ -131,23 +167,6 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
refute Repo.reload!(old_media_item).culled_at refute Repo.reload!(old_media_item).culled_at
end end
test "doesn't cull media from on or after the cutoff date" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(2)
Media.update_media_item(old_media_item, %{uploaded_at: now_minus(2, :days)})
Media.update_media_item(new_media_item, %{uploaded_at: now_minus(1, :day)})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
assert Repo.reload!(old_media_item).media_filepath
refute Repo.reload!(new_media_item).culled_at
refute Repo.reload!(old_media_item).culled_at
end
test "doesn't cull media items that have prevent_culling set" do test "doesn't cull media items that have prevent_culling set" do
{_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date() {_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date()
+7
View File
@@ -441,6 +441,13 @@ defmodule Pinchflat.MediaTest do
assert Media.pending_download?(media_item) assert Media.pending_download?(media_item)
end end
test "returns true if the cutoff date is equal to the upload date" do
source = source_fixture(%{download_cutoff_date: now_minus(2, :days)})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
assert Media.pending_download?(media_item)
end
test "returns false if there is a cutoff date after the media's upload date" do test "returns false if there is a cutoff date after the media's upload date" do
source = source_fixture(%{download_cutoff_date: now_minus(1, :day)}) source = source_fixture(%{download_cutoff_date: now_minus(1, :day)})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)}) media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
+12
View File
@@ -147,6 +147,18 @@ defmodule Pinchflat.SourcesTest do
assert "could not fetch source details from URL" in errors_on(changeset).original_url assert "could not fetch source details from URL" in errors_on(changeset).original_url
end end
test "adds an error if the runner succeeds but the result was invalid JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, "Not JSON"} end)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:error, %Ecto.Changeset{} = changeset} = Sources.create_source(valid_attrs)
assert "could not fetch source details from URL" in errors_on(changeset).original_url
end
test "you can specify a custom custom_name" do test "you can specify a custom custom_name" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/4)
@@ -133,7 +133,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
test "returns an error if the output is not JSON" do test "returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_details(@channel_url) assert {:error, "Error decoding JSON response"} = MediaCollection.get_source_details(@channel_url)
end end
end end