Compare commits

...

4 Commits

Author SHA1 Message Date
Kieran Eglin f6d8e15670 Bumped version 2024-03-20 14:48:58 -07:00
Kieran 2906ff1b30 Update logo (#100)
* Placeholder commit

* Added new logo to app

* added logo to header of small screens

* Updated favicon

* Added logo to README

* Updated README
2024-03-20 14:34:49 -07:00
Kieran 0582a7bfd5 Misc indexing improvements (#99)
* increased file follower timeout to 10 minutes

* Removed unique index across a source's collection and its media profile

* Ensure source gets updating during slow indexing
2024-03-20 11:34:33 -07:00
Kieran 3db2e8190f Source title regex filtering (#98)
* Added a basic/advanced mode to source form

* Added regex field and UI to source form

* Implemented title regex filtering
2024-03-20 10:51:14 -07:00
51 changed files with 148 additions and 79 deletions
+17 -1
View File
@@ -1,4 +1,20 @@
# Pinchflat (Alpha) <p align="center">
<img
src="priv/static/images/originals/logo-white-wordmark-with-background.png"
alt="Pinchflat Logo by @hernandito"
width="700"
/>
</p>
<p align="center">
<sup>
<em>logo by <a href="https://github.com/hernandito" target="_blank">@hernandito</a></em>
</sup>
</p>
# Your next YouTube media manager
## Disclaimer
This is alpha software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges. This is alpha software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges.
+15
View File
@@ -25,6 +25,21 @@ config :pinchflat,
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"), basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD") basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD")
arch_string = to_string(:erlang.system_info(:system_architecture))
system_arch =
cond do
String.contains?(arch_string, "arm") -> "arm"
String.contains?(arch_string, "aarch") -> "arm"
String.contains?(arch_string, "x86") -> "x86"
true -> "unknown"
end
config :pinchflat, Pinchflat.Repo,
load_extensions: [
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
]
if config_env() == :prod do if config_env() == :prod do
config_path = config_path =
System.get_env("CONFIG_PATH") || System.get_env("CONFIG_PATH") ||
+9
View File
@@ -66,6 +66,7 @@ defmodule Pinchflat.Media do
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath)) |> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_profile)) |> where(^build_format_clauses(media_profile))
|> where(^maybe_apply_cutoff_date(source)) |> where(^maybe_apply_cutoff_date(source))
|> where(^maybe_apply_title_regex(source))
|> Repo.maybe_limit(limit) |> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@@ -247,6 +248,14 @@ defmodule Pinchflat.Media do
end end
end end
defp maybe_apply_title_regex(source) do
if source.title_filter_regex do
dynamic([mi], fragment("regexp_like(?, ?)", mi.title, ^source.title_filter_regex))
else
dynamic(true)
end
end
defp build_format_clauses(media_profile) do defp build_format_clauses(media_profile) do
mapped_struct = Map.from_struct(media_profile) mapped_struct = Map.from_struct(media_profile)
@@ -9,7 +9,7 @@ defmodule Pinchflat.SlowIndexing.FileFollowerServer do
require Logger require Logger
@poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval) @poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval)
@activity_timeout_ms 60_000 @activity_timeout_ms 600_000
# Client API # Client API
@doc """ @doc """
@@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
require Logger require Logger
alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
@@ -60,6 +61,9 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
# See the method definition below for more info on how file watchers work # See the method definition below for more info on how file watchers work
# (important reading if you're not familiar with it) # (important reading if you're not familiar with it)
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source) {:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source)
# Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed.
source = Repo.reload!(source)
result = result =
Enum.map(media_attributes, fn media_attrs -> Enum.map(media_attributes, fn media_attrs ->
@@ -117,6 +121,10 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
end end
defp create_media_item_and_enqueue_download(source, media_attrs) do defp create_media_item_and_enqueue_download(source, media_attrs) do
# Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed.
source = Repo.reload!(source)
case Media.create_media_item_from_backend_attrs(source, media_attrs) do case Media.create_media_item_from_backend_attrs(source, media_attrs) do
{:ok, %MediaItem{} = media_item} -> {:ok, %MediaItem{} = media_item} ->
if source.download_media && Media.pending_download?(media_item) do if source.download_media && Media.pending_download?(media_item) do
+8 -6
View File
@@ -28,6 +28,7 @@ defmodule Pinchflat.Sources.Source do
last_indexed_at last_indexed_at
original_url original_url
download_cutoff_date download_cutoff_date
title_filter_regex
media_profile_id media_profile_id
)a )a
@@ -57,11 +58,6 @@ defmodule Pinchflat.Sources.Source do
field :collection_name, :string field :collection_name, :string
field :collection_id, :string field :collection_id, :string
field :collection_type, Ecto.Enum, values: [:channel, :playlist] field :collection_type, Ecto.Enum, values: [:channel, :playlist]
field :nfo_filepath, :string
field :poster_filepath, :string
field :fanart_filepath, :string
field :banner_filepath, :string
field :series_directory, :string
field :index_frequency_minutes, :integer, default: 60 * 24 field :index_frequency_minutes, :integer, default: 60 * 24
field :fast_index, :boolean, default: false field :fast_index, :boolean, default: false
field :download_media, :boolean, default: true field :download_media, :boolean, default: true
@@ -69,6 +65,13 @@ defmodule Pinchflat.Sources.Source do
# Only download media items that were published after this date # Only download media items that were published after this date
field :download_cutoff_date, :date field :download_cutoff_date, :date
field :original_url, :string field :original_url, :string
field :title_filter_regex, :string
field :series_directory, :string
field :nfo_filepath, :string
field :poster_filepath, :string
field :fanart_filepath, :string
field :banner_filepath, :string
belongs_to :media_profile, MediaProfile belongs_to :media_profile, MediaProfile
@@ -95,7 +98,6 @@ defmodule Pinchflat.Sources.Source do
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end) |> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> validate_required(required_fields) |> validate_required(required_fields)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false) |> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|> unique_constraint([:collection_id, :media_profile_id])
end end
@doc false @doc false
@@ -1,10 +1,9 @@
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden"> <div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none"> <header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
<div class="flex flex-grow items-center px-4 py-4 shadow-2 md:px-6 2xl:px-11"> <div class="w-65 px-4 py-2 shadow-2 md:px-6">
<div class="flex items-center"> <div class="flex items-center gap-2 py-2">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" /> <img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
</div> </div>
</div> </div>
</header> </header>
@@ -1,18 +1,18 @@
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none"> <header class="sticky top-0 z-999 flex h-20 w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
<div class="flex flex-grow items-center justify-between lg:justify-end px-4 py-4 shadow-2 md:px-6 2xl:px-11"> <div class="flex flex-grow items-center justify-between lg:justify-end px-4 py-4 shadow-2 md:px-6 2xl:px-11">
<div class="flex items-center gap-2 sm:gap-4 lg:hidden"> <div class="flex items-center gap-2 sm:gap-4 lg:hidden w-2/6">
<section class="pr-1">
<img src={~p"/images/icon.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-10" />
</section>
<button <button
class="z-99999 block rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden" class="z-99999 block mx-2 rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden"
@click.stop="sidebarVisible = !sidebarVisible" @click.stop="sidebarVisible = !sidebarVisible"
> >
<.icon name="hero-bars-3" /> <.icon name="hero-bars-3" />
</button> </button>
<a class="hidden sm:flex items-center lg:hidden" href="/">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
</a>
</div> </div>
<div class="bg-meta-4 rounded-md"> <div class="bg-meta-4 rounded-md w-4/6 lg:w-3/6 xl:w-2/6">
<div class="relative"> <div class="relative">
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex"> <span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
<.icon name="hero-magnifying-glass" /> <.icon name="hero-magnifying-glass" />
@@ -23,7 +23,7 @@
name="q" name="q"
value={@params["q"]} value={@params["q"]}
placeholder="Type to search..." placeholder="Type to search..."
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none lg:w-125" class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
/> />
</form> </form>
</div> </div>
@@ -1,24 +1,23 @@
<aside <aside
x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'" x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'"
class={[ class={[
"-translate-x-full absolute left-0 top-0 z-9999 flex h-screen w-60 flex-col overflow-y-hidden justify-between", "-translate-x-full absolute left-0 top-0 z-9999 flex h-screen w-65 flex-col overflow-y-hidden justify-between",
"bg-black duration-300 ease-linear shadow-lg sm:shadow-none dark:bg-boxdark lg:static lg:translate-x-0" "bg-black duration-300 ease-linear shadow-lg sm:shadow-none dark:bg-boxdark lg:static lg:translate-x-0"
]} ]}
@click.outside="sidebarVisible = false" @click.outside="sidebarVisible = false"
> >
<section> <section>
<div class="flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5"> <div class="flex items-center justify-between gap-2 px-6 py-4">
<a href="/" class="flex items-center"> <a href="/" class="flex items-center">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" /> <img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
</a> </a>
<button class="block lg:hidden" @click.stop="sidebarVisible = !sidebarVisible"> <button class="block mt-3 lg:hidden" @click.stop="sidebarVisible = !sidebarVisible">
<.icon name="hero-arrow-left" class="fill-current" /> <.icon name="hero-arrow-left" class="fill-current" />
</button> </button>
</div> </div>
<div class="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear"> <div class="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
<nav class="mt-5 px-4 py-4 lg:mt-9 lg:px-6"> <nav class="mt-3 px-4 py-4 lg:px-6">
<h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">MENU</h3> <h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">MENU</h3>
<div class="flex flex-col justify-between"> <div class="flex flex-col justify-between">
<ul class="mb-6 flex flex-col gap-1.5"> <ul class="mb-6 flex flex-col gap-1.5">
@@ -31,7 +30,7 @@
</div> </div>
</section> </section>
<section> <section>
<nav class="mt-5 px-4 py-4 lg:mt-9 lg:px-6"> <nav class="px-4 py-4 lg:px-6">
<ul class="mb-6 flex flex-col gap-1.5"> <ul class="mb-6 flex flex-col gap-1.5">
<.sidebar_item <.sidebar_item
icon="hero-code-bracket" icon="hero-code-bracket"
@@ -8,7 +8,7 @@
<%= assigns[:page_title] || "Pinchflat" %> <%= assigns[:page_title] || "Pinchflat" %>
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-02-29"} /> <link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-03-20"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script> </script>
</head> </head>
@@ -238,7 +238,7 @@
/> />
</section> </section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Media profile</.button> <.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
</section> </section>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5"> <div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
@@ -1,11 +1,22 @@
<.simple_form :let={f} for={@changeset} action={@action}> <.simple_form
:let={f}
for={@changeset}
action={@action}
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
>
<.error :if={@changeset.action}> <.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below. Oops, something went wrong! Please check the errors below.
</.error> </.error>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <section class="flex justify-between items-center mt-8">
General Options <h3 class=" text-2xl text-black dark:text-white">
</h3> General Options
</h3>
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
</span>
</section>
<.input <.input
field={f[:custom_name]} field={f[:custom_name]}
@@ -69,7 +80,24 @@
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format" help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
/> />
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Source</.button> <section x-show="advancedMode">
<h3 class="mt-8 text-2xl text-black dark:text-white">
Advanced Options
</h3>
<p class="text-sm mt-2">
Tread carefully
</p>
<.input
field={f[:title_filter_regex]}
type="text"
label="Title Filter Regex"
placeholder="(?i)^How to Bike$"
help="A PCRE-compatible regex. Only media with titles that match this regex will be downloaded. Look up 'SQLean Regex docs' for more"
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Source</.button>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5"> <div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
<.fast_indexing_help /> <.fast_indexing_help />
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "0.1.0-alpha.6", version: "0.1.0-alpha.7",
elixir: "~> 1.16", elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddTitleRegexToSource do
use Ecto.Migration
def change do
alter table(:sources) do
add :title_filter_regex, :string
end
end
end
@@ -0,0 +1,7 @@
defmodule Pinchflat.Repo.Migrations.RemoveUniqueIndexFromSources do
use Ecto.Migration
def change do
drop unique_index(:sources, [:collection_id, :media_profile_id])
end
end
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+20
View File
@@ -239,6 +239,26 @@ defmodule Pinchflat.MediaTest do
end end
end end
describe "list_pending_media_items_for/1 when testing title regex" do
test "returns only media items that match the title regex" do
source = source_fixture(%{title_filter_regex: "(?i)^FOO$"})
matching_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "foo"})
_non_matching_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "bar"})
assert Media.list_pending_media_items_for(source) == [matching_media_item]
end
test "does not apply a regex if none is specified" do
source = source_fixture(%{title_filter_regex: nil})
media_item_one = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "foo"})
media_item_two = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "bar"})
assert Media.list_pending_media_items_for(source) == [media_item_one, media_item_two]
end
end
describe "list_downloaded_media_items_for/1" do describe "list_downloaded_media_items_for/1" do
test "returns only media items with a media_filepath" do test "returns only media items with a media_filepath" do
source = source_fixture() source = source_fixture()
-43
View File
@@ -138,49 +138,6 @@ defmodule Pinchflat.SourcesTest do
assert {:error, %Ecto.Changeset{}} = Sources.create_source(@invalid_source_attrs) assert {:error, %Ecto.Changeset{}} = Sources.create_source(@invalid_source_attrs)
end end
test "creation enforces uniqueness of collection_id scoped to the media_profile" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
channel_id: "some_channel_id_12345678",
playlist_id: "some_channel_id_12345678",
playlist_title: "some channel name - videos"
})}
end)
valid_once_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:ok, %Source{}} = Sources.create_source(valid_once_attrs)
assert {:error, %Ecto.Changeset{}} = Sources.create_source(valid_once_attrs)
end
test "creation lets you duplicate collection_ids as long as the media profile is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
channel_id: "some_channel_id_12345678",
playlist_id: "some_channel_id_12345678",
playlist_title: "some channel name - videos"
})}
end)
valid_attrs = %{
name: "some name",
original_url: "https://www.youtube.com/channel/abc123"
}
source_1_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
source_2_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
assert {:ok, %Source{}} = Sources.create_source(source_1_attrs)
assert {:ok, %Source{}} = Sources.create_source(source_2_attrs)
end
test "creation will schedule the indexing task" do test "creation will schedule the indexing task" do
expect(YtDlpRunnerMock, :run, &channel_mock/3) expect(YtDlpRunnerMock, :run, &channel_mock/3)