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""" +
+
+ + <%= tab.title %> + +
+
+
+ <%= 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