Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21eaeb8b60 | |||
| 2b348607bf | |||
| 2affcd8616 | |||
| cab4c339e5 | |||
| 103541cd20 | |||
| 8cba501bff | |||
| 6a02f217e3 | |||
| c3f49184b5 | |||
| 3837a0dedd | |||
| c70f211232 | |||
| ba0ded03a4 | |||
| 8513deef09 | |||
| 83b9adea59 | |||
| 10a639a976 | |||
| c79e27d9b3 | |||
| 8ef5e0f41f | |||
| 854502634c | |||
| fc8eed8b50 | |||
| f42aab286a | |||
| bf11cd0dd9 | |||
| e051a37f0d | |||
| 64ebf756ab | |||
| 377bc617ea | |||
| a03f69a8d4 | |||
| d39ebe7ac1 | |||
| 8b5dcc15d6 | |||
| 5e58dcd879 | |||
| a9d5407071 | |||
| 37c1da8014 | |||
| 937a14985f | |||
| a6a20c111f | |||
| 2bb96edc00 | |||
| 87f07ff634 | |||
| dd60cb5419 | |||
| 610b00566b | |||
| aba15ed886 | |||
| 87da716fdf | |||
| 45f056c6e0 | |||
| 11d160008c | |||
| ba0a1ba100 | |||
| 8c51a286c9 | |||
| 46c27e4875 | |||
| b639918555 | |||
| 5596351f58 | |||
| ad150b6f16 | |||
| f6d8e15670 | |||
| 2906ff1b30 | |||
| 0582a7bfd5 | |||
| 3db2e8190f | |||
| 087c9c2d0f | |||
| c046abf36f | |||
| 70dd95211f | |||
| 8491f8b317 | |||
| d0cb782082 | |||
| 7a7e8080cd | |||
| fbe21cb304 | |||
| 0f3329e97d | |||
| 25eb772896 | |||
| cf59bf99cd | |||
| 47e96e3780 | |||
| a7af6a9125 | |||
| 8f9d18dc71 | |||
| 513212faf2 | |||
| 5ec2c92a0c | |||
| 3c897e96e6 | |||
| c0885cfaf2 | |||
| b2f39a7b7f | |||
| bada14625b | |||
| 7e4f1a8412 | |||
| 0e537d8f57 | |||
| afe41d2e36 | |||
| f36bd5abab | |||
| b3f094fa69 | |||
| ccdcf8eec5 | |||
| 67d7f397d1 | |||
| 09a4bcb36b | |||
| dc0313d875 | |||
| 1caddd86c7 | |||
| 1c65b41002 | |||
| 6e195f6a6f | |||
| d0f55cd463 | |||
| 39caf4a94f | |||
| 254054c0c1 | |||
| 5c9933b684 | |||
| cbade0dd02 | |||
| 63e5058365 | |||
| a46dd19ec7 | |||
| 55f9676bb7 | |||
| fc52f16475 | |||
| 8cc36928f1 | |||
| 1ad9e2c331 | |||
| 6738071613 | |||
| b54e69c609 | |||
| 5b29f5036a | |||
| 9fc2edfd4a | |||
| 01b8856f22 | |||
| 3bb7edba0b | |||
| 52c0a1742a | |||
| e33a46bddc | |||
| 2ca395a5bf | |||
| ebebbf7571 | |||
| b8cec51c3b | |||
| 0d29f70a28 | |||
| c1f30dbd2c | |||
| fdb83ed3f3 | |||
| 94f777d1ed | |||
| 47f0fd533f | |||
| 7d62f7c7df | |||
| d4d653398b | |||
| fab77f4deb | |||
| 8fcd41f45d | |||
| 44bd105e80 | |||
| 9d3b9cc063 | |||
| 060e340558 | |||
| 219320ce11 | |||
| 8bdd189fa5 | |||
| dabec62e99 | |||
| 89497c4c6b | |||
| 013b2ccc21 | |||
| ca01f17b58 | |||
| bdef6c75bb | |||
| d25e65f822 | |||
| a5e7c48f70 | |||
| fee39151a8 | |||
| 4827da3106 | |||
| 536f868ba0 |
@@ -77,5 +77,3 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|||||||
@@ -48,11 +48,9 @@ If it doesn't work for your use case, please make a feature request! You can als
|
|||||||
- Uses a novel approach to download new content more quickly than other apps
|
- Uses a novel approach to download new content more quickly than other apps
|
||||||
- Supports downloading audio content
|
- Supports downloading audio content
|
||||||
- Custom rules for handling YouTube Shorts and livestreams
|
- Custom rules for handling YouTube Shorts and livestreams
|
||||||
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
|
|
||||||
- Advanced options like setting cutoff dates and filtering by title
|
- Advanced options like setting cutoff dates and filtering by title
|
||||||
- Reliable hands-off operation
|
- Reliable hands-off operation
|
||||||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||||
- Sponsorblock integration
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { Socket } from 'phoenix'
|
|||||||
import { LiveSocket } from 'phoenix_live_view'
|
import { LiveSocket } from 'phoenix_live_view'
|
||||||
import topbar from '../vendor/topbar'
|
import topbar from '../vendor/topbar'
|
||||||
import Alpine from 'alpinejs'
|
import Alpine from 'alpinejs'
|
||||||
import './tabs'
|
|
||||||
|
|
||||||
window.Alpine = Alpine
|
window.Alpine = Alpine
|
||||||
Alpine.start()
|
Alpine.start()
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
window.setTabIndex = (index) => {
|
|
||||||
window.location.hash = `tab-${index}`
|
|
||||||
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
// The conditionals and currIndex stuff ensures that
|
|
||||||
// the tab index is always set to 0 if the hash is empty
|
|
||||||
// AND other hash values are ignored
|
|
||||||
window.getTabIndex = (currIndex) => {
|
|
||||||
if (window.location.hash === '' || window.location.hash === '#') {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.location.hash.startsWith('#tab-')) {
|
|
||||||
return parseInt(window.location.hash.replace('#tab-', ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
return currIndex
|
|
||||||
}
|
|
||||||
+2
-8
@@ -46,13 +46,7 @@ config :pinchflat, Oban,
|
|||||||
engine: Oban.Engines.Lite,
|
engine: Oban.Engines.Lite,
|
||||||
repo: Pinchflat.Repo,
|
repo: Pinchflat.Repo,
|
||||||
# Keep old jobs for 30 days for display in the UI
|
# Keep old jobs for 30 days for display in the UI
|
||||||
plugins: [
|
plugins: [{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60}],
|
||||||
{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
|
|
||||||
{Oban.Plugins.Cron,
|
|
||||||
crontab: [
|
|
||||||
{"@daily", Pinchflat.Downloading.MediaRetentionWorker}
|
|
||||||
]}
|
|
||||||
],
|
|
||||||
# TODO: consider making this an env var or something?
|
# TODO: consider making this an env var or something?
|
||||||
queues: [
|
queues: [
|
||||||
default: 10,
|
default: 10,
|
||||||
@@ -85,7 +79,7 @@ config :esbuild,
|
|||||||
|
|
||||||
# Configure tailwind (the version is required)
|
# Configure tailwind (the version is required)
|
||||||
config :tailwind,
|
config :tailwind,
|
||||||
version: "3.4.3",
|
version: "3.3.2",
|
||||||
default: [
|
default: [
|
||||||
args: ~w(
|
args: ~w(
|
||||||
--config=tailwind.config.js
|
--config=tailwind.config.js
|
||||||
|
|||||||
+2
-1
@@ -32,7 +32,8 @@ config :pinchflat, Pinchflat.Mailer, adapter: Swoosh.Adapters.Test
|
|||||||
# Disable swoosh api client as it is only required for production adapters.
|
# Disable swoosh api client as it is only required for production adapters.
|
||||||
config :swoosh, :api_client, false
|
config :swoosh, :api_client, false
|
||||||
|
|
||||||
config :logger, level: :critical
|
# Print only warnings and errors during test
|
||||||
|
config :logger, level: :warning
|
||||||
|
|
||||||
# Initialize plugs at runtime for faster test compilation
|
# Initialize plugs at runtime for faster test compilation
|
||||||
config :phoenix, :plug_init_mode, :runtime
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
defmodule Pinchflat.Boot.DataBackfillWorker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :local_metadata,
|
||||||
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||||
|
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]
|
||||||
|
|
||||||
|
# This one is going to be a little more self-contained
|
||||||
|
# instead of relying on outside modules for the methods.
|
||||||
|
# That's because, for now, these methods are not intended
|
||||||
|
# to be used elsewhere.
|
||||||
|
#
|
||||||
|
# I'm just trying out that pattern and seeing if I like it better
|
||||||
|
# so this may change.
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias __MODULE__
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Cancels all pending backfill jobs. Useful for ensuring worker runs immediately
|
||||||
|
on app boot.
|
||||||
|
|
||||||
|
Returns {:ok, integer()}
|
||||||
|
"""
|
||||||
|
def cancel_pending_backfill_jobs do
|
||||||
|
Oban.Job
|
||||||
|
|> where(worker: "Pinchflat.Boot.DataBackfillWorker")
|
||||||
|
|> Oban.cancel_all_jobs()
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl Oban.Worker
|
||||||
|
@doc """
|
||||||
|
Performs one-off tasks to get data in the right shape.
|
||||||
|
This can be needed when we add new features or change the way
|
||||||
|
we store data. Must be idempotent. All new data should already
|
||||||
|
conform to the expected schema so this should only be needed
|
||||||
|
for existing data. Still runs periodically to be safe.
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
def perform(%Oban.Job{}) do
|
||||||
|
Logger.info("Running data backfill worker")
|
||||||
|
# Nothing to do for now - just reschedule
|
||||||
|
# Keeping in-place because we _will_ need it in the future
|
||||||
|
|
||||||
|
reschedule_backfill()
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reschedule_backfill do
|
||||||
|
# Run hourly
|
||||||
|
next_run_in = 60 * 60
|
||||||
|
|
||||||
|
%{}
|
||||||
|
|> DataBackfillWorker.new(schedule_in: next_run_in)
|
||||||
|
|> Repo.insert_unique_job()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,6 +11,9 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
|
|||||||
use GenServer, restart: :temporary
|
use GenServer, restart: :temporary
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Boot.DataBackfillWorker
|
||||||
|
|
||||||
def start_link(opts \\ []) do
|
def start_link(opts \\ []) do
|
||||||
GenServer.start_link(__MODULE__, %{}, opts)
|
GenServer.start_link(__MODULE__, %{}, opts)
|
||||||
end
|
end
|
||||||
@@ -26,7 +29,16 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
|
|||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def init(state) do
|
def init(state) do
|
||||||
# Empty for now, keeping because tasks _will_ be added in future
|
enqueue_backfill_worker()
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp enqueue_backfill_worker do
|
||||||
|
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||||
|
|
||||||
|
%{}
|
||||||
|
|> DataBackfillWorker.new()
|
||||||
|
|> Repo.insert_unique_job()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.YtDlp.CommandRunner
|
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
|
|
||||||
def start_link(opts \\ []) do
|
def start_link(opts \\ []) do
|
||||||
@@ -63,10 +62,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp apply_default_settings do
|
defp apply_default_settings do
|
||||||
{:ok, yt_dlp_version} = CommandRunner.version()
|
|
||||||
|
|
||||||
Settings.fetch!(:onboarding, true)
|
Settings.fetch!(:onboarding, true)
|
||||||
Settings.fetch!(:pro_enabled, false)
|
Settings.fetch!(:pro_enabled, false)
|
||||||
Settings.set!(:yt_dlp_version, yt_dlp_version)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,29 +19,27 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
|
|
||||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def kickoff_with_task(media_item, job_args \\ %{}, job_opts \\ []) do
|
def kickoff_with_task(media_item, opts \\ []) do
|
||||||
%{id: media_item.id}
|
%{id: media_item.id}
|
||||||
|> Map.merge(job_args)
|
|> MediaDownloadWorker.new(opts)
|
||||||
|> MediaDownloadWorker.new(job_opts)
|
|
||||||
|> Tasks.create_job_with_task(media_item)
|
|> Tasks.create_job_with_task(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
For a given media item, download the media alongside any options.
|
For a given media item, download the media alongside any options.
|
||||||
Does not download media if its source is set to not download media
|
Does not download media if its source is set to not download media.
|
||||||
(unless forced).
|
|
||||||
|
|
||||||
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
|
||||||
media_item =
|
media_item =
|
||||||
media_item_id
|
media_item_id
|
||||||
|> Media.get_media_item!()
|
|> Media.get_media_item!()
|
||||||
|> Repo.preload(:source)
|
|> Repo.preload(:source)
|
||||||
|
|
||||||
# If the source is set to not download media, perform a no-op
|
# If the source is set to not download media, perform a no-op
|
||||||
if media_item.source.download_media || args["force"] do
|
if media_item.source.download_media do
|
||||||
download_media_and_schedule_jobs(media_item)
|
download_media_and_schedule_jobs(media_item)
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
@@ -59,9 +57,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
{:ok, updated_media_item}
|
{:ok, updated_media_item}
|
||||||
|
|
||||||
err ->
|
err ->
|
||||||
Logger.error("Failed to download media for media item #{media_item.id}: #{inspect(err)}")
|
err
|
||||||
|
|
||||||
{:error, :download_failed}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
|
||||||
@moduledoc false
|
|
||||||
|
|
||||||
use Oban.Worker,
|
|
||||||
queue: :local_metadata,
|
|
||||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
|
||||||
tags: ["media_item", "local_metadata"]
|
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
alias Pinchflat.Media
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Deletes media items that are past their retention date and prevents
|
|
||||||
them from being re-downloaded.
|
|
||||||
|
|
||||||
This worker is scheduled to run daily via the Oban Cron plugin.
|
|
||||||
|
|
||||||
Returns :ok
|
|
||||||
"""
|
|
||||||
@impl Oban.Worker
|
|
||||||
def perform(%Oban.Job{}) do
|
|
||||||
cullable_media = Media.list_cullable_media_items()
|
|
||||||
Logger.info("Culling #{length(cullable_media)} media items past their retention date")
|
|
||||||
|
|
||||||
Enum.each(cullable_media, fn media_item ->
|
|
||||||
Media.delete_media_files(media_item, %{
|
|
||||||
prevent_download: true,
|
|
||||||
culled_at: DateTime.utc_now()
|
|
||||||
})
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -23,32 +23,38 @@ defmodule Pinchflat.Media do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of media_items that are cullable based on the retention period
|
Returns a list of pending media_items for a given source, where
|
||||||
of the source they belong to.
|
pending means the `media_filepath` is `nil` AND the media_item
|
||||||
|
matches the format selection rules of the parent media_profile.
|
||||||
|
|
||||||
Returns [%MediaItem{}, ...]
|
See `build_format_clauses` but tl;dr is it _may_ filter based
|
||||||
|
on shorts or livestreams depending on the media_profile settings.
|
||||||
|
|
||||||
|
Returns [%MediaItem{}, ...].
|
||||||
"""
|
"""
|
||||||
def list_cullable_media_items do
|
def list_pending_media_items_for(%Source{} = source, opts \\ []) do
|
||||||
|
limit = Keyword.get(opts, :limit, nil)
|
||||||
|
source = Repo.preload(source, :media_profile)
|
||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.with_media_filepath()
|
|> MediaQuery.for_source(source)
|
||||||
|> MediaQuery.with_passed_retention_period()
|
|> matching_download_criteria_for(source)
|
||||||
|> MediaQuery.with_no_culling_prevention()
|
|> Repo.maybe_limit(limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of pending media_items for a given source, where
|
Returns a list of downloaded media_items for a given source.
|
||||||
pending means the `media_filepath` is `nil` AND the media_item
|
|
||||||
matches satisfies `MediaQuery.with_media_pending_download`. You
|
|
||||||
should really check out that function if you need to know more
|
|
||||||
because it has a lot going on.
|
|
||||||
|
|
||||||
Returns [%MediaItem{}, ...].
|
Returns [%MediaItem{}, ...].
|
||||||
"""
|
"""
|
||||||
def list_pending_media_items_for(%Source{} = source) do
|
def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
|
||||||
|
limit = Keyword.get(opts, :limit, nil)
|
||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> MediaQuery.for_source(source)
|
||||||
|> MediaQuery.with_media_pending_download()
|
|> MediaQuery.with_media_filepath()
|
||||||
|
|> Repo.maybe_limit(limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.with_id(media_item.id)
|
|> MediaQuery.with_id(media_item.id)
|
||||||
|> MediaQuery.with_media_pending_download()
|
|> matching_download_criteria_for(media_item.source)
|
||||||
|> Repo.exists?()
|
|> Repo.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -155,7 +161,7 @@ defmodule Pinchflat.Media do
|
|||||||
Tasks.delete_tasks_for(media_item)
|
Tasks.delete_tasks_for(media_item)
|
||||||
|
|
||||||
if delete_files do
|
if delete_files do
|
||||||
{:ok, _} = do_delete_media_files(media_item)
|
{:ok, _} = delete_media_files(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Should delete these no matter what
|
# Should delete these no matter what
|
||||||
@@ -163,25 +169,6 @@ defmodule Pinchflat.Media do
|
|||||||
Repo.delete(media_item)
|
Repo.delete(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Deletes the tasks and media files associated with a media_item but leaves the
|
|
||||||
media_item in the database. Does not delete anything to do with associated metadata.
|
|
||||||
|
|
||||||
Optionally accepts a second argument `addl_attrs` which will be merged into the
|
|
||||||
media_item before it is updated. Useful for setting things like `prevent_download`
|
|
||||||
and `culled_at`, if wanted
|
|
||||||
|
|
||||||
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
|
||||||
"""
|
|
||||||
def delete_media_files(%MediaItem{} = media_item, addl_attrs \\ %{}) do
|
|
||||||
filepath_attrs = MediaItem.filepath_attribute_defaults()
|
|
||||||
|
|
||||||
Tasks.delete_tasks_for(media_item)
|
|
||||||
{:ok, _} = do_delete_media_files(media_item)
|
|
||||||
|
|
||||||
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
|
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
|
||||||
"""
|
"""
|
||||||
@@ -189,7 +176,7 @@ defmodule Pinchflat.Media do
|
|||||||
MediaItem.changeset(media_item, attrs)
|
MediaItem.changeset(media_item, attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_delete_media_files(media_item) do
|
defp delete_media_files(media_item) do
|
||||||
mapped_struct = Map.from_struct(media_item)
|
mapped_struct = Map.from_struct(media_item)
|
||||||
|
|
||||||
MediaItem.filepath_attributes()
|
MediaItem.filepath_attributes()
|
||||||
@@ -213,4 +200,12 @@ defmodule Pinchflat.Media do
|
|||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.filter(&is_binary/1)
|
||||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp matching_download_criteria_for(query, source_with_preloads) do
|
||||||
|
query
|
||||||
|
|> MediaQuery.with_no_media_filepath()
|
||||||
|
|> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
|
||||||
|
|> MediaQuery.with_format_preference(source_with_preloads.media_profile)
|
||||||
|
|> MediaQuery.matching_title_regex(source_with_preloads.title_filter_regex)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
:subtitle_filepaths,
|
:subtitle_filepaths,
|
||||||
:thumbnail_filepath,
|
:thumbnail_filepath,
|
||||||
:metadata_filepath,
|
:metadata_filepath,
|
||||||
:nfo_filepath,
|
:nfo_filepath
|
||||||
# These are user or system controlled fields
|
|
||||||
:prevent_download,
|
|
||||||
:prevent_culling,
|
|
||||||
:culled_at
|
|
||||||
]
|
]
|
||||||
# Pretty much all the fields captured at index are required.
|
# Pretty much all the fields captured at index are required.
|
||||||
@required_fields ~w(
|
@required_fields ~w(
|
||||||
@@ -46,7 +42,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
source_id
|
source_id
|
||||||
upload_date
|
upload_date
|
||||||
short_form_content
|
short_form_content
|
||||||
)a
|
)a
|
||||||
|
|
||||||
schema "media_items" do
|
schema "media_items" do
|
||||||
# This is _not_ used as the primary key or internally in the database
|
# This is _not_ used as the primary key or internally in the database
|
||||||
@@ -74,10 +70,6 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
# Will very likely revisit because I can't leave well-enough alone.
|
# Will very likely revisit because I can't leave well-enough alone.
|
||||||
field :subtitle_filepaths, {:array, {:array, :string}}, default: []
|
field :subtitle_filepaths, {:array, {:array, :string}}, default: []
|
||||||
|
|
||||||
field :prevent_download, :boolean, default: false
|
|
||||||
field :prevent_culling, :boolean, default: false
|
|
||||||
field :culled_at, :utc_datetime
|
|
||||||
|
|
||||||
field :matching_search_term, :string, virtual: true
|
field :matching_search_term, :string, virtual: true
|
||||||
|
|
||||||
belongs_to :source, Source
|
belongs_to :source, Source
|
||||||
@@ -104,14 +96,4 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
def filepath_attributes do
|
def filepath_attributes do
|
||||||
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
|
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
|
||||||
def filepath_attribute_defaults do
|
|
||||||
filepath_attributes()
|
|
||||||
|> Enum.map(fn
|
|
||||||
:subtitle_filepaths -> {:subtitle_filepaths, []}
|
|
||||||
field -> {field, nil}
|
|
||||||
end)
|
|
||||||
|> Enum.into(%{})
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
Query helpers for the Media context.
|
Query helpers for the Media context.
|
||||||
|
|
||||||
These methods are made to be one-ish liners used
|
These methods are made to be one-ish liners used
|
||||||
to compose queries. Each method should strive to do
|
to compose queries for media items. Each method should
|
||||||
_one_ thing. These don't need to be tested as
|
strive to do _one_ thing. These don't need to be tested
|
||||||
they are just building blocks for other functionality
|
as they are just building blocks for other functionality
|
||||||
which, itself, will be tested.
|
which, itself, will be tested.
|
||||||
|
|
||||||
|
ALSO, this is me trying something new. If I like it,
|
||||||
|
I'll refactor other contexts to use this pattern.
|
||||||
"""
|
"""
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
@@ -14,7 +17,6 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
|
|
||||||
# Prefixes:
|
# Prefixes:
|
||||||
# - for_* - belonging to a certain record
|
# - for_* - belonging to a certain record
|
||||||
# - join_* - for joining on a certain record
|
|
||||||
# - with_* - for filtering based on full, concrete attributes
|
# - with_* - for filtering based on full, concrete attributes
|
||||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
||||||
#
|
#
|
||||||
@@ -29,28 +31,6 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
where(query, [mi], mi.source_id == ^source.id)
|
where(query, [mi], mi.source_id == ^source.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def join_sources(query) do
|
|
||||||
from(mi in query, join: s in assoc(mi, :source), as: :sources)
|
|
||||||
end
|
|
||||||
|
|
||||||
def with_passed_retention_period(query) do
|
|
||||||
query
|
|
||||||
|> require_assoc(:source)
|
|
||||||
|> where(
|
|
||||||
[mi, source],
|
|
||||||
fragment(
|
|
||||||
"IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
|
|
||||||
source.retention_period_days,
|
|
||||||
source.retention_period_days,
|
|
||||||
mi.media_downloaded_at
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def with_no_culling_prevention(query) do
|
|
||||||
where(query, [mi], mi.prevent_culling == false)
|
|
||||||
end
|
|
||||||
|
|
||||||
def with_id(query, id) do
|
def with_id(query, id) do
|
||||||
where(query, [mi], mi.id == ^id)
|
where(query, [mi], mi.id == ^id)
|
||||||
end
|
end
|
||||||
@@ -67,23 +47,16 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
where(query, [mi], is_nil(mi.media_filepath))
|
where(query, [mi], is_nil(mi.media_filepath))
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_upload_date_after_source_cutoff(query) do
|
def with_upload_date_after(query, nil), do: query
|
||||||
query
|
|
||||||
|> require_assoc(:source)
|
def with_upload_date_after(query, date) do
|
||||||
|> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
|
where(query, [mi], mi.upload_date >= ^date)
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_no_prevented_download(query) do
|
def matching_title_regex(query, nil), do: query
|
||||||
where(query, [mi], mi.prevent_download == false)
|
|
||||||
end
|
|
||||||
|
|
||||||
def matching_source_title_regex(query) do
|
def matching_title_regex(query, regex) do
|
||||||
query
|
where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^regex))
|
||||||
|> require_assoc(:source)
|
|
||||||
|> where(
|
|
||||||
[mi, source],
|
|
||||||
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex)
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def matching_search_term(query, nil), do: query
|
def matching_search_term(query, nil), do: query
|
||||||
@@ -104,55 +77,44 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_format_matching_profile_preference(query) do
|
# NOTE: this method breaks the contract set by other methods in that it
|
||||||
query
|
# takes a media_profile struct instead of taking just the attributes it
|
||||||
|> require_assoc(:media_profile)
|
# cares about. Consider refactoring but low priority.
|
||||||
|> where(
|
def with_format_preference(query, media_profile) do
|
||||||
fragment("""
|
mapped_struct = Map.from_struct(media_profile)
|
||||||
CASE
|
|
||||||
WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
|
|
||||||
livestream = true OR short_form_content = true
|
|
||||||
WHEN shorts_behaviour = 'only' THEN
|
|
||||||
short_form_content = true
|
|
||||||
WHEN livestream_behaviour = 'only' THEN
|
|
||||||
livestream = true
|
|
||||||
WHEN shorts_behaviour = 'exclude' AND livestream_behaviour = 'exclude' THEN
|
|
||||||
short_form_content = false AND livestream = false
|
|
||||||
WHEN shorts_behaviour = 'exclude' THEN
|
|
||||||
short_form_content = false
|
|
||||||
WHEN livestream_behaviour = 'exclude' THEN
|
|
||||||
livestream = false
|
|
||||||
ELSE
|
|
||||||
true
|
|
||||||
END
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def with_media_pending_download(query) do
|
finders =
|
||||||
query
|
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
|
||||||
|> with_no_prevented_download()
|
case {attr, media_profile} do
|
||||||
|> with_no_media_filepath()
|
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
|
||||||
|> with_upload_date_after_source_cutoff()
|
dynamic(
|
||||||
|> with_format_matching_profile_preference()
|
[mi],
|
||||||
|> matching_source_title_regex()
|
^dynamic and (mi.livestream == true or mi.short_form_content == true)
|
||||||
end
|
)
|
||||||
|
|
||||||
defp require_assoc(query, identifier) do
|
# Technically redundant, but makes the other clauses easier to parse
|
||||||
if has_named_binding?(query, identifier) do
|
# (redundant because this condition is the same as the condition above, just flipped)
|
||||||
query
|
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
|
||||||
else
|
dynamic
|
||||||
do_require_assoc(query, identifier)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp do_require_assoc(query, :source) do
|
{{:shorts_behaviour, :only}, _} ->
|
||||||
from(mi in query, join: s in assoc(mi, :source), as: :source)
|
dynamic([mi], ^dynamic and mi.short_form_content == true)
|
||||||
end
|
|
||||||
|
|
||||||
defp do_require_assoc(query, :media_profile) do
|
{{:livestream_behaviour, :only}, _} ->
|
||||||
query
|
dynamic([mi], ^dynamic and mi.livestream == true)
|
||||||
|> require_assoc(:source)
|
|
||||||
|> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
|
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
|
||||||
|
dynamic([mi], ^dynamic and mi.short_form_content == false)
|
||||||
|
|
||||||
|
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
|
||||||
|
# return records with livestream: false
|
||||||
|
dynamic([mi], ^dynamic and mi.livestream == false)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
dynamic
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
where(query, ^finders)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media.MediaQuery
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
|
|
||||||
@@ -25,11 +25,8 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
def persisted_media_items_for(source, opts \\ []) do
|
def persisted_media_items_for(source, opts \\ []) do
|
||||||
limit = Keyword.get(opts, :limit, 500)
|
limit = Keyword.get(opts, :limit, 500)
|
||||||
|
|
||||||
MediaQuery.new()
|
source
|
||||||
|> MediaQuery.for_source(source)
|
|> Media.list_downloaded_media_items_for(limit: limit)
|
||||||
|> MediaQuery.with_media_filepath()
|
|
||||||
|> Repo.maybe_limit(limit)
|
|
||||||
|> Repo.all()
|
|
||||||
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -43,25 +43,25 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
xmlns:atom="http://www.w3.org/2005/Atom">
|
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
<channel>
|
<channel>
|
||||||
<title>#{safe(source.custom_name)}</title>
|
<title>#{safe(source.custom_name)}</title>
|
||||||
<link>#{safe(source.original_url)}</link>
|
<link>#{source.original_url}</link>
|
||||||
<description>#{safe(source.description)}</description>
|
<description>#{safe(source.description)}</description>
|
||||||
<category>TV & Film</category>
|
<category>TV & Film</category>
|
||||||
<generator>Generated by Pinchflat</generator>
|
<generator>Generated by Pinchflat</generator>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<lastBuildDate>#{Calendar.strftime(source.updated_at, @datetime_format)}</lastBuildDate>
|
<lastBuildDate>#{Calendar.strftime(source.updated_at, @datetime_format)}</lastBuildDate>
|
||||||
<pubDate>#{Calendar.strftime(source.inserted_at, @datetime_format)}</pubDate>
|
<pubDate>#{Calendar.strftime(source.inserted_at, @datetime_format)}</pubDate>
|
||||||
<atom:link href="#{safe(generate_self_link(url_base, source))}" rel="self" type="application/rss+xml" />
|
<atom:link href="#{generate_self_link(url_base, source)}" rel="self" type="application/rss+xml" />
|
||||||
<podcast:locked>yes</podcast:locked>
|
<podcast:locked>yes</podcast:locked>
|
||||||
<podcast:guid>#{source.uuid}</podcast:guid>
|
<podcast:guid>#{source.uuid}</podcast:guid>
|
||||||
<image>
|
<image>
|
||||||
<url>#{safe(feed_image_path)}</url>
|
<url>#{feed_image_path}</url>
|
||||||
<title>#{safe(source.custom_name)}</title>
|
<title>#{safe(source.custom_name)}</title>
|
||||||
<link>#{safe(source.original_url)}</link>
|
<link>#{source.original_url}</link>
|
||||||
</image>
|
</image>
|
||||||
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
||||||
<itunes:subtitle>#{safe(source.custom_name)}</itunes:subtitle>
|
<itunes:subtitle>#{safe(source.custom_name)}</itunes:subtitle>
|
||||||
<itunes:block>yes</itunes:block>
|
<itunes:block>yes</itunes:block>
|
||||||
<itunes:image href="#{safe(feed_image_path)}"></itunes:image>
|
<itunes:image href="#{feed_image_path}"></itunes:image>
|
||||||
<itunes:explicit>false</itunes:explicit>
|
<itunes:explicit>false</itunes:explicit>
|
||||||
<itunes:category text="TV & Film"></itunes:category>
|
<itunes:category text="TV & Film"></itunes:category>
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
<item>
|
<item>
|
||||||
<guid isPermaLink="false">#{media_item.uuid}</guid>
|
<guid isPermaLink="false">#{media_item.uuid}</guid>
|
||||||
<title>#{safe(media_item.title)}</title>
|
<title>#{safe(media_item.title)}</title>
|
||||||
<link>#{safe(media_item.original_url)}</link>
|
<link>#{media_item.original_url}</link>
|
||||||
<description>#{safe(media_item.description)}</description>
|
<description>#{safe(media_item.description)}</description>
|
||||||
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
||||||
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
|
|
||||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def kickoff_with_task(source, job_args \\ %{}, job_opts \\ []) do
|
def kickoff_with_task(source, opts \\ []) do
|
||||||
%{id: source.id}
|
%{id: source.id}
|
||||||
|> Map.merge(job_args)
|
|> MediaCollectionIndexingWorker.new(opts)
|
||||||
|> MediaCollectionIndexingWorker.new(job_opts)
|
|
||||||
|> Tasks.create_job_with_task(source)
|
|> Tasks.create_job_with_task(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,8 +30,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
|
The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
|
||||||
the provided source, kicks off downloads for each new MediaItem, and
|
the provided source, kicks off downloads for each new MediaItem, and
|
||||||
reschedules the job to run again in the future. It will ALWAYS index a source
|
reschedules the job to run again in the future. It will ALWAYS index a source
|
||||||
if it's never been indexed before or if `force` is set to `true`, but rescheduling
|
if it's never been indexed before, but rescheduling is determined by the
|
||||||
is determined by the `index_frequency_minutes` field.
|
`index_frequency_minutes` field.
|
||||||
|
|
||||||
README: Re-scheduling here works a little different than you may expect.
|
README: Re-scheduling here works a little different than you may expect.
|
||||||
The reschedule time is relative to the time the job has actually _completed_.
|
The reschedule time is relative to the time the job has actually _completed_.
|
||||||
@@ -72,7 +71,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
Returns :ok | {:ok, %Task{}}
|
Returns :ok | {:ok, %Task{}}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => source_id} = args}) do
|
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
||||||
source = Sources.get_source!(source_id)
|
source = Sources.get_source!(source_id)
|
||||||
|
|
||||||
case {source.index_frequency_minutes, source.last_indexed_at} do
|
case {source.index_frequency_minutes, source.last_indexed_at} do
|
||||||
@@ -90,11 +89,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# If the source HAS been indexed and is not meant to reschedule,
|
# If the source HAS been indexed and is not meant to reschedule,
|
||||||
# perform a no-op (unless forced)
|
# perform a no-op
|
||||||
if args["force"] do
|
|
||||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
|
||||||
end
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
|
|
||||||
Returns {:ok, %Task{}}.
|
Returns {:ok, %Task{}}.
|
||||||
"""
|
"""
|
||||||
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
|
def kickoff_indexing_task(%Source{} = source) do
|
||||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
||||||
|
|
||||||
MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts)
|
MediaCollectionIndexingWorker.kickoff_with_task(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
last_indexed_at
|
last_indexed_at
|
||||||
original_url
|
original_url
|
||||||
download_cutoff_date
|
download_cutoff_date
|
||||||
retention_period_days
|
|
||||||
title_filter_regex
|
title_filter_regex
|
||||||
media_profile_id
|
media_profile_id
|
||||||
)a
|
)a
|
||||||
@@ -73,7 +72,6 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
field :last_indexed_at, :utc_datetime
|
field :last_indexed_at, :utc_datetime
|
||||||
# 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 :retention_period_days, :integer
|
|
||||||
field :original_url, :string
|
field :original_url, :string
|
||||||
field :title_filter_regex, :string
|
field :title_filter_regex, :string
|
||||||
|
|
||||||
@@ -108,9 +106,7 @@ 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)
|
||||||
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
||||||
|> validate_required(required_fields)
|
|> validate_required(required_fields)
|
||||||
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|
|
||||||
|> 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, :title_filter_regex], error_key: :original_url)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
defmodule Pinchflat.Sources.SourcesQuery do
|
|
||||||
@moduledoc """
|
|
||||||
Query helpers for the Sources context.
|
|
||||||
|
|
||||||
These methods are made to be one-ish liners used
|
|
||||||
to compose queries. Each method should strive to do
|
|
||||||
_one_ thing. These don't need to be tested as
|
|
||||||
they are just building blocks for other functionality
|
|
||||||
which, itself, will be tested.
|
|
||||||
"""
|
|
||||||
import Ecto.Query, warn: false
|
|
||||||
|
|
||||||
alias Pinchflat.Sources.Source
|
|
||||||
|
|
||||||
# Prefixes:
|
|
||||||
# - for_* - belonging to a certain record
|
|
||||||
# - join_* - for joining on a certain record
|
|
||||||
# - with_* - for filtering based on full, concrete attributes
|
|
||||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
|
||||||
#
|
|
||||||
# Suffixes:
|
|
||||||
# - _for - the arg passed is an association record
|
|
||||||
|
|
||||||
def new do
|
|
||||||
Source
|
|
||||||
end
|
|
||||||
|
|
||||||
def for_media_profile(query, media_profile) do
|
|
||||||
where(query, [s], s.media_profile_id == ^media_profile.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -8,5 +8,4 @@ defmodule Pinchflat.YtDlp.BackendCommandRunner do
|
|||||||
|
|
||||||
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
|
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
|
||||||
@callback run(binary(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
|
@callback run(binary(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
|
||||||
@callback version() :: {:ok, binary()} | {:error, binary()}
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -48,19 +48,6 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl BackendCommandRunner
|
|
||||||
def version do
|
|
||||||
command = backend_executable()
|
|
||||||
|
|
||||||
case System.cmd(command, ["--version"]) do
|
|
||||||
{output, 0} ->
|
|
||||||
{:ok, String.trim(output)}
|
|
||||||
|
|
||||||
{output, _} ->
|
|
||||||
{:error, output}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_cookie_options do
|
defp build_cookie_options do
|
||||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
cookie_file = Path.join(base_dir, "cookies.txt")
|
cookie_file = Path.join(base_dir, "cookies.txt")
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
def tabbed_layout(assigns) do
|
def tabbed_layout(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
x-data="{ openTab: getTabIndex(0), activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
||||||
@hashchange.window="openTab = getTabIndex(openTab)"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
||||||
@@ -23,7 +22,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
<a
|
<a
|
||||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
:for={{tab, idx} <- Enum.with_index(@tab)}
|
||||||
href="#"
|
href="#"
|
||||||
@click.prevent={"openTab = setTabIndex(#{idx})"}
|
@click.prevent={"openTab = #{idx}"}
|
||||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
||||||
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
|
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
|
||||||
>
|
>
|
||||||
@@ -34,7 +33,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
<%= render_slot(@tab_append) %>
|
<%= render_slot(@tab_append) %>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="mt-4 min-h-60">
|
<div class="mt-4">
|
||||||
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
||||||
<%= render_slot(tab) %>
|
<%= render_slot(tab) %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,11 +58,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
<span class="group relative flex items-center gap-2.5 px-4 py-2 text-sm">
|
||||||
Pinchflat v<%= Application.spec(:pinchflat)[:vsn] %>
|
v<%= Application.spec(:pinchflat)[:vsn] %>
|
||||||
</span>
|
|
||||||
<span class="group relative flex items-center gap-2.5 px-4 text-sm">
|
|
||||||
yt-dlp <%= Settings.get!(:yt_dlp_version) %>
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
defmodule PinchflatWeb.HealthController do
|
|
||||||
use PinchflatWeb, :controller
|
|
||||||
|
|
||||||
def check(conn, _params) do
|
|
||||||
conn
|
|
||||||
|> put_status(:ok)
|
|
||||||
|> json(%{status: "ok"})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -6,7 +6,6 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
|
||||||
|
|
||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
media_item =
|
media_item =
|
||||||
@@ -17,46 +16,23 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||||||
render(conn, :show, media_item: media_item)
|
render(conn, :show, media_item: media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit(conn, %{"id" => id}) do
|
|
||||||
media_item = Media.get_media_item!(id)
|
|
||||||
changeset = Media.change_media_item(media_item)
|
|
||||||
|
|
||||||
render(conn, :edit, media_item: media_item, changeset: changeset)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update(conn, %{"id" => id, "media_item" => params}) do
|
|
||||||
media_item = Media.get_media_item!(id)
|
|
||||||
|
|
||||||
case Media.update_media_item(media_item, params) do
|
|
||||||
{:ok, media_item} ->
|
|
||||||
conn
|
|
||||||
|> put_flash(:info, "Media Item updated successfully.")
|
|
||||||
|> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
|
||||||
|
|
||||||
{:error, %Ecto.Changeset{} = changeset} ->
|
|
||||||
render(conn, :edit, media_item: media_item, changeset: changeset)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete(conn, %{"id" => id} = params) do
|
def delete(conn, %{"id" => id} = params) do
|
||||||
prevent_download = Map.get(params, "prevent_download", false)
|
delete_files = Map.get(params, "delete_files", false)
|
||||||
media_item = Media.get_media_item!(id)
|
media_item = Media.get_media_item!(id)
|
||||||
{:ok, _} = Media.delete_media_files(media_item, %{prevent_download: prevent_download})
|
{:ok, _} = Media.delete_media_item(media_item, delete_files: delete_files)
|
||||||
|
|
||||||
|
flash_message =
|
||||||
|
if delete_files do
|
||||||
|
"Record and files deleted successfully."
|
||||||
|
else
|
||||||
|
"Record deleted successfully. Files were not deleted."
|
||||||
|
end
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, "Files deleted successfully.")
|
|> put_flash(:info, flash_message)
|
||||||
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def force_download(conn, %{"media_item_id" => id}) do
|
|
||||||
media_item = Media.get_media_item!(id)
|
|
||||||
{:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, %{force: true})
|
|
||||||
|
|
||||||
conn
|
|
||||||
|> put_flash(:info, "Download task enqueued.")
|
|
||||||
|> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
|
||||||
end
|
|
||||||
|
|
||||||
# See here for details on streaming files and range requests:
|
# See here for details on streaming files and range requests:
|
||||||
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
|
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -3,14 +3,6 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
|
|||||||
|
|
||||||
embed_templates "media_item_html/*"
|
embed_templates "media_item_html/*"
|
||||||
|
|
||||||
@doc """
|
|
||||||
Renders a media item form.
|
|
||||||
"""
|
|
||||||
attr :changeset, Ecto.Changeset, required: true
|
|
||||||
attr :action, :string, required: true
|
|
||||||
|
|
||||||
def media_item_form(assigns)
|
|
||||||
|
|
||||||
def media_file_exists?(media_item) do
|
def media_file_exists?(media_item) do
|
||||||
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
|
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}/force_download"}
|
|
||||||
method="post"
|
|
||||||
data-confirm="Are you sure you force a download of this media?"
|
|
||||||
>
|
|
||||||
Force Download
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<div class="h-px w-full bg-bodydark2"></div>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete all files for this media item? This cannot be undone."
|
|
||||||
>
|
|
||||||
Delete Files
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?prevent_download=true"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete all files for this media item and prevent it from re-downloading in the future? This cannot be undone."
|
|
||||||
>
|
|
||||||
Delete and Ignore
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
</.button_dropdown>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<div class="mb-6 flex gap-3 flex-row items-center">
|
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
|
||||||
Editing "<%= StringUtils.truncate(@media_item.title, 35) %>"
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
|
||||||
<div class="max-w-full overflow-x-auto">
|
|
||||||
<div class="flex flex-col gap-10">
|
|
||||||
<.media_item_form changeset={@changeset} action={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<.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}>
|
|
||||||
Oops, something went wrong! Please check the errors below.
|
|
||||||
</.error>
|
|
||||||
|
|
||||||
<h3 class=" text-2xl text-black dark:text-white">
|
|
||||||
General Options
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<.input
|
|
||||||
field={f[:prevent_download]}
|
|
||||||
type="toggle"
|
|
||||||
label="Prevent Download"
|
|
||||||
help="Checking excludes this media item from automatic download. Download can still be manually forced"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<.input
|
|
||||||
field={f[:prevent_culling]}
|
|
||||||
type="toggle"
|
|
||||||
label="Prevent Automatic Deletion"
|
|
||||||
help="Checking excludes media from being automatically deleted based on media retention rules"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media Item</.button>
|
|
||||||
</.simple_form>
|
|
||||||
@@ -4,23 +4,25 @@
|
|||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||||
<%= StringUtils.truncate(@media_item.title, 35) %>
|
Media Item #<%= @media_item.id %>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav>
|
|
||||||
<.link href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}/edit"}>
|
|
||||||
<.button color="bg-primary" rounding="rounded-lg">
|
|
||||||
<.icon name="hero-pencil-square" class="mr-2" /> Edit
|
|
||||||
</.button>
|
|
||||||
</.link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
|
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
|
||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
<:tab_append>
|
<:tab_append>
|
||||||
<.actions_dropdown media_item={@media_item} />
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<.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."
|
||||||
|
>
|
||||||
|
Delete Files
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
</.button_dropdown>
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
@@ -30,13 +32,12 @@
|
|||||||
<.media_preview media_item={@media_item} />
|
<.media_preview media_item={@media_item} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<h2 class="font-bold text-2xl"><%= @media_item.title %></h2>
|
|
||||||
<h3 class="font-bold text-xl">Attributes</h3>
|
<h3 class="font-bold text-xl">Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Source:</strong>
|
<strong>Source:</strong>
|
||||||
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
<.inline_link href={~p"/sources/#{@media_item.source_id}"}>
|
||||||
<%= @media_item.source.custom_name %>
|
<%= @media_item.source.custom_name %>
|
||||||
</.subtle_link>
|
</.inline_link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.Profiles
|
||||||
alias Pinchflat.Sources.SourcesQuery
|
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
media_profiles =
|
media_profiles = Profiles.list_media_profiles()
|
||||||
MediaProfile
|
|
||||||
|> order_by(asc: :name)
|
|
||||||
|> Repo.all()
|
|
||||||
|
|
||||||
render(conn, :index, media_profiles: media_profiles)
|
render(conn, :index, media_profiles: media_profiles)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -39,15 +32,12 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
media_profile = Profiles.get_media_profile!(id)
|
media_profile =
|
||||||
|
id
|
||||||
|
|> Profiles.get_media_profile!()
|
||||||
|
|> Repo.preload(:sources)
|
||||||
|
|
||||||
sources =
|
render(conn, :show, media_profile: media_profile)
|
||||||
SourcesQuery.new()
|
|
||||||
|> SourcesQuery.for_media_profile(media_profile)
|
|
||||||
|> order_by(asc: :custom_name)
|
|
||||||
|> Repo.all()
|
|
||||||
|
|
||||||
render(conn, :show, media_profile: media_profile, sources: sources)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit(conn, %{"id" => id}) do
|
def edit(conn, %{"id" => id}) do
|
||||||
|
|||||||
@@ -50,11 +50,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Sources">
|
<:tab title="Sources">
|
||||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
|
||||||
<:col :let={source} label="Name">
|
<:col :let={source} label="Name">
|
||||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
<%= source.custom_name || source.collection_name %>
|
||||||
<%= source.custom_name || source.collection_name %>
|
|
||||||
</.subtle_link>
|
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||||
<:col :let={source} label="Should Download?">
|
<:col :let={source} label="Should Download?">
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
<a href={~p"/media_profiles"} class="mt-4 flex flex-col items-center justify-center">
|
<div class="mt-4 flex flex-col items-center justify-center">
|
||||||
<span class="text-md font-medium">Media Profile(s)</span>
|
<span class="text-md font-medium">Media Profile(s)</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @media_profile_count %>
|
<%= @media_profile_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
<a href={~p"/sources"} class="mt-4 flex flex-col items-center justify-center">
|
<div class="mt-4 flex flex-col items-center justify-center">
|
||||||
<span class="text-md font-medium">Source(s)</span>
|
<span class="text-md font-medium">Source(s)</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @source_count %>
|
<%= @source_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
<a href="#" class="mt-4 flex flex-col items-center justify-center">
|
<div class="mt-4 flex flex-col items-center justify-center">
|
||||||
<span class="text-md font-medium">Media Item(s)</span>
|
<span class="text-md font-medium">Media Item(s)</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @media_item_count %>
|
<%= @media_item_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-strokedark">I know this page isn't super useful yet, but give it time :)</span>
|
<span class="text-strokedark">I know this page isn't super useful yet, but give it time :)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
|||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media.MediaQuery
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Podcasts.RssFeedBuilder
|
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
@@ -20,15 +20,10 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
|||||||
|
|
||||||
def feed_image(conn, %{"uuid" => uuid}) do
|
def feed_image(conn, %{"uuid" => uuid}) do
|
||||||
source = Repo.get_by!(Source, uuid: uuid)
|
source = Repo.get_by!(Source, uuid: uuid)
|
||||||
|
# This provides a fallback image if the source has none.
|
||||||
# This is used to fetch a fallback cover image
|
# We only need one since we're using the internal metadata image which
|
||||||
# if the source doesn't have any usable images
|
# we know exists.
|
||||||
media_items =
|
media_items = Media.list_downloaded_media_items_for(source, limit: 1)
|
||||||
MediaQuery.new()
|
|
||||||
|> MediaQuery.for_source(source)
|
|
||||||
|> MediaQuery.with_media_filepath()
|
|
||||||
|> Repo.maybe_limit(1)
|
|
||||||
|> Repo.all()
|
|
||||||
|
|
||||||
case PodcastHelpers.select_cover_image(source, media_items) do
|
case PodcastHelpers.select_cover_image(source, media_items) do
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
|
|||||||
@@ -4,21 +4,15 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.MediaQuery
|
alias Pinchflat.Profiles
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaQuery
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
|
||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
sources =
|
sources = Repo.preload(Sources.list_sources(), :media_profile)
|
||||||
Source
|
|
||||||
|> order_by(asc: :custom_name)
|
|
||||||
|> Repo.all()
|
|
||||||
|> Repo.preload(:media_profile)
|
|
||||||
|
|
||||||
render(conn, :index, sources: sources)
|
render(conn, :index, sources: sources)
|
||||||
end
|
end
|
||||||
@@ -55,26 +49,9 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
source = Repo.preload(Sources.get_source!(id), :media_profile)
|
source = Repo.preload(Sources.get_source!(id), :media_profile)
|
||||||
|
|
||||||
pending_tasks =
|
pending_tasks = Repo.preload(Tasks.list_pending_tasks_for(source), :job)
|
||||||
source
|
pending_media = Media.list_pending_media_items_for(source, limit: 100)
|
||||||
|> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable])
|
downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100)
|
||||||
|> Repo.preload(:job)
|
|
||||||
|
|
||||||
pending_media =
|
|
||||||
MediaQuery.new()
|
|
||||||
|> MediaQuery.for_source(source)
|
|
||||||
|> MediaQuery.with_media_pending_download()
|
|
||||||
|> order_by(desc: :id)
|
|
||||||
|> limit(100)
|
|
||||||
|> Repo.all()
|
|
||||||
|
|
||||||
downloaded_media =
|
|
||||||
MediaQuery.new()
|
|
||||||
|> MediaQuery.for_source(source)
|
|
||||||
|> MediaQuery.with_media_filepath()
|
|
||||||
|> order_by(desc: :id)
|
|
||||||
|> limit(100)
|
|
||||||
|> Repo.all()
|
|
||||||
|
|
||||||
render(conn, :show,
|
render(conn, :show,
|
||||||
source: source,
|
source: source,
|
||||||
@@ -127,30 +104,12 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
|> redirect(to: ~p"/sources")
|
|> redirect(to: ~p"/sources")
|
||||||
end
|
end
|
||||||
|
|
||||||
def force_download(conn, %{"source_id" => id}) do
|
|
||||||
source = Sources.get_source!(id)
|
|
||||||
DownloadingHelpers.enqueue_pending_download_tasks(source)
|
|
||||||
|
|
||||||
conn
|
|
||||||
|> put_flash(:info, "Forced download of pending media items.")
|
|
||||||
|> redirect(to: ~p"/sources/#{source}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def force_index(conn, %{"source_id" => id}) do
|
|
||||||
source = Sources.get_source!(id)
|
|
||||||
SlowIndexingHelpers.kickoff_indexing_task(source, %{force: true})
|
|
||||||
|
|
||||||
conn
|
|
||||||
|> put_flash(:info, "Index enqueued.")
|
|
||||||
|> redirect(to: ~p"/sources/#{source}")
|
|
||||||
end
|
|
||||||
|
|
||||||
defp media_profiles do
|
defp media_profiles do
|
||||||
MediaProfile
|
Profiles.list_media_profiles()
|
||||||
|> order_by(asc: :name)
|
|
||||||
|> Repo.all()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# NOTE: should move this out of the controller
|
||||||
|
# once I finally add some query fragment layer
|
||||||
defp total_downloaded_for(source) do
|
defp total_downloaded_for(source) do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> MediaQuery.for_source(source)
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
|
||||||
<:option>
|
|
||||||
<span
|
|
||||||
x-data="{ copied: false }"
|
|
||||||
x-on:click={"
|
|
||||||
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
|
|
||||||
copied = true
|
|
||||||
setTimeout(() => copied = false, 4000)
|
|
||||||
"}
|
|
||||||
>
|
|
||||||
Copy RSS Feed
|
|
||||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
|
||||||
</span>
|
|
||||||
</:option>
|
|
||||||
<:option :if={@source.download_media}>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@source}/force_download"}
|
|
||||||
method="post"
|
|
||||||
data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed."
|
|
||||||
>
|
|
||||||
Force Download
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@source}/force_index"}
|
|
||||||
method="post"
|
|
||||||
data-confirm="Are you sure you want to force an index of this source? This isn't normally needed."
|
|
||||||
>
|
|
||||||
Force Index
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<div class="h-px w-full bg-bodydark2"></div>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.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."
|
|
||||||
>
|
|
||||||
Delete Source
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.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"
|
|
||||||
>
|
|
||||||
Delete Source + Files
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
</.button_dropdown>
|
|
||||||
@@ -22,13 +22,6 @@
|
|||||||
<:col :let={source} label="Should Download?">
|
<:col :let={source} label="Should Download?">
|
||||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={source} label="Retention">
|
|
||||||
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
|
||||||
<%= source.retention_period_days %> day(s)
|
|
||||||
<% else %>
|
|
||||||
<span class="text-lg">∞</span>
|
|
||||||
<% end %>
|
|
||||||
</:col>
|
|
||||||
<:col :let={source} label="Media Profile">
|
<:col :let={source} label="Media Profile">
|
||||||
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
||||||
<%= source.media_profile.name %>
|
<%= source.media_profile.name %>
|
||||||
|
|||||||
@@ -20,7 +20,43 @@
|
|||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
<:tab_append>
|
<:tab_append>
|
||||||
<.actions_dropdown source={@source} conn={@conn} />
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<span
|
||||||
|
x-data="{ copied: false }"
|
||||||
|
x-on:click={"
|
||||||
|
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
|
||||||
|
copied = true
|
||||||
|
setTimeout(() => copied = false, 4000)
|
||||||
|
"}
|
||||||
|
>
|
||||||
|
Copy RSS Feed
|
||||||
|
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
|
</span>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.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."
|
||||||
|
>
|
||||||
|
Delete Source
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.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"
|
||||||
|
>
|
||||||
|
Delete Source + Files
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
</.button_dropdown>
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
@@ -28,9 +64,9 @@
|
|||||||
<h3 class="font-bold text-lg">Attributes</h3>
|
<h3 class="font-bold text-lg">Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Media Profile:</strong>
|
<strong>Media Profile:</strong>
|
||||||
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
<.inline_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
||||||
<%= @source.media_profile.name %>
|
<%= @source.media_profile.name %>
|
||||||
</.subtle_link>
|
</.inline_link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||||
@@ -41,17 +77,10 @@
|
|||||||
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4>
|
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4>
|
||||||
<.table rows={@pending_media} table_class="text-black dark:text-white">
|
<.table rows={@pending_media} table_class="text-black dark:text-white">
|
||||||
<:col :let={media_item} label="Title">
|
<:col :let={media_item} label="Title">
|
||||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
|
||||||
</.subtle_link>
|
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
<:col :let={media_item} label="" class="flex place-content-evenly">
|
||||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
|
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
|
||||||
<.icon_link
|
|
||||||
href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"}
|
|
||||||
icon="hero-pencil-square"
|
|
||||||
class="mx-1"
|
|
||||||
/>
|
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -63,17 +92,10 @@
|
|||||||
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items (<%= @total_downloaded %> total)</h4>
|
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items (<%= @total_downloaded %> total)</h4>
|
||||||
<.table rows={@downloaded_media} table_class="text-black dark:text-white">
|
<.table rows={@downloaded_media} table_class="text-black dark:text-white">
|
||||||
<:col :let={media_item} label="Title">
|
<:col :let={media_item} label="Title">
|
||||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
|
||||||
</.subtle_link>
|
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
<:col :let={media_item} label="" class="flex place-content-evenly">
|
||||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
|
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
|
||||||
<.icon_link
|
|
||||||
href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"}
|
|
||||||
icon="hero-pencil-square"
|
|
||||||
class="mx-1"
|
|
||||||
/>
|
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -88,14 +88,6 @@
|
|||||||
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.input
|
|
||||||
field={f[:retention_period_days]}
|
|
||||||
type="number"
|
|
||||||
label="Retention Period (days)"
|
|
||||||
min="0"
|
|
||||||
help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section x-show="advancedMode">
|
<section x-show="advancedMode">
|
||||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||||
Advanced Options
|
Advanced Options
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ defmodule PinchflatWeb.Endpoint do
|
|||||||
plug Plug.Static,
|
plug Plug.Static,
|
||||||
at: "/",
|
at: "/",
|
||||||
from: :pinchflat,
|
from: :pinchflat,
|
||||||
gzip: Mix.env() == :prod,
|
gzip: false,
|
||||||
only: PinchflatWeb.static_paths()
|
only: PinchflatWeb.static_paths()
|
||||||
|
|
||||||
# Code reloading can be explicitly enabled under the
|
# Code reloading can be explicitly enabled under the
|
||||||
|
|||||||
+14
-23
@@ -1,6 +1,5 @@
|
|||||||
defmodule PinchflatWeb.Router do
|
defmodule PinchflatWeb.Router do
|
||||||
use PinchflatWeb, :router
|
use PinchflatWeb, :router
|
||||||
import Phoenix.LiveDashboard.Router
|
|
||||||
|
|
||||||
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
||||||
# the extension from the path
|
# the extension from the path
|
||||||
@@ -12,7 +11,6 @@ defmodule PinchflatWeb.Router do
|
|||||||
plug :put_root_layout, html: {PinchflatWeb.Layouts, :root}
|
plug :put_root_layout, html: {PinchflatWeb.Layouts, :root}
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
plug :allow_iframe_embed
|
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
@@ -32,12 +30,7 @@ defmodule PinchflatWeb.Router do
|
|||||||
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
||||||
|
|
||||||
resources "/sources", Sources.SourceController do
|
resources "/sources", Sources.SourceController do
|
||||||
post "/force_download", Sources.SourceController, :force_download
|
resources "/media", MediaItems.MediaItemController, only: [:show, :delete]
|
||||||
post "/force_index", Sources.SourceController, :force_index
|
|
||||||
|
|
||||||
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] do
|
|
||||||
post "/force_download", MediaItems.MediaItemController, :force_download
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,19 +45,21 @@ defmodule PinchflatWeb.Router do
|
|||||||
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
||||||
end
|
end
|
||||||
|
|
||||||
# No auth or CSRF protection for the health check endpoint
|
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||||
scope "/", PinchflatWeb do
|
if Application.compile_env(:pinchflat, :dev_routes) do
|
||||||
pipe_through :api
|
# If you want to use the LiveDashboard in production, you should put
|
||||||
|
# it behind authentication and allow only admins to access it.
|
||||||
|
# If your application does not have an admins-only section yet,
|
||||||
|
# you can use Plug.BasicAuth to set up some basic authentication
|
||||||
|
# as long as you are also using SSL (which you should anyway).
|
||||||
|
import Phoenix.LiveDashboard.Router
|
||||||
|
|
||||||
get "/healthcheck", HealthController, :check
|
scope "/dev" do
|
||||||
end
|
pipe_through :browser
|
||||||
|
|
||||||
scope "/dev" do
|
live_dashboard "/dashboard", metrics: PinchflatWeb.Telemetry
|
||||||
pipe_through :browser
|
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||||
|
end
|
||||||
live_dashboard "/dashboard",
|
|
||||||
metrics: PinchflatWeb.Telemetry,
|
|
||||||
ecto_repos: [Pinchflat.Repo]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_basic_auth(conn, opts) do
|
defp maybe_basic_auth(conn, opts) do
|
||||||
@@ -89,8 +84,4 @@ defmodule PinchflatWeb.Router do
|
|||||||
defp credential_set?(credential) do
|
defp credential_set?(credential) do
|
||||||
credential && credential != ""
|
credential && credential != ""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp allow_iframe_embed(conn, _opts) do
|
|
||||||
delete_resp_header(conn, "x-frame-options")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "0.1.8",
|
version: "0.1.5",
|
||||||
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,
|
||||||
@@ -33,7 +33,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def application do
|
def application do
|
||||||
[
|
[
|
||||||
mod: {Pinchflat.Application, []},
|
mod: {Pinchflat.Application, []},
|
||||||
extra_applications: [:logger, :runtime_tools, :inets, :os_mon]
|
extra_applications: [:logger, :runtime_tools, :inets]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -50,7 +50,6 @@ defmodule Pinchflat.MixProject do
|
|||||||
{:phoenix_ecto, "~> 4.4"},
|
{:phoenix_ecto, "~> 4.4"},
|
||||||
{:ecto_sql, "~> 3.10"},
|
{:ecto_sql, "~> 3.10"},
|
||||||
{:ecto_sqlite3, ">= 0.0.0"},
|
{:ecto_sqlite3, ">= 0.0.0"},
|
||||||
{:ecto_sqlite3_extras, "~> 1.2.0"},
|
|
||||||
{:phoenix_html, "~> 3.3"},
|
{:phoenix_html, "~> 3.3"},
|
||||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||||
{:phoenix_live_view, "~> 0.20.1"},
|
{:phoenix_live_view, "~> 0.20.1"},
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
|
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
|
||||||
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
|
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
|
||||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"},
|
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"},
|
||||||
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
|
|
||||||
"elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"},
|
"elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"},
|
||||||
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
|
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
|
||||||
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
||||||
@@ -47,7 +46,6 @@
|
|||||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||||
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
|
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
|
||||||
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
|
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
|
||||||
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"},
|
|
||||||
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
|
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
|
||||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
||||||
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
|
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.ReReAddSourceUniquenessIndex do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def up do
|
|
||||||
execute """
|
|
||||||
CREATE UNIQUE INDEX sources_collection_id_media_profile_id_title_filter_regex_index ON sources (
|
|
||||||
collection_id,
|
|
||||||
media_profile_id,
|
|
||||||
IFNULL(title_filter_regex, '')
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
def down do
|
|
||||||
execute """
|
|
||||||
DROP INDEX sources_collection_id_media_profile_id_title_filter_regex_index;
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.AddPreventDownloadToMediaItems do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def change do
|
|
||||||
alter table(:media_items) do
|
|
||||||
add :prevent_download, :boolean, default: false, null: false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.AddRetentionPeriodToSources do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def change do
|
|
||||||
alter table(:sources) do
|
|
||||||
add :retention_period_days, :integer
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.AddCulledAtToMediaItems do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def change do
|
|
||||||
alter table(:media_items) do
|
|
||||||
add :culled_at, :utc_datetime
|
|
||||||
add :prevent_culling, :boolean, default: false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -95,9 +95,11 @@ ENV LANGUAGE en_US:en
|
|||||||
ENV LC_ALL en_US.UTF-8
|
ENV LC_ALL en_US.UTF-8
|
||||||
|
|
||||||
WORKDIR "/app"
|
WORKDIR "/app"
|
||||||
|
RUN chown nobody /app
|
||||||
|
|
||||||
# Set up data volumes
|
# Set up data volumes
|
||||||
RUN mkdir /config /downloads
|
RUN mkdir /config /downloads
|
||||||
|
RUN chown nobody /config /downloads
|
||||||
|
|
||||||
# set runner ENV
|
# set runner ENV
|
||||||
ENV MIX_ENV="prod"
|
ENV MIX_ENV="prod"
|
||||||
@@ -106,7 +108,7 @@ ENV RUN_CONTEXT="selfhosted"
|
|||||||
EXPOSE ${PORT}
|
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 --chown=nobody:root /app/_build/${MIX_ENV}/rel/pinchflat ./
|
||||||
|
|
||||||
# NEVER do this if you're running in an environment where you don't trust the user
|
# 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.
|
# (ie: most environments). This is only acceptable in a self-hosted environment.
|
||||||
@@ -119,13 +121,12 @@ COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
|||||||
# root, use --user 0:0 or something.
|
# root, use --user 0:0 or something.
|
||||||
RUN passwd -d root
|
RUN passwd -d root
|
||||||
|
|
||||||
|
USER nobody
|
||||||
|
|
||||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||||
# advised to add an init process such as tini via `apt-get install`
|
# advised to add an init process such as tini via `apt-get install`
|
||||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||||
# ENTRYPOINT ["/tini", "--"]
|
# ENTRYPOINT ["/tini", "--"]
|
||||||
|
|
||||||
HEALTHCHECK --interval=120s --start-period=10s \
|
|
||||||
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
|
|
||||||
|
|
||||||
# Start the app
|
# Start the app
|
||||||
CMD ["/app/bin/docker_start"]
|
CMD ["/app/bin/docker_start"]
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
defmodule Pinchflat.Boot.DataBackfillWorkerTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
alias Pinchflat.Boot.DataBackfillWorker
|
||||||
|
alias Pinchflat.JobFixtures.TestJobWorker
|
||||||
|
|
||||||
|
describe "cancel_pending_backfill_jobs/0" do
|
||||||
|
test "cancels all pending backfill jobs" do
|
||||||
|
%{}
|
||||||
|
|> DataBackfillWorker.new()
|
||||||
|
|> Repo.insert_unique_job()
|
||||||
|
|
||||||
|
assert_enqueued(worker: DataBackfillWorker)
|
||||||
|
|
||||||
|
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||||
|
|
||||||
|
refute_enqueued(worker: DataBackfillWorker)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not cancel jobs for other workers" do
|
||||||
|
%{id: 0}
|
||||||
|
|> TestJobWorker.new()
|
||||||
|
|> Repo.insert_unique_job()
|
||||||
|
|
||||||
|
assert_enqueued(worker: TestJobWorker)
|
||||||
|
|
||||||
|
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||||
|
|
||||||
|
assert_enqueued(worker: TestJobWorker)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
setup do
|
||||||
|
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reschedules itself once complete" do
|
||||||
|
perform_job(DataBackfillWorker, %{})
|
||||||
|
|
||||||
|
assert_enqueued(worker: DataBackfillWorker, scheduled_at: now_plus(60, :minutes))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -34,23 +34,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item)
|
assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item)
|
||||||
assert task.media_item_id == media_item.id
|
assert task.media_item_id == media_item.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can be called with additional job arguments", %{media_item: media_item} do
|
|
||||||
job_args = %{"force" => true}
|
|
||||||
|
|
||||||
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, job_args)
|
|
||||||
|
|
||||||
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id, "force" => true})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can be called with additional job options", %{media_item: media_item} do
|
|
||||||
job_opts = [max_attempts: 5]
|
|
||||||
|
|
||||||
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, %{}, job_opts)
|
|
||||||
|
|
||||||
[job] = all_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
|
||||||
assert job.max_attempts == 5
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
@@ -91,12 +74,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it ensures error are returned in a 2-item tuple", %{media_item: media_item} do
|
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error", 1} end)
|
|
||||||
|
|
||||||
assert {:error, :download_failed} = perform_job(MediaDownloadWorker, %{id: media_item.id})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "it does not download if the source is set to not download", %{media_item: media_item} do
|
test "it does not download if the source is set to not download", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> :ok end)
|
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> :ok end)
|
||||||
|
|
||||||
@@ -105,14 +82,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "downloads anyway if forced", %{media_item: media_item} do
|
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> :ok end)
|
|
||||||
|
|
||||||
Sources.update_source(media_item.source, %{download_media: false})
|
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "it saves the file's size to the database", %{media_item: media_item} do
|
test "it saves the file's size to the database", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||||
metadata = render_parsed_metadata(:media_metadata)
|
metadata = render_parsed_metadata(:media_metadata)
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|
||||||
use Pinchflat.DataCase
|
|
||||||
|
|
||||||
import Pinchflat.MediaFixtures
|
|
||||||
import Pinchflat.SourcesFixtures
|
|
||||||
|
|
||||||
alias Pinchflat.Media
|
|
||||||
alias Pinchflat.Downloading.MediaRetentionWorker
|
|
||||||
|
|
||||||
describe "perform/1" do
|
|
||||||
test "deletes media files that are past their retention date" do
|
|
||||||
{_source, old_media_item, new_media_item} = prepare_records()
|
|
||||||
|
|
||||||
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 deleted media to not re-download" do
|
|
||||||
{_source, old_media_item, new_media_item} = prepare_records()
|
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
|
||||||
|
|
||||||
refute Repo.reload!(new_media_item).prevent_download
|
|
||||||
assert Repo.reload!(old_media_item).prevent_download
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sets culled_at timestamp on deleted media" do
|
|
||||||
{_source, old_media_item, new_media_item} = prepare_records()
|
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
|
||||||
|
|
||||||
refute Repo.reload!(new_media_item).culled_at
|
|
||||||
assert Repo.reload!(old_media_item).culled_at
|
|
||||||
assert DateTime.diff(now(), Repo.reload!(old_media_item).culled_at) < 1
|
|
||||||
end
|
|
||||||
|
|
||||||
test "doesn't cull media items that have prevent_culling set" do
|
|
||||||
{_source, old_media_item, _new_media_item} = prepare_records()
|
|
||||||
|
|
||||||
Media.update_media_item(old_media_item, %{prevent_culling: true})
|
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
|
||||||
|
|
||||||
assert File.exists?(old_media_item.media_filepath)
|
|
||||||
assert Repo.reload!(old_media_item).media_filepath
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp prepare_records do
|
|
||||||
source = source_fixture(%{retention_period_days: 2})
|
|
||||||
|
|
||||||
old_media_item =
|
|
||||||
media_item_with_attachments(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
new_media_item =
|
|
||||||
media_item_with_attachments(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_downloaded_at: now_minus(1, :day)
|
|
||||||
})
|
|
||||||
|
|
||||||
{source, old_media_item, new_media_item}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+22
-166
@@ -38,98 +38,6 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_cullable_media_items/0" do
|
|
||||||
test "returns media items where the source has a retention period" do
|
|
||||||
source_one = source_fixture(%{retention_period_days: 2})
|
|
||||||
source_two = source_fixture(%{retention_period_days: 0})
|
|
||||||
source_three = source_fixture(%{retention_period_days: nil})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source_two.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source_three.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
expected_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source_one.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert Media.list_cullable_media_items() == [expected_media_item]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns media_items with a media_filepath" do
|
|
||||||
source = source_fixture(%{retention_period_days: 2})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: nil,
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
expected_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert Media.list_cullable_media_items() == [expected_media_item]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns items that have passed their retention period" do
|
|
||||||
source = source_fixture(%{retention_period_days: 2})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(2, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
expected_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert Media.list_cullable_media_items() == [expected_media_item]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "doesn't return items that are set to prevent culling" do
|
|
||||||
source = source_fixture(%{retention_period_days: 2})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days),
|
|
||||||
prevent_culling: true
|
|
||||||
})
|
|
||||||
|
|
||||||
expected_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert Media.list_cullable_media_items() == [expected_media_item]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "list_pending_media_items_for/1" do
|
describe "list_pending_media_items_for/1" do
|
||||||
test "it returns pending without a filepath for a given source" do
|
test "it returns pending without a filepath for a given source" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
@@ -149,6 +57,14 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
assert Media.list_pending_media_items_for(source) == []
|
assert Media.list_pending_media_items_for(source) == []
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "list_pending_media_items_for/1 when testing shorts" do
|
describe "list_pending_media_items_for/1 when testing shorts" do
|
||||||
@@ -317,13 +233,22 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_pending_media_items_for/1 when testing download prevention" do
|
describe "list_downloaded_media_items_for/1" do
|
||||||
test "returns only media items that are not prevented from downloading" do
|
test "returns only media items with a media_filepath" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
_prevented_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, prevent_download: true})
|
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
||||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, prevent_download: false})
|
media_item = media_item_fixture(%{source_id: source.id, media_filepath: "/video/#{Faker.File.file_name(:video)}"})
|
||||||
|
|
||||||
assert Media.list_pending_media_items_for(source) == [media_item]
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -395,18 +320,6 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
assert Media.pending_download?(media_item)
|
assert Media.pending_download?(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns true if the media item is not prevented from downloading" do
|
|
||||||
media_item = media_item_fixture(%{media_filepath: nil, prevent_download: false})
|
|
||||||
|
|
||||||
assert Media.pending_download?(media_item)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns false if the media item is prevented from downloading" do
|
|
||||||
media_item = media_item_fixture(%{media_filepath: nil, prevent_download: true})
|
|
||||||
|
|
||||||
refute Media.pending_download?(media_item)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "search/1" do
|
describe "search/1" do
|
||||||
@@ -674,63 +587,6 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "delete_media_files/2" do
|
|
||||||
test "does not delete the media_item" do
|
|
||||||
media_item = media_item_fixture()
|
|
||||||
|
|
||||||
assert {:ok, %MediaItem{}} = Media.delete_media_files(media_item)
|
|
||||||
assert Repo.reload!(media_item)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "deletes attached tasks" do
|
|
||||||
media_item = media_item_fixture()
|
|
||||||
task = task_fixture(%{media_item_id: media_item.id})
|
|
||||||
|
|
||||||
assert {:ok, %MediaItem{}} = Media.delete_media_files(media_item)
|
|
||||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "deletes the media_item's files" do
|
|
||||||
media_item = media_item_with_attachments()
|
|
||||||
|
|
||||||
assert File.exists?(media_item.media_filepath)
|
|
||||||
assert {:ok, _} = Media.delete_media_files(media_item)
|
|
||||||
refute File.exists?(media_item.media_filepath)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not delete the media item's metadata files" do
|
|
||||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
|
||||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
|
||||||
|
|
||||||
update_attrs = %{
|
|
||||||
metadata: %{
|
|
||||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
|
||||||
thumbnail_filepath:
|
|
||||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, %{
|
|
||||||
"thumbnail" => "https://example.com/thumbnail.jpg"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, updated_media_item} = Media.update_media_item(media_item, update_attrs)
|
|
||||||
metadata = Repo.preload(updated_media_item, :metadata).metadata
|
|
||||||
|
|
||||||
assert {:ok, _} = Media.delete_media_files(updated_media_item)
|
|
||||||
assert Repo.reload(metadata)
|
|
||||||
assert File.exists?(updated_media_item.metadata.metadata_filepath)
|
|
||||||
|
|
||||||
# cleanup
|
|
||||||
Media.delete_media_item(updated_media_item, delete_files: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can take additional attributes update media item" do
|
|
||||||
media_item = media_item_with_attachments()
|
|
||||||
|
|
||||||
assert {:ok, updated_media_item} = Media.delete_media_files(media_item, %{prevent_download: true})
|
|
||||||
assert updated_media_item.prevent_download
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "change_media_item/1" do
|
describe "change_media_item/1" do
|
||||||
test "change_media_item/1 returns a media_item changeset" do
|
test "change_media_item/1 returns a media_item changeset" do
|
||||||
media_item = media_item_fixture()
|
media_item = media_item_fixture()
|
||||||
|
|||||||
@@ -14,42 +14,6 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
||||||
describe "kickoff_with_task/3" do
|
|
||||||
setup do
|
|
||||||
source = source_fixture(index_frequency_minutes: 10)
|
|
||||||
|
|
||||||
{:ok, %{source: source}}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "starts the worker", %{source: source} do
|
|
||||||
assert [] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
|
||||||
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source)
|
|
||||||
assert [_] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "attaches a task", %{source: source} do
|
|
||||||
assert {:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source)
|
|
||||||
assert task.source_id == source.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can be called with additional job arguments", %{source: source} do
|
|
||||||
job_args = %{"force" => true}
|
|
||||||
|
|
||||||
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source, job_args)
|
|
||||||
|
|
||||||
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id, "force" => true})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can be called with additional job options", %{source: source} do
|
|
||||||
job_opts = [max_attempts: 5]
|
|
||||||
|
|
||||||
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source, %{}, job_opts)
|
|
||||||
|
|
||||||
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
|
||||||
assert job.max_attempts == 5
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
test "it indexes the source if it should be indexed" do
|
test "it indexes the source if it should be indexed" do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
@@ -67,14 +31,6 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it indexes the source no matter what if the 'force' arg is passed" do
|
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
|
||||||
|
|
||||||
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
|
|
||||||
|
|
||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
|
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
|
||||||
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
||||||
describe "kickoff_indexing_task/3" do
|
describe "kickoff_indexing_task/1" do
|
||||||
test "it schedules a job" do
|
test "it schedules a job" do
|
||||||
source = source_fixture(index_frequency_minutes: 1)
|
source = source_fixture(index_frequency_minutes: 1)
|
||||||
|
|
||||||
@@ -64,25 +64,6 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
|
|
||||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can be called with additional job arguments" do
|
|
||||||
source = source_fixture(index_frequency_minutes: 1)
|
|
||||||
job_args = %{"force" => true}
|
|
||||||
|
|
||||||
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source, job_args)
|
|
||||||
|
|
||||||
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id, "force" => true})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can be called with additional job options" do
|
|
||||||
source = source_fixture(index_frequency_minutes: 1)
|
|
||||||
job_opts = [max_attempts: 5]
|
|
||||||
|
|
||||||
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source, %{}, job_opts)
|
|
||||||
|
|
||||||
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
|
||||||
assert job.max_attempts == 5
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "index_and_enqueue_download_for_media_items/1" do
|
describe "index_and_enqueue_download_for_media_items/1" do
|
||||||
|
|||||||
@@ -137,75 +137,6 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
assert source.custom_name == "some channel name"
|
assert source.custom_name == "some channel name"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creation enforces uniqueness of collection_id scoped to the media_profile and title regex" 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",
|
|
||||||
title_filter_regex: nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 and profiles as long as the regex 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 = %{
|
|
||||||
media_profile_id: media_profile_fixture().id,
|
|
||||||
name: "some name",
|
|
||||||
original_url: "https://www.youtube.com/channel/abc123"
|
|
||||||
}
|
|
||||||
|
|
||||||
source_1_attrs = Map.merge(valid_attrs, %{title_filter_regex: "foo"})
|
|
||||||
source_2_attrs = Map.merge(valid_attrs, %{title_filter_regex: "bar"})
|
|
||||||
|
|
||||||
assert {:ok, %Source{}} = Sources.create_source(source_1_attrs)
|
|
||||||
assert {:ok, %Source{}} = Sources.create_source(source_2_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",
|
|
||||||
title_filter_regex: "TEST"
|
|
||||||
}
|
|
||||||
|
|
||||||
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 "collection_type is inferred from source details" do
|
test "collection_type is inferred from source details" do
|
||||||
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||||
expect(YtDlpRunnerMock, :run, &playlist_mock/3)
|
expect(YtDlpRunnerMock, :run, &playlist_mock/3)
|
||||||
|
|||||||
@@ -102,14 +102,6 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "version/0" do
|
|
||||||
test "adds the version arg" do
|
|
||||||
assert {:ok, output} = Runner.version()
|
|
||||||
|
|
||||||
assert String.contains?(output, "--version")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp wrap_executable(new_executable, fun) do
|
defp wrap_executable(new_executable, fun) do
|
||||||
Application.put_env(:pinchflat, :yt_dlp_executable, new_executable)
|
Application.put_env(:pinchflat, :yt_dlp_executable, new_executable)
|
||||||
fun.()
|
fun.()
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
defmodule PinchflatWeb.HealthControllerTest do
|
|
||||||
use PinchflatWeb.ConnCase
|
|
||||||
|
|
||||||
describe "GET /healthcheck" do
|
|
||||||
test "returns ok", %{conn: conn} do
|
|
||||||
conn = get(conn, "/healthcheck")
|
|
||||||
assert json_response(conn, 200) == %{"status" => "ok"}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -4,65 +4,33 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
|||||||
import Pinchflat.MediaFixtures
|
import Pinchflat.MediaFixtures
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
|
||||||
|
|
||||||
describe "show media" do
|
describe "show media" do
|
||||||
setup [:create_media_item]
|
setup [:create_media_item]
|
||||||
|
|
||||||
test "renders the page", %{conn: conn, media_item: media_item} do
|
test "renders the page", %{conn: conn, media_item: media_item} do
|
||||||
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
||||||
|
assert html_response(conn, 200) =~ "Media Item ##{media_item.id}"
|
||||||
assert html_response(conn, 200) =~ "#{media_item.title}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "edit media" do
|
describe "delete media when just deleting the records" do
|
||||||
setup [:create_media_item]
|
|
||||||
|
|
||||||
test "renders form for editing chosen media_item", %{conn: conn, media_item: media_item} do
|
|
||||||
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}/edit")
|
|
||||||
|
|
||||||
assert html_response(conn, 200) =~ "Editing"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "update media" do
|
|
||||||
setup [:create_media_item]
|
|
||||||
|
|
||||||
test "redirects when data is valid", %{conn: conn, media_item: media_item} do
|
|
||||||
update_attrs = %{title: "New Title"}
|
|
||||||
|
|
||||||
conn = put(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}", media_item: update_attrs)
|
|
||||||
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}/media/#{media_item}"
|
|
||||||
|
|
||||||
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
|
||||||
assert html_response(conn, 200) =~ update_attrs[:title]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "renders errors when data is invalid", %{conn: conn, media_item: media_item} do
|
|
||||||
conn = put(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}", media_item: %{title: nil})
|
|
||||||
|
|
||||||
assert html_response(conn, 200) =~ "Editing"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "delete media" do
|
|
||||||
setup do
|
setup do
|
||||||
media_item = media_item_with_attachments()
|
media_item = media_item_with_attachments()
|
||||||
|
|
||||||
%{media_item: media_item}
|
%{media_item: media_item}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "the media item not is deleted", %{conn: conn, media_item: media_item} do
|
test "the media item is deleted", %{conn: conn, media_item: media_item} do
|
||||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
|
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
|
||||||
|
|
||||||
assert Repo.reload!(media_item)
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "the files are deleted", %{conn: conn, media_item: media_item} do
|
test "the files are not deleted", %{conn: conn, media_item: media_item} do
|
||||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
|
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
|
||||||
|
|
||||||
refute File.exists?(media_item.media_filepath)
|
assert File.exists?(media_item.media_filepath)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "redirects to the source page", %{conn: conn, media_item: media_item} do
|
test "redirects to the source page", %{conn: conn, media_item: media_item} do
|
||||||
@@ -70,46 +38,31 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
|||||||
|
|
||||||
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}"
|
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "doesn't prevent re-download by default", %{conn: conn, media_item: media_item} do
|
|
||||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
|
|
||||||
|
|
||||||
media_item = Repo.reload(media_item)
|
|
||||||
|
|
||||||
refute media_item.prevent_download
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can optionally prevent re-download", %{conn: conn, media_item: media_item} do
|
|
||||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?prevent_download=true")
|
|
||||||
|
|
||||||
media_item = Repo.reload(media_item)
|
|
||||||
|
|
||||||
assert media_item.prevent_download
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "force_download" do
|
describe "delete media when deleting the records and files" do
|
||||||
test "enqueues download task", %{conn: conn} do
|
setup do
|
||||||
media_item = media_item_fixture()
|
media_item = media_item_with_attachments()
|
||||||
|
|
||||||
assert [] = all_enqueued(worker: MediaDownloadWorker)
|
%{media_item: media_item}
|
||||||
post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
|
|
||||||
assert [_] = all_enqueued(worker: MediaDownloadWorker)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "forces a download even if one wouldn't normally run", %{conn: conn} do
|
test "the media item is deleted", %{conn: conn, media_item: media_item} do
|
||||||
media_item = media_item_fixture(%{media_filepath: nil})
|
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
|
||||||
|
|
||||||
post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
|
||||||
assert [_] = all_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id, "force" => true})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "redirects to the show page", %{conn: conn} do
|
test "the files are deleted", %{conn: conn, media_item: media_item} do
|
||||||
media_item = media_item_fixture()
|
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
|
||||||
|
|
||||||
conn = post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
|
refute File.exists?(media_item.media_filepath)
|
||||||
|
end
|
||||||
|
|
||||||
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}/media/#{media_item.id}"
|
test "redirects to the source page", %{conn: conn, media_item: media_item} do
|
||||||
|
conn = delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ defmodule PinchflatWeb.SourceControllerTest do
|
|||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
|
||||||
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
media_profile = media_profile_fixture()
|
media_profile = media_profile_fixture()
|
||||||
@@ -162,59 +160,6 @@ defmodule PinchflatWeb.SourceControllerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "force_download" do
|
|
||||||
test "enqueues pending download tasks", %{conn: conn} do
|
|
||||||
source = source_fixture()
|
|
||||||
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
|
||||||
|
|
||||||
assert [] = all_enqueued(worker: MediaDownloadWorker)
|
|
||||||
post(conn, ~p"/sources/#{source.id}/force_download")
|
|
||||||
assert [_] = all_enqueued(worker: MediaDownloadWorker)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "redirects to the source page", %{conn: conn} do
|
|
||||||
source = source_fixture()
|
|
||||||
|
|
||||||
conn = post(conn, ~p"/sources/#{source.id}/force_download")
|
|
||||||
assert redirected_to(conn) == ~p"/sources/#{source.id}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "force_index" do
|
|
||||||
test "forces an index", %{conn: conn} do
|
|
||||||
source = source_fixture()
|
|
||||||
|
|
||||||
assert [] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
|
||||||
post(conn, ~p"/sources/#{source.id}/force_index")
|
|
||||||
assert [_] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "forces an index even if one wouldn't normally run", %{conn: conn} do
|
|
||||||
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
|
|
||||||
|
|
||||||
post(conn, ~p"/sources/#{source.id}/force_index")
|
|
||||||
assert [job] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
|
||||||
assert job.args == %{"id" => source.id, "force" => true}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "deletes pending indexing tasks", %{conn: conn} do
|
|
||||||
source = source_fixture()
|
|
||||||
{:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source)
|
|
||||||
job = Repo.preload(task, :job).job
|
|
||||||
|
|
||||||
assert job.state == "available"
|
|
||||||
post(conn, ~p"/sources/#{source.id}/force_index")
|
|
||||||
assert Repo.reload!(job).state == "cancelled"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "redirects to the source page", %{conn: conn} do
|
|
||||||
source = source_fixture()
|
|
||||||
|
|
||||||
conn = post(conn, ~p"/sources/#{source.id}/force_index")
|
|
||||||
assert redirected_to(conn) == ~p"/sources/#{source.id}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp create_source(_) do
|
defp create_source(_) do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
media_item = media_item_with_attachments(%{source_id: source.id})
|
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ defmodule PinchflatWeb.ConnCase do
|
|||||||
quote do
|
quote do
|
||||||
# The default endpoint for testing
|
# The default endpoint for testing
|
||||||
@endpoint PinchflatWeb.Endpoint
|
@endpoint PinchflatWeb.Endpoint
|
||||||
alias Pinchflat.Repo
|
|
||||||
|
|
||||||
use PinchflatWeb, :verified_routes
|
use PinchflatWeb, :verified_routes
|
||||||
use Oban.Testing, repo: Repo
|
|
||||||
|
|
||||||
# Import conveniences for testing with connections
|
# Import conveniences for testing with connections
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|||||||
@@ -12,9 +12,5 @@ for ((i = 1; i <= $#; i++)); do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "${!i}" == "--print-to-file" ]; then
|
# Write all args to the file
|
||||||
# Write all args to the file
|
echo "$@" >"$file_location"
|
||||||
echo "$@" >"$file_location"
|
|
||||||
else
|
|
||||||
echo "$@"
|
|
||||||
fi
|
|
||||||
|
|||||||
Reference in New Issue
Block a user