diff --git a/lib/pinchflat/media.ex b/lib/pinchflat/media.ex
index e9fb633..6f93d1b 100644
--- a/lib/pinchflat/media.ex
+++ b/lib/pinchflat/media.ex
@@ -41,12 +41,14 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...].
"""
- def list_pending_media_items_for(%Source{} = source) do
+ def list_pending_media_items_for(%Source{} = source, opts \\ []) do
+ limit = Keyword.get(opts, :limit, nil)
media_profile = Repo.preload(source, :media_profile).media_profile
MediaItem
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_profile))
+ |> Repo.maybe_limit(limit)
|> Repo.all()
end
@@ -55,9 +57,12 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...].
"""
- def list_downloaded_media_items_for(%Source{} = source) do
+ def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
+ limit = Keyword.get(opts, :limit, nil)
+
MediaItem
|> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath))
+ |> Repo.maybe_limit(limit)
|> Repo.all()
end
diff --git a/lib/pinchflat/media_source/source.ex b/lib/pinchflat/media_source/source.ex
index 1c94d64..ed77c7b 100644
--- a/lib/pinchflat/media_source/source.ex
+++ b/lib/pinchflat/media_source/source.ex
@@ -7,6 +7,7 @@ defmodule Pinchflat.Sources.Source do
import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils
+ alias Pinchflat.Tasks.Task
alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile
@@ -47,6 +48,7 @@ defmodule Pinchflat.Sources.Source do
belongs_to :media_profile, MediaProfile
+ has_many :tasks, Task
has_many :media_items, MediaItem, foreign_key: :source_id
timestamps(type: :utc_datetime)
diff --git a/lib/pinchflat/repo.ex b/lib/pinchflat/repo.ex
index 1ade75a..a4d3b85 100644
--- a/lib/pinchflat/repo.ex
+++ b/lib/pinchflat/repo.ex
@@ -3,6 +3,8 @@ defmodule Pinchflat.Repo do
otp_app: :pinchflat,
adapter: Ecto.Adapters.SQLite3
+ import Ecto.Query, warn: false
+
@doc """
It's not immediately obvious if an Oban job qualifies as unique, so this method
attempts creating a job and checks for the `conflict?` field in the returned job.
@@ -16,4 +18,13 @@ defmodule Pinchflat.Repo do
err -> err
end
end
+
+ @doc """
+ Applies a limit to a query if provided, otherwise returns the query as-is.
+
+ Returns %Ecto.Query{}.
+ """
+ def maybe_limit(query, limit) do
+ if limit, do: limit(query, ^limit), else: query
+ end
end
diff --git a/lib/pinchflat_web.ex b/lib/pinchflat_web.ex
index e0a512d..6f301ff 100644
--- a/lib/pinchflat_web.ex
+++ b/lib/pinchflat_web.ex
@@ -88,6 +88,7 @@ defmodule PinchflatWeb do
# Core UI components and translation
import PinchflatWeb.Gettext
import PinchflatWeb.CoreComponents
+ import PinchflatWeb.CustomComponents.TabComponents
import PinchflatWeb.CustomComponents.TextComponents
import PinchflatWeb.CustomComponents.TableComponents
import PinchflatWeb.CustomComponents.ButtonComponents
diff --git a/lib/pinchflat_web/components/core_components.ex b/lib/pinchflat_web/components/core_components.ex
index f9477df..09d842c 100644
--- a/lib/pinchflat_web/components/core_components.ex
+++ b/lib/pinchflat_web/components/core_components.ex
@@ -587,6 +587,7 @@ defmodule PinchflatWeb.CoreComponents do
attrs =
Enum.filter(assigns.map, fn
{_, %{__struct__: _}} -> false
+ {_, [%{__meta__: _} | _]} -> false
_ -> true
end)
diff --git a/lib/pinchflat_web/components/custom_components/tab_components.ex b/lib/pinchflat_web/components/custom_components/tab_components.ex
new file mode 100644
index 0000000..1b0ae22
--- /dev/null
+++ b/lib/pinchflat_web/components/custom_components/tab_components.ex
@@ -0,0 +1,37 @@
+defmodule PinchflatWeb.CustomComponents.TabComponents do
+ @moduledoc false
+ use Phoenix.Component
+
+ @doc """
+ Takes a list of tabs and renders them in a tabbed layout.
+ """
+ slot :tab, required: true do
+ attr :title, :string, required: true
+ end
+
+ def tabbed_layout(assigns) do
+ ~H"""
+
+
+
+
+ <%= render_slot(tab) %>
+
+
+
+ """
+ end
+end
diff --git a/lib/pinchflat_web/components/custom_components/text_components.ex b/lib/pinchflat_web/components/custom_components/text_components.ex
index e362110..6ba9771 100644
--- a/lib/pinchflat_web/components/custom_components/text_components.ex
+++ b/lib/pinchflat_web/components/custom_components/text_components.ex
@@ -23,7 +23,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
attr :href, :string, required: true
slot :inner_block
- def reference_link(assigns) do
+ def inline_link(assigns) do
~H"""
<.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300">
<%= render_slot(@inner_block) %>
diff --git a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
index 6d705b5..95847dd 100644
--- a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
+++ b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
@@ -1,10 +1,14 @@
defmodule PinchflatWeb.MediaItems.MediaItemController do
use PinchflatWeb, :controller
+ alias Pinchflat.Repo
alias Pinchflat.Media
def show(conn, %{"id" => id}) do
- media_item = Media.get_media_item!(id)
+ media_item =
+ id
+ |> Media.get_media_item!()
+ |> Repo.preload([:source, tasks: [:job]])
render(conn, :show, media_item: media_item)
end
diff --git a/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex b/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex
index 9c5dce2..95da677 100644
--- a/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex
+++ b/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex
@@ -8,23 +8,51 @@
-
+
-
-
Attributes
- <.list_items_from_map map={Map.from_struct(@media_item)} />
-
+ <.tabbed_layout>
+ <:tab title="Attributes">
+
+
Attributes
+
+ Source:
+ <.inline_link href={~p"/sources/#{@media_item.source_id}"}>
+ <%= @media_item.source.friendly_name %>
+
+
-
- <.link
- href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
- method="delete"
- data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
- >
- <.button color="bg-meta-1" rounding="rounded-full">
- Delete Files
-
-
-
+ <.list_items_from_map map={Map.from_struct(@media_item)} />
+
+
+
+ <.link
+ href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
+ method="delete"
+ data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
+ >
+ <.button color="bg-meta-1" rounding="rounded-full">
+ Delete Files
+
+
+
+
+ <:tab title="Tasks">
+ <%= if match?([_|_], @media_item.tasks) do %>
+ <.table rows={@media_item.tasks} table_class="text-black dark:text-white">
+ <:col :let={task} label="Worker">
+ <%= task.job.worker %>
+
+ <:col :let={task} label="State">
+ <%= task.job.state %>
+
+ <:col :let={task} label="Scheduled At">
+ <%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
+
+
+ <% else %>
+
Nothing Here!
+ <% end %>
+
+
diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_controller.ex b/lib/pinchflat_web/controllers/media_profiles/media_profile_controller.ex
index 246f6e3..f0d78d9 100644
--- a/lib/pinchflat_web/controllers/media_profiles/media_profile_controller.ex
+++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_controller.ex
@@ -1,6 +1,7 @@
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
use PinchflatWeb, :controller
+ alias Pinchflat.Repo
alias Pinchflat.Profiles
alias Pinchflat.Profiles.MediaProfile
@@ -39,7 +40,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
end
def show(conn, %{"id" => id}) do
- media_profile = Profiles.get_media_profile!(id)
+ media_profile =
+ id
+ |> Profiles.get_media_profile!()
+ |> Repo.preload(:sources)
+
render(conn, :show, media_profile: media_profile)
end
diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/output_template_help.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/output_template_help.html.heex
index c98fb21..7b11b1d 100644
--- a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/output_template_help.html.heex
+++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/output_template_help.html.heex
@@ -10,9 +10,9 @@
yt-dlp-style
- <.reference_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template">
+ <.inline_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template">
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" />
- :
+ :
<.inline_code>/%(channel)s/%(duration>%H-%M-%S)s-%(id)s.%(ext)s
@@ -50,9 +50,9 @@
Any single-word yt-dlp
option
- <.reference_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template">
+ <.inline_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template">
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" />
-
+
can be used with the curly braced liquid-style syntax.
This is just a list of the most common options as well as some custom aliases
diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex
index a7833b1..7cdb2df 100644
--- a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex
+++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex
@@ -18,31 +18,50 @@
-
-
Attributes for "<%= @media_profile.name %>"
- <.list_items_from_map map={Map.from_struct(@media_profile)} />
-
+ <.tabbed_layout>
+ <:tab title="Attributes">
+
+
Attributes for "<%= @media_profile.name %>"
+ <.list_items_from_map map={Map.from_struct(@media_profile)} />
+
-
- <.link
- href={~p"/media_profiles/#{@media_profile}"}
- method="delete"
- data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
- >
- <.button color="bg-meta-1" rounding="rounded-full">
- Delete Profile and its Sources
-
-
- <.link
- href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
- method="delete"
- data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
- class="mt-5 md:mt-0"
- >
- <.button color="bg-meta-1" rounding="rounded-full">
- Delete Profile, Sources, and Files
-
-
-
+
+ <.link
+ href={~p"/media_profiles/#{@media_profile}"}
+ method="delete"
+ data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
+ >
+ <.button color="bg-meta-1" rounding="rounded-full">
+ Delete Profile and its Sources
+
+
+ <.link
+ href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
+ method="delete"
+ data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
+ class="mt-5 md:mt-0"
+ >
+ <.button color="bg-meta-1" rounding="rounded-full">
+ Delete Profile, Sources, and Files
+
+
+
+
+ <:tab title="Sources">
+ <.table rows={@media_profile.sources} table_class="text-black dark:text-white">
+ <:col :let={source} label="Name">
+ <%= source.friendly_name || source.collection_name %>
+
+ <:col :let={source} label="Type"><%= source.collection_type %>
+ <:col :let={source} label="Should Download?">
+ <.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
+
+ <:col :let={source} label="" class="flex place-content-evenly">
+ <.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
+ <.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
+
+
+
+
diff --git a/lib/pinchflat_web/controllers/sources/source_controller.ex b/lib/pinchflat_web/controllers/sources/source_controller.ex
index 5ecfbc7..f2cb342 100644
--- a/lib/pinchflat_web/controllers/sources/source_controller.ex
+++ b/lib/pinchflat_web/controllers/sources/source_controller.ex
@@ -54,10 +54,10 @@ defmodule PinchflatWeb.Sources.SourceController do
source =
id
|> Sources.get_source!()
- |> Repo.preload(:media_profile)
+ |> Repo.preload([:media_profile, tasks: [:job]])
- pending_media = Media.list_pending_media_items_for(source)
- downloaded_media = Media.list_downloaded_media_items_for(source)
+ pending_media = Media.list_pending_media_items_for(source, limit: 100)
+ downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100)
render(conn, :show, source: source, pending_media: pending_media, downloaded_media: downloaded_media)
end
diff --git a/lib/pinchflat_web/controllers/sources/source_html/show.html.heex b/lib/pinchflat_web/controllers/sources/source_html/show.html.heex
index b5fa0e3..d8c69d2 100644
--- a/lib/pinchflat_web/controllers/sources/source_html/show.html.heex
+++ b/lib/pinchflat_web/controllers/sources/source_html/show.html.heex
@@ -18,83 +18,89 @@
-
-
Attributes for "<%= @source.friendly_name %>"
+ <.tabbed_layout>
+ <:tab title="Attributes">
+
+
Attributes
+
+ Media Profile:
+ <.inline_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
+ <%= @source.media_profile.name %>
+
+
- Relationships
- <.list>
- <:item title="media_profile">
+ <.list_items_from_map map={Map.from_struct(@source)} />
+
+
+
<.link
- navigate={~p"/media_profiles/#{@source.media_profile_id}"}
- class="hover:text-secondary duration-200 ease-in-out"
+ href={~p"/sources/#{@source}"}
+ method="delete"
+ data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
- <%= @source.media_profile.name %>
+ <.button color="bg-meta-1" rounding="rounded-full">
+ Delete Source
+
-
-
-
- Attributes
- <.list_items_from_map map={Map.from_struct(@source)} />
-
- Downloaded Media
- <%= if match?([_|_], @downloaded_media) do %>
- <.table rows={@downloaded_media} table_class="text-black dark:text-white">
- <:col :let={media_item} label="Title">
- <%= StringUtils.truncate(media_item.title, 50) %>
-
- <:col :let={media_item} label="" class="flex place-content-evenly">
- <.link
- navigate={~p"/sources/#{@source.id}/media/#{media_item.id}"}
- class="hover:text-secondary duration-200 ease-in-out mx-0.5"
- >
- <.icon name="hero-eye" />
-
-
-
- <% else %>
- Nothing Here!
- <% end %>
-
- Pending Media
- <%= if match?([_|_], @pending_media) do %>
- <.table rows={@pending_media} table_class="text-black dark:text-white">
- <:col :let={media_item} label="Title">
- <%= StringUtils.truncate(media_item.title, 50) %>
-
- <:col :let={media_item} label="" class="flex place-content-evenly">
- <.link
- navigate={~p"/sources/#{@source.id}/media/#{media_item.id}"}
- class="hover:text-secondary duration-200 ease-in-out mx-0.5"
- >
- <.icon name="hero-eye" />
-
-
-
- <% else %>
- Nothing Here!
- <% end %>
-
-
-
- <.link
- href={~p"/sources/#{@source}"}
- method="delete"
- data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
- >
- <.button color="bg-meta-1" rounding="rounded-full">
- Delete Source
-
-
- <.link
- href={~p"/sources/#{@source}?delete_files=true"}
- method="delete"
- data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
- class="mt-5 md:mt-0"
- >
- <.button color="bg-meta-1" rounding="rounded-full">
- Delete Source and Files
-
-
-
+ <.link
+ href={~p"/sources/#{@source}?delete_files=true"}
+ method="delete"
+ data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
+ class="mt-5 md:mt-0"
+ >
+ <.button color="bg-meta-1" rounding="rounded-full">
+ Delete Source and Files
+
+
+
+
+ <:tab title="Pending Media">
+ <%= if match?([_|_], @pending_media) do %>
+
Shows a maximum of 100 media items
+ <.table rows={@pending_media} table_class="text-black dark:text-white">
+ <:col :let={media_item} label="Title">
+ <%= StringUtils.truncate(media_item.title, 50) %>
+
+ <:col :let={media_item} label="" class="flex place-content-evenly">
+ <.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
+
+
+ <% else %>
+
Nothing Here!
+ <% end %>
+
+ <:tab title="Downloaded Media">
+ <%= if match?([_|_], @downloaded_media) do %>
+
Shows a maximum of 100 media items
+ <.table rows={@downloaded_media} table_class="text-black dark:text-white">
+ <:col :let={media_item} label="Title">
+ <%= StringUtils.truncate(media_item.title, 50) %>
+
+ <:col :let={media_item} label="" class="flex place-content-evenly">
+ <.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
+
+
+ <% else %>
+
Nothing Here!
+ <% end %>
+
+ <:tab title="Tasks">
+ <%= if match?([_|_], @source.tasks) do %>
+ <.table rows={@source.tasks} table_class="text-black dark:text-white">
+ <:col :let={task} label="Worker">
+ <%= task.job.worker %>
+
+ <:col :let={task} label="State">
+ <%= task.job.state %>
+
+ <:col :let={task} label="Scheduled At">
+ <%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
+
+
+ <% else %>
+
Nothing Here!
+ <% end %>
+
+
diff --git a/test/pinchflat/media_test.exs b/test/pinchflat/media_test.exs
index fdd87d2..15ca76d 100644
--- a/test/pinchflat/media_test.exs
+++ b/test/pinchflat/media_test.exs
@@ -64,6 +64,14 @@ defmodule Pinchflat.MediaTest do
assert Media.list_pending_media_items_for(source) == []
end
+
+ test "optionally accepts a limit" do
+ source = source_fixture()
+ media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
+
+ assert Media.list_pending_media_items_for(source, limit: 1) == [media_item]
+ assert Media.list_pending_media_items_for(source, limit: 0) == []
+ end
end
describe "list_pending_media_items_for/1 when testing shorts" do
@@ -196,6 +204,15 @@ defmodule Pinchflat.MediaTest do
assert Media.list_downloaded_media_items_for(source) == [media_item]
end
+
+ test "optionally accepts a limit" do
+ source = source_fixture()
+ _media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
+ media_item = media_item_fixture(%{source_id: source.id, media_filepath: "/video/#{Faker.File.file_name(:video)}"})
+
+ assert Media.list_downloaded_media_items_for(source, limit: 1) == [media_item]
+ assert Media.list_downloaded_media_items_for(source, limit: 0) == []
+ end
end
describe "search/1" do
diff --git a/test/pinchflat/repo_test.exs b/test/pinchflat/repo_test.exs
index 0d7eea2..eb465f3 100644
--- a/test/pinchflat/repo_test.exs
+++ b/test/pinchflat/repo_test.exs
@@ -1,6 +1,9 @@
defmodule Pinchflat.RepoTest do
use Pinchflat.DataCase
+ import Pinchflat.ProfilesFixtures
+ alias Pinchflat.Repo
+ alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.JobFixtures.TestJobWorker
describe "insert_unique_job/1" do
@@ -23,4 +26,30 @@ defmodule Pinchflat.RepoTest do
assert {:error, _} = Pinchflat.Repo.insert_unique_job(%Ecto.Changeset{})
end
end
+
+ describe "maybe_limit/2" do
+ test "applies a limit if provided" do
+ media_profile_fixture()
+ media_profile_fixture()
+
+ result =
+ MediaProfile
+ |> Repo.maybe_limit(1)
+ |> Repo.aggregate(:count, :id)
+
+ assert result == 1
+ end
+
+ test "does not apply a limit if not provided" do
+ media_profile_fixture()
+ media_profile_fixture()
+
+ result =
+ MediaProfile
+ |> Repo.maybe_limit(nil)
+ |> Repo.aggregate(:count, :id)
+
+ assert result == 2
+ end
+ end
end