Compare commits

..

40 Commits

Author SHA1 Message Date
Kieran Eglin a4d5f45edc Bumped version 2024-04-16 17:38:59 -07:00
Kieran 618711691b [Bugfix]: Misc. bugfixes 2024-04-16 (#189)
* Manually installed mutagen

* Stopped upgrade form from submitting on enter

* Gracefully handle duplicate enqueued downloads

* Update metadata thumbnail fetcher to use the best jpg available
2024-04-16 17:37:39 -07:00
Kieran 4721957875 Added yt-dlp options for year formatting (#188) 2024-04-16 16:54:55 -07:00
Kieran b2e5e9b880 Resolved bug where non-pending media would notify when fast indexing (#187) 2024-04-16 13:47:56 -07:00
Kieran Eglin 8ca19ba076 Bumped version 2024-04-10 22:28:02 -07:00
Kieran f2a7463ff3 [Enhancement] Improve support for 4k videos with Plex (#181)
* Added WIP 4k MP4 fix [skip ci]

* Added tests for new remux options
2024-04-10 22:22:17 -07:00
Kieran c36e33e1fd [Housekeeping] Close system port when jobs are cancelled (#182)
* Created a test setup that works

* Refactored test setup into real-world fixes
2024-04-10 22:13:19 -07:00
Kieran e984c05298 [Enhancement] Allow overriding output templates on a per-source basis (#179)
* Added output path override to table and download option builder

* Added output template override to UI
2024-04-10 22:02:19 -07:00
Kieran Eglin 96c65012ca Bumped version 2024-04-10 21:59:01 -07:00
Kieran Eglin 25aaef7da4 Fixed bug with redownloading not forcing download of the video 2024-04-10 21:43:15 -07:00
Kieran Eglin 1a699223fe Updated README blurb on WAL issues 2024-04-10 21:01:50 -07:00
Kieran Eglin 2a9677df51 Bumped version 2024-04-10 20:35:45 -07:00
Kieran 8fbcc8b289 [Enhancement] Allow custom yt-dlp options (#176)
* Added option for yt-dlp config file usage

* renamed yt-dlp config file

* refactored to use a precedence-based approach

* Updated README
2024-04-10 20:17:22 -07:00
Kieran Eglin 0fcdd1df84 Renamed FS Helpers module to FS Utils 2024-04-10 18:02:26 -07:00
Kieran e841f39cf2 [Enhancement] Redownload new media after a delay (#173)
* Added redownload-related columns

* Added methods for fetching re-downloadable media items

* Filled out redownload worker + tests

* Added redownload worker to config.exs cron

* Added to UI and README
2024-04-10 17:54:45 -07:00
Kieran Eglin a2bcd454c7 Added apprise to runtime 2024-04-09 19:07:49 -07:00
Kieran Eglin cec9e3c7ff bumped version 2024-04-09 18:45:38 -07:00
Kieran b0c2a33644 Added more custom source attributes to output template (#172) 2024-04-09 18:24:07 -07:00
Kieran Eglin 318d6a7594 updated help text for fast indexing 2024-04-09 17:51:45 -07:00
Kieran 26d457e656 [Enhancement] Add Apprise support (#170)
* [WIP] add settings sidebar entry and placeholder page

* [WIP] added placeholder UI and logic for settings form

* Added column and UI for apprise server

* Add some tests

* Added placeholder command runner for apprise

* [WIP] Adding apprise package

* Added apprise command runner

* Hooked up apprise notification module

* Ensured apprise was running in verbose mode

* Updated wording of apprise notification

* Added apprise to README
2024-04-09 17:45:39 -07:00
Kieran Eglin a9f40ed843 Bumped version 2024-04-06 13:28:06 -07:00
Kieran f27323ffa3 [Ehnacement] More gracefully handle Sponsorblock failures (#169)
* Updated downloader and runner to handle sponsorblock failures more gracefully

* stopped download worker from running if a media item is preventing download
2024-04-06 13:23:36 -07:00
Kieran 81b49f55bf [Bugfix] Properly escape NFO files (#168)
* Properly escaped NFO file contents

* Added an NFO backfill worker

* Added a try-catch to the backfill since I _really_ don't want failures to halt app boot
2024-04-06 11:48:28 -07:00
Kieran 24875eaeac [Housekeeping] Refactor settings model (#165)
* [WIP] renamed current settings module and tables to have backup suffix

* Created new settings table, schema, and context

* Migrated from old settings module to new one

* Removed settings backup modules

* Added some tests and docs
2024-04-04 12:43:17 -07:00
Kieran Eglin d9053fff0c Bumped version 2024-04-04 08:47:37 -07:00
Kieran Eglin 70c1f97625 re-added removal of root password 2024-04-03 17:36:52 -07:00
Kieran Eglin 332485cdaf Improved docs 2024-04-03 17:30:13 -07:00
Kieran e55bcaddd0 [Enhancement] Improve ordering of models (#164)
* [WIP] ordering app queries

* Refactored media queries to be self-contained
2024-04-03 17:26:46 -07:00
Kieran 4b12764f45 [Housekeeping] Upgraded Tailwind to 3.4.3 (#163)
* Upgraded Tailwind

* Enabled asset compression in prod
2024-04-03 14:57:45 -07:00
Kieran b872c5c20b [Enhancement] Allow manual indexing/downloading (#162)
* Added controller actions and UI for forcing index and download actions

* Added forcing of downloads for media items
2024-04-03 14:21:10 -07:00
Kieran Eglin 9381c80aac bumped version 2024-04-03 11:10:33 -07:00
Kieran Eglin ce35130a93 Added caching to GH actions 2024-04-03 11:06:40 -07:00
Kieran 3b1c1692fb Retained tab state using location hash (#161) 2024-04-03 11:05:49 -07:00
Kieran 79c61bca4f [Enhancement] Delete media after "x" days (#160)
* [Enhancement] Adds ability to stop media from re-downloading (#159)

* Added column

* Added methods for ignoring media items from future download

* Added new deletion options to controller and UI

* Added controller actions and UI for editing a media item

* Added column to sources

* Added retention period to form

* [WIP] getting retention methods in place

* Hooked up retention worker

* Added column and UI to prevent automatic deletion

* Docs

* Removed unused backfill worker

* Added edit links to media item tabs on source view

* Clarified form wording

* Form wording (again)
2024-04-03 10:44:11 -07:00
Kieran f9c2f7b8f2 [Enhancement] Improve Dockerfile permissions (#157)
* Updated dockerfile

* added a healthcheck
2024-04-01 18:56:03 -07:00
Kieran Eglin 2f9abe86b4 Merged fixes I forgot to push 2024-04-01 18:46:10 -07:00
Kieran 22fbb4b930 Re-adds source uniqueness index (#156) 2024-04-01 18:27:52 -07:00
Kieran Eglin 3daf72a161 Improved index to respect nulls 2024-04-01 18:22:58 -07:00
Kieran Eglin 4e26253b33 Re-adds source uniqueness index 2024-04-01 18:08:46 -07:00
Kieran c58e176619 Added yt-dlp version to sidebar (#155) 2024-04-01 17:54:51 -07:00
127 changed files with 3489 additions and 1146 deletions
+2
View File
@@ -77,3 +77,5 @@ 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
+7 -2
View File
@@ -48,10 +48,15 @@ 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
- Apprise support for notifications
- Allows automatically redownloading new media after a set period
- This can help improve the download quality of new content or improve SponsorBlock tags
- 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 - Sponsorblock integration
- \[Advanced\] allows custom `yt-dlp` options ([docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-yt%E2%80%90dlp-options))
## Screenshots ## Screenshots
@@ -109,10 +114,10 @@ It's recommended to not run the container as root. Doing so can create permissio
### Advanced: storing Pinchflat config directory on a network share ### Advanced: storing Pinchflat config directory on a network share
README: This is currently in the testing phase and not a recommended option (yet). The implications of changing this setting isn't clear and this could, conceivably, result in data loss. Only change this setting if you know what you're doing, why this is important, and are okay with possible data loss or DB corruption. This may become the default in the future once it's been tested more thoroughly.
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares. As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
Changing this setting from WAL to `delete` on an existing Pinchflat instance could, conceivably, result in data loss. Only change this setting if you know what you're doing, why this is important, and are okay with possible data loss or DB corruption. Backup your database first!
If you change this setting and it works well for you, please leave a comment on [#137](https://github.com/kieraneglin/pinchflat/issues/137)! Doubly so if it does _not_ work well. If you change this setting and it works well for you, please leave a comment on [#137](https://github.com/kieraneglin/pinchflat/issues/137)! Doubly so if it does _not_ work well.
## EFF donations ## EFF donations
+12
View File
@@ -22,6 +22,7 @@ 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()
@@ -35,6 +36,17 @@ let liveSocket = new LiveSocket('/live', Socket, {
window.Alpine.clone(from, to) window.Alpine.clone(from, to)
} }
} }
},
hooks: {
supressEnterSubmission: {
mounted() {
this.el.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
}
})
}
}
} }
}) })
+20
View File
@@ -0,0 +1,20 @@
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
}
+11 -2
View File
@@ -12,7 +12,9 @@ config :pinchflat,
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
# Specifying backend data here makes mocking and local testing SUPER easy # Specifying backend data here makes mocking and local testing SUPER easy
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
apprise_executable: System.find_executable("apprise"),
yt_dlp_runner: Pinchflat.YtDlp.CommandRunner, yt_dlp_runner: Pinchflat.YtDlp.CommandRunner,
apprise_runner: Pinchflat.Notifications.CommandRunner,
media_directory: "/downloads", media_directory: "/downloads",
# The user may or may not store metadata for their needs, but the app will always store its copy # The user may or may not store metadata for their needs, but the app will always store its copy
metadata_directory: "/config/metadata", metadata_directory: "/config/metadata",
@@ -46,7 +48,14 @@ 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: [{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60}], plugins: [
{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
{Oban.Plugins.Cron,
crontab: [
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
{"0 2 * * *", Pinchflat.Downloading.MediaRedownloadWorker}
]}
],
# TODO: consider making this an env var or something? # TODO: consider making this an env var or something?
queues: [ queues: [
default: 10, default: 10,
@@ -79,7 +88,7 @@ config :esbuild,
# Configure tailwind (the version is required) # Configure tailwind (the version is required)
config :tailwind, config :tailwind,
version: "3.3.2", version: "3.4.3",
default: [ default: [
args: ~w( args: ~w(
--config=tailwind.config.js --config=tailwind.config.js
+1
View File
@@ -57,6 +57,7 @@ if config_env() == :prod do
config :pinchflat, config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
apprise_executable: System.find_executable("apprise"),
media_directory: "/downloads", media_directory: "/downloads",
metadata_directory: metadata_path, metadata_directory: metadata_path,
extras_directory: extras_path, extras_directory: extras_path,
+1
View File
@@ -3,6 +3,7 @@ import Config
config :pinchflat, config :pinchflat,
# Specifying backend data here makes mocking and local testing SUPER easy # Specifying backend data here makes mocking and local testing SUPER easy
yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
apprise_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
media_directory: Path.join([System.tmp_dir!(), "test", "media"]), media_directory: Path.join([System.tmp_dir!(), "test", "media"]),
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]), metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]),
tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]), tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]),
+17 -8
View File
@@ -5,15 +5,10 @@ ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEB
FROM ${DEV_IMAGE} FROM ${DEV_IMAGE}
# Set the locale deets
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
# Install debian packages # Install debian packages
RUN apt-get update -qq RUN apt-get update -qq
RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client \ RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client \
python3 python3-pip python3-setuptools python3-wheel python3-dev python3 python3-pip python3-setuptools python3-wheel python3-dev locales procps
# Install nodejs # Install nodejs
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
@@ -25,9 +20,23 @@ RUN npm install -g yarn
RUN mix local.hex --force RUN mix local.hex --force
RUN mix local.rebar --force RUN mix local.rebar --force
# Download YT-DLP # Download and update YT-DLP
# NOTE: If you're seeing weird issues, consider using the FFMPEG released by yt-dlp # NOTE: If you're seeing weird issues, consider using the FFMPEG released by yt-dlp
RUN python3 -m pip install -U --pre yt-dlp --break-system-packages RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
RUN chmod a+rx /usr/local/bin/yt-dlp
RUN yt-dlp -U
# Download Apprise
RUN python3 -m pip install -U apprise --break-system-packages
# Download Mutagen for music thumbnail generation
RUN python3 -m pip install -U mutagen --break-system-packages
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
# Create app directory and copy the Elixir projects into it. # Create app directory and copy the Elixir projects into it.
WORKDIR /app WORKDIR /app
@@ -1,62 +0,0 @@
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
+70
View File
@@ -0,0 +1,70 @@
defmodule Pinchflat.Boot.NfoBackfillWorker do
@moduledoc false
use Oban.Worker,
queue: :local_metadata,
# This should have it running once _ever_ (until the job is pruned, anyway)
# NOTE: remove within the next month
unique: [period: :infinity, states: Oban.Job.states()],
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]
import Ecto.Query, warn: false
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.Metadata.MetadataFileHelpers
@doc """
Runs a one-off backfill job to regenerate NFO files for media items that have
both an NFO file and a metadata file. This is needed because NFO files weren't
escaping characters properly so we need to regenerate them.
This job will only run once as long as I remove it before the jobs are pruned in a month.
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{}) do
Logger.info("Running NFO backfill worker")
media_items = get_media_items_to_backfill()
Enum.each(media_items, fn media_item ->
nfo_exists = File.exists?(media_item.nfo_filepath)
metadata_exists = File.exists?(media_item.metadata.metadata_filepath)
if nfo_exists && metadata_exists do
Logger.info("NFO and metadata exist for media item #{media_item.id} - proceeding")
regenerate_nfo_for_media_item(media_item)
end
end)
:ok
end
defp get_media_items_to_backfill do
from(m in MediaItem, where: not is_nil(m.nfo_filepath))
|> Repo.all()
|> Repo.preload([:metadata, source: :media_profile])
end
defp regenerate_nfo_for_media_item(media_item) do
try do
case MetadataFileHelpers.read_compressed_metadata(media_item.metadata.metadata_filepath) do
{:ok, metadata} ->
Media.update_media_item(media_item, %{
nfo_filepath: NfoBuilder.build_and_store_for_media_item(media_item.nfo_filepath, metadata)
})
_err ->
Logger.error("Failed to read metadata for media item #{media_item.id}")
end
rescue
e -> Logger.error("Unknown error regenerating NFO file for MI ##{media_item.id}: #{inspect(e)}")
end
end
end
+4 -12
View File
@@ -7,13 +7,13 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
Phoenix supervision tree. Phoenix supervision tree.
""" """
alias Pinchflat.Repo
alias Pinchflat.Boot.NfoBackfillWorker
# restart: :temporary means that this process will never be restarted (ie: will run once and then die) # restart: :temporary means that this process will never be restarted (ie: will run once and then die)
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
@@ -29,16 +29,8 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
""" """
@impl true @impl true
def init(state) do def init(state) do
enqueue_backfill_worker() Repo.insert_unique_job(NfoBackfillWorker.new(%{}))
{: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
+25 -10
View File
@@ -14,7 +14,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
def start_link(opts \\ []) do def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, opts) GenServer.start_link(__MODULE__, %{}, opts)
@@ -32,7 +32,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
@impl true @impl true
def init(state) do def init(state) do
reset_executing_jobs() reset_executing_jobs()
create_blank_cookie_file() create_blank_yt_dlp_files()
apply_default_settings() apply_default_settings()
{:ok, state} {:ok, state}
@@ -50,19 +50,34 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
Logger.info("Reset #{count} executing jobs") Logger.info("Reset #{count} executing jobs")
end end
defp create_blank_cookie_file do defp create_blank_yt_dlp_files do
files = ["cookies.txt", "yt-dlp-configs/base-config.txt"]
base_dir = Application.get_env(:pinchflat, :extras_directory) base_dir = Application.get_env(:pinchflat, :extras_directory)
filepath = Path.join(base_dir, "cookies.txt")
if !File.exists?(filepath) do Enum.each(files, fn file ->
Logger.info("Cookies does not exist - creating it") filepath = Path.join(base_dir, file)
FilesystemHelpers.write_p!(filepath, "") if !File.exists?(filepath) do
end Logger.info("Creating blank file: #{filepath}")
FilesystemUtils.write_p!(filepath, "")
end
end)
end end
defp apply_default_settings do defp apply_default_settings do
Settings.fetch!(:onboarding, true) {:ok, yt_dlp_version} = yt_dlp_runner().version()
Settings.fetch!(:pro_enabled, false) {:ok, apprise_version} = apprise_runner().version()
Settings.set(yt_dlp_version: yt_dlp_version)
Settings.set(apprise_version: apprise_version)
end
defp yt_dlp_runner do
Application.get_env(:pinchflat, :yt_dlp_runner)
end
defp apprise_runner do
Application.get_env(:pinchflat, :apprise_runner)
end end
end end
@@ -3,6 +3,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
Builds the options for yt-dlp to download media based on the given media profile. Builds the options for yt-dlp to download media based on the given media profile.
""" """
alias Pinchflat.Sources
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.OutputPathBuilder alias Pinchflat.Downloading.OutputPathBuilder
@@ -10,8 +11,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
@doc """ @doc """
Builds the options for yt-dlp to download media based on the given media's profile. Builds the options for yt-dlp to download media based on the given media's profile.
IDEA: consider adding the ability to pass in a second argument to override Returns {:ok, [Keyword.t()]}
these options
""" """
def build(%MediaItem{} = media_item_with_preloads) do def build(%MediaItem{} = media_item_with_preloads) do
media_profile = media_item_with_preloads.source.media_profile media_profile = media_item_with_preloads.source.media_profile
@@ -23,25 +23,33 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
metadata_options(media_profile) ++ metadata_options(media_profile) ++
quality_options(media_profile) ++ quality_options(media_profile) ++
sponsorblock_options(media_profile) ++ sponsorblock_options(media_profile) ++
output_options(media_item_with_preloads) output_options(media_item_with_preloads) ++
config_file_options(media_item_with_preloads)
{:ok, built_options} {:ok, built_options}
end end
@doc """ @doc """
Builds the output path for yt-dlp to download media based on the given source's Builds the output path for yt-dlp to download media based on the given source's
media profile. media profile. Uses the source's override output path template if it exists.
Returns binary() Returns binary()
""" """
def build_output_path_for(%Source{} = source_with_preloads) do def build_output_path_for(%Source{} = source_with_preloads) do
output_path_template = source_with_preloads.media_profile.output_path_template output_path_template = Sources.output_path_template(source_with_preloads)
build_output_path(output_path_template, source_with_preloads) build_output_path(output_path_template, source_with_preloads)
end end
defp default_options do defp default_options do
[:no_progress, :windows_filenames] [
:no_progress,
:windows_filenames,
# Add force-overwrites to make sure redownloading works
:force_overwrites,
# This makes the date metadata conform to what jellyfin expects
parse_metadata: "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"
]
end end
defp subtitle_options(media_profile) do defp subtitle_options(media_profile) do
@@ -104,16 +112,18 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end end
defp quality_options(media_profile) do defp quality_options(media_profile) do
video_codec_options = "+codec:avc:m4a" video_codec_option = fn res ->
[format_sort: "res:#{res},+codec:avc:m4a", remux_video: "mp4"]
end
case media_profile.preferred_resolution do case media_profile.preferred_resolution do
# Also be aware that :audio disabled all embedding options for subtitles # Also be aware that :audio disabled all embedding options for subtitles
:audio -> [:extract_audio, format: "bestaudio[ext=m4a]"] :audio -> [:extract_audio, format: "bestaudio[ext=m4a]"]
:"360p" -> [format_sort: "res:360,#{video_codec_options}"] :"360p" -> video_codec_option.("360")
:"480p" -> [format_sort: "res:480,#{video_codec_options}"] :"480p" -> video_codec_option.("480")
:"720p" -> [format_sort: "res:720,#{video_codec_options}"] :"720p" -> video_codec_option.("720")
:"1080p" -> [format_sort: "res:1080,#{video_codec_options}"] :"1080p" -> video_codec_option.("1080")
:"2160p" -> [format_sort: "res:2160,#{video_codec_options}"] :"2160p" -> video_codec_option.("2160")
end end
end end
@@ -128,6 +138,35 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end end
end end
# This is put here instead of the CommandRunner module because it should only
# be applied to downloading - if it were in CommandRunner it would apply to
# all yt-dlp commands (like indexing)
defp config_file_options(media_item) do
base_dir = Path.join(Application.get_env(:pinchflat, :extras_directory), "yt-dlp-configs")
# Ordered by priority - the first file has the highest priority
filenames = [
"media-item-#{media_item.id}-config.txt",
"source-#{media_item.source_id}-config.txt",
"media-profile-#{media_item.source.media_profile_id}-config.txt",
"base-config.txt"
]
config_filepaths =
Enum.reduce(filenames, [], fn filename, acc ->
filepath = Path.join(base_dir, filename)
case File.read(filepath) do
{:ok, file_data} ->
if String.trim(file_data) != "", do: [filepath | acc], else: acc
{:error, _} ->
acc
end
end)
Enum.map(config_filepaths, fn filepath -> {:config_locations, filepath} end)
end
defp output_options(media_item_with_preloads) do defp output_options(media_item_with_preloads) do
[ [
output: build_output_path_for(media_item_with_preloads.source) output: build_output_path_for(media_item_with_preloads.source)
@@ -144,6 +183,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
defp output_options_map(source) do defp output_options_map(source) do
%{ %{
"source_custom_name" => source.custom_name, "source_custom_name" => source.custom_name,
"source_collection_id" => source.collection_id,
"source_collection_name" => source.collection_name,
"source_collection_type" => source.collection_type "source_collection_type" => source.collection_type
} }
end end
@@ -152,7 +193,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
# It's dependent on the output_path_template being a string ending `.{{ ext }}` # It's dependent on the output_path_template being a string ending `.{{ ext }}`
# (or equivalent), but that's validated by the MediaProfile schema. # (or equivalent), but that's validated by the MediaProfile schema.
defp determine_thumbnail_location(media_item_with_preloads) do defp determine_thumbnail_location(media_item_with_preloads) do
output_path_template = media_item_with_preloads.source.media_profile.output_path_template output_path_template = Sources.output_path_template(media_item_with_preloads.source)
output_path_template output_path_template
|> String.split(~r{\.}, include_captures: true) |> String.split(~r{\.}, include_captures: true)
@@ -7,9 +7,11 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
require Logger require Logger
alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
@doc """ @doc """
@@ -43,4 +45,23 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|> Media.list_pending_media_items_for() |> Media.list_pending_media_items_for()
|> Enum.each(&Tasks.delete_pending_tasks_for/1) |> Enum.each(&Tasks.delete_pending_tasks_for/1)
end end
@doc """
Takes a single media item and enqueues a download job if the media should be
downloaded, based on the source's download settings and whether media is
considered pending.
Returns {:ok, %Task{}} | {:error, :should_not_download} | {:error, any()}
"""
def kickoff_download_if_pending(%MediaItem{} = media_item) do
media_item = Repo.preload(media_item, :source)
if media_item.source.download_media && Media.pending_download?(media_item) do
Logger.info("Kicking off download for media item ##{media_item.id} (#{media_item.media_id})")
MediaDownloadWorker.kickoff_with_task(media_item)
else
{:error, :should_not_download}
end
end
end end
@@ -19,28 +19,33 @@ 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, opts \\ []) do def kickoff_with_task(media_item, job_args \\ %{}, job_opts \\ []) do
%{id: media_item.id} %{id: media_item.id}
|> MediaDownloadWorker.new(opts) |> Map.merge(job_args)
|> 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}}) do def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
should_force = Map.get(args, "force", false)
is_redownload = Map.get(args, "redownload?", false)
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 or media item is set to not download media, perform a no-op unless forced
if media_item.source.download_media do if (media_item.source.download_media && !media_item.prevent_download) || should_force do
download_media_and_schedule_jobs(media_item) download_media_and_schedule_jobs(media_item, is_redownload)
else else
:ok :ok
end end
@@ -49,27 +54,39 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale") Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
end end
defp download_media_and_schedule_jobs(media_item) do defp download_media_and_schedule_jobs(media_item, is_redownload) do
case MediaDownloader.download_for_media_item(media_item) do case MediaDownloader.download_for_media_item(media_item) do
{:ok, updated_media_item} -> {:ok, updated_media_item} ->
compute_and_save_media_filesize(updated_media_item) Media.update_media_item(updated_media_item, %{
media_size_bytes: compute_media_filesize(updated_media_item),
media_redownloaded_at: get_redownloaded_at(is_redownload)
})
{:ok, updated_media_item} {:ok, updated_media_item}
err -> {:recovered, _} ->
Logger.error("Failed to download media for media item #{media_item.id}: #{inspect(err)}") {:error, :retry}
{:error, _message} ->
{:error, :download_failed} {:error, :download_failed}
end end
end end
defp compute_and_save_media_filesize(media_item) do defp compute_media_filesize(media_item) do
case File.stat(media_item.media_filepath) do case File.stat(media_item.media_filepath) do
{:ok, %{size: size}} -> {:ok, %{size: size}} ->
Media.update_media_item(media_item, %{media_size_bytes: size}) size
_ -> _ ->
:ok nil
end
end
defp get_redownloaded_at(is_redownload) do
if is_redownload do
DateTime.utc_now()
else
nil
end end
end end
end end
+66 -21
View File
@@ -5,12 +5,15 @@ defmodule Pinchflat.Downloading.MediaDownloader do
to download the media with the desired options. to download the media with the desired options.
""" """
require Logger
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Metadata.NfoBuilder alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.Metadata.MetadataParser alias Pinchflat.Metadata.MetadataParser
alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadOptionBuilder alias Pinchflat.Downloading.DownloadOptionBuilder
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@@ -27,33 +30,69 @@ defmodule Pinchflat.Downloading.MediaDownloader do
Returns {:ok, %MediaItem{}} | {:error, any, ...any} Returns {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
def download_for_media_item(%MediaItem{} = media_item) do def download_for_media_item(%MediaItem{} = media_item) do
item_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile]) output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
case download_with_options(media_item.original_url, item_with_preloads) do case download_with_options(media_item.original_url, media_with_preloads, output_filepath) do
{:ok, parsed_json} -> {:ok, parsed_json} ->
parsed_attrs = update_media_item_from_parsed_json(media_with_preloads, parsed_json)
parsed_json
|> MetadataParser.parse_for_media_item()
|> Map.merge(%{
media_downloaded_at: DateTime.utc_now(),
nfo_filepath: determine_nfo_filepath(item_with_preloads, parsed_json),
metadata: %{
# IDEA: might be worth kicking off a job for this since thumbnail fetching
# could fail and I want to handle that in isolation
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, parsed_json),
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item, parsed_json)
}
})
# Don't forgor to use preloaded associations or updates to {:error, message, _exit_code} ->
# associations won't work! Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
Media.update_media_item(item_with_preloads, parsed_attrs)
if String.contains?(to_string(message), recoverable_errors()) do
attempt_update_media_item(media_with_preloads, output_filepath)
{:recovered, message}
else
{:error, message}
end
err -> err ->
err Logger.error("Unknown error downloading media item ##{media_with_preloads.id}: #{inspect(err)}")
{:error, "Unknown error: #{inspect(err)}"}
end end
end end
defp attempt_update_media_item(media_with_preloads, output_filepath) do
with {:ok, contents} <- File.read(output_filepath),
{:ok, parsed_json} <- Phoenix.json_library().decode(contents) do
Logger.info("""
Recovery from yt-dlp error seems possible. Updating media item ##{media_with_preloads.id}
with parsed JSON from partial download attempt. Full download will be re-attemted in future
anyway
""")
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
else
err ->
Logger.error("Unable to recover error for media item ##{media_with_preloads.id}: #{inspect(err)}")
{:error, :retry_failed}
end
end
defp update_media_item_from_parsed_json(media_with_preloads, parsed_json) do
parsed_attrs =
parsed_json
|> MetadataParser.parse_for_media_item()
|> Map.merge(%{
media_downloaded_at: DateTime.utc_now(),
nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json),
metadata: %{
# IDEA: might be worth kicking off a job for this since thumbnail fetching
# could fail and I want to handle that in isolation
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_with_preloads, parsed_json),
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads, parsed_json)
}
})
# Don't forgor to use preloaded associations or updates to
# associations won't work!
Media.update_media_item(media_with_preloads, parsed_attrs)
end
defp determine_nfo_filepath(media_item, parsed_json) do defp determine_nfo_filepath(media_item, parsed_json) do
if media_item.source.media_profile.download_nfo do if media_item.source.media_profile.download_nfo do
filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo" filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo"
@@ -64,9 +103,15 @@ defmodule Pinchflat.Downloading.MediaDownloader do
end end
end end
defp download_with_options(url, item_with_preloads) do defp download_with_options(url, item_with_preloads, output_filepath) do
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads) {:ok, options} = DownloadOptionBuilder.build(item_with_preloads)
YtDlpMedia.download(url, options) YtDlpMedia.download(url, options, output_filepath: output_filepath)
end
defp recoverable_errors do
[
"Unable to communicate with SponsorBlock"
]
end end
end end
@@ -0,0 +1,31 @@
defmodule Pinchflat.Downloading.MediaRedownloadWorker do
@moduledoc false
use Oban.Worker,
queue: :media_fetching,
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
tags: ["media_item", "media_fetching"]
require Logger
alias Pinchflat.Media
alias Pinchflat.Downloading.MediaDownloadWorker
@doc """
Redownloads media items that are eligible for redownload.
This worker is scheduled to run daily via the Oban Cron plugin
and it should run _after_ the retention worker.
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{}) do
redownloadable_media = Media.list_redownloadable_media_items()
Logger.info("Redownloading #{length(redownloadable_media)} media items")
Enum.each(redownloadable_media, fn media_item ->
MediaDownloadWorker.kickoff_with_task(media_item, %{redownload?: true})
end)
end
end
@@ -0,0 +1,33 @@
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
@@ -5,62 +5,45 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
Many of these methods are made to be kickoff or be consumed by workers. Many of these methods are made to be kickoff or be consumed by workers.
""" """
require Logger
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery alias Pinchflat.Media.MediaQuery
alias Pinchflat.FastIndexing.YoutubeRss alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.MediaIndexingWorker
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@doc """ @doc """
Fetches new media IDs from a source's YouTube RSS feed and kicks off indexing tasks Fetches new media IDs from a source's YouTube RSS feed, indexes them, and kicks off downloading
for any new media items. See comments in `MediaIndexingWorker` for more info on the tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the
order of operations and how this fits into the indexing process. order of operations and how this fits into the indexing process.
Despite the similar name to `kickoff_fast_indexing_task`, this does work differently. Returns [%MediaItem{}] where each item is a new media item that was created _but not necessarily
`kickoff_fast_indexing_task` starts a task that _calls_ this function whereas this downloaded_.
function starts individual indexing tasks for each new media item. I think it does
make sense grammatically, but I could see how that's confusing.
Returns :ok
""" """
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source) {:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
existing_media_items = list_media_items_by_media_id_for(source, media_ids) existing_media_items = list_media_items_by_media_id_for(source, media_ids)
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id) new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
Enum.each(new_media_ids, fn media_id -> maybe_new_media_items =
url = "https://www.youtube.com/watch?v=#{media_id}" Enum.map(new_media_ids, fn media_id ->
case create_media_item_from_media_id(source, media_id) do
{:ok, media_item} ->
media_item
MediaIndexingWorker.kickoff_with_task(source, url) err ->
end) Logger.error("Error creating media item '#{media_id}' from URL: #{inspect(err)}")
end nil
@doc """
Indexes a single media item for a source and enqueues a download job if the
media should be downloaded. This method creates the media item record so it's
the one-stop-shop for adding a media item (and possibly downloading it) just
by a URL and source.
Returns {:ok, media_item} | {:error, any()}
"""
def index_and_enqueue_download_for_media_item(%Source{} = source, url) do
maybe_media_item = create_media_item_from_url(source, url)
case maybe_media_item do
{:ok, media_item} ->
if source.download_media && Media.pending_download?(media_item) do
MediaDownloadWorker.kickoff_with_task(media_item)
end end
end)
{:ok, media_item} DownloadingHelpers.enqueue_pending_download_tasks(source)
err -> Enum.filter(maybe_new_media_items, & &1)
err
end
end end
defp list_media_items_by_media_id_for(source, media_ids) do defp list_media_items_by_media_id_for(source, media_ids) do
@@ -70,9 +53,15 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|> Repo.all() |> Repo.all()
end end
defp create_media_item_from_url(source, url) do defp create_media_item_from_media_id(source, media_id) do
{:ok, media_attrs} = YtDlpMedia.get_media_attributes(url) url = "https://www.youtube.com/watch?v=#{media_id}"
Media.create_media_item_from_backend_attrs(source, media_attrs) case YtDlpMedia.get_media_attributes(url) do
{:ok, media_attrs} ->
Media.create_media_item_from_backend_attrs(source, media_attrs)
err ->
err
end
end end
end end
@@ -10,9 +10,12 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
alias __MODULE__ alias __MODULE__
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Media
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingHelpers alias Pinchflat.FastIndexing.FastIndexingHelpers
alias Pinchflat.Notifications.SourceNotifications
@doc """ @doc """
Starts the source fast indexing worker and creates a task for the source. Starts the source fast indexing worker and creates a task for the source.
@@ -26,9 +29,21 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
end end
@doc """ @doc """
Kicks off the fast indexing process for a source, reschedules the job to run again Similar to `MediaCollectionIndexingWorker`, but for working with RSS feeds.
once complete. See `MediaCollectionIndexingWorker` and `MediaIndexingWorker` comments `MediaCollectionIndexingWorker` should be preferred in general, but this is
for more useful for downloading small batches of media items via fast indexing.
Only kicks off downloads for media that _should_ be downloaded
(ie: the source is set to download and the media matches the profile's format preferences)
Order of operations:
1. FastIndexingWorker (this module) periodically checks the YouTube RSS feed for new media.
with `FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed`
2. If the above `kickoff_download_tasks_from_youtube_rss_feed` finds new media items in the RSS feed,
it indexes them with a yt-dlp call to create the media item records then kicks off downloading
tasks (MediaDownloadWorker) for any new media items _that should be downloaded_.
3. Once downloads are kicked off, this worker sends a notification to the apprise server if applicable
then reschedules itself to run again in the future.
Returns :ok | {:ok, :job_exists} | {:ok, %Task{}} Returns :ok | {:ok, :job_exists} | {:ok, %Task{}}
""" """
@@ -37,8 +52,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
source = Sources.get_source!(source_id) source = Sources.get_source!(source_id)
if source.fast_index do if source.fast_index do
FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source) perform_indexing_and_send_notification(source)
reschedule_indexing(source) reschedule_indexing(source)
else else
:ok :ok
@@ -48,6 +62,19 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale") Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
end end
defp perform_indexing_and_send_notification(source) do
apprise_server = Settings.get!(:apprise_server)
new_media_items =
source
|> FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed()
|> Enum.filter(&Media.pending_download?(&1))
if source.download_media do
SourceNotifications.send_new_media_notification(apprise_server, source, length(new_media_items))
end
end
defp reschedule_indexing(source) do defp reschedule_indexing(source) do
next_run_in = Source.fast_index_frequency() * 60 next_run_in = Source.fast_index_frequency() * 60
@@ -1,69 +0,0 @@
defmodule Pinchflat.FastIndexing.MediaIndexingWorker do
@moduledoc false
use Oban.Worker,
queue: :media_indexing,
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
tags: ["media_source", "media_indexing"]
require Logger
alias __MODULE__
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.FastIndexing.FastIndexingHelpers
@doc """
Starts the fast media indexing worker and creates a task for the source.
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
"""
def kickoff_with_task(source, media_url, opts \\ []) do
%{id: source.id, media_url: media_url}
|> MediaIndexingWorker.new(opts)
|> Tasks.create_job_with_task(source)
end
@doc """
Similar to `MediaCollectionIndexingWorker`, but for individual media items.
Does not reschedule or check anything to do with a source's indexing
frequency - only collects initial metadata then kicks off a download.
`MediaCollectionIndexingWorker` should be preferred in general, but this is
useful for downloading one-off media items based on a URL (like for fast indexing).
Only downloads media that _should_ be downloaded (ie: the source is set to download
and the media matches the profile's format preferences)
Order of operations:
1. FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed/1 (which is running
in its own worker) periodically checks the YouTube RSS feed for new media
2. If new media is found, it enqueues a MediaIndexingWorker (this module) for each new media
item
3. This worker fetches the media metadata and uses that to determine if it should be
downloaded. If so, it enqueues a MediaDownloadWorker
Each is a worker because they all either need to be scheduled periodically or call out to
an external service and will be long-running. They're split into different jobs to separate
retry logic for each step and allow us to better optimize various queues (eg: the indexing
steps can keep running while the slow download steps are worked through).
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => source_id, "media_url" => media_url}}) do
source = Sources.get_source!(source_id)
case FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, media_url) do
{:ok, media_item} ->
Logger.debug("Indexed and enqueued download for url: #{media_url} (media item: #{media_item.id})")
{:error, reason} ->
Logger.debug("Failed to index and enqueue download for url: #{media_url} (reason: #{inspect(reason)})")
end
:ok
rescue
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
end
end
+63 -34
View File
@@ -11,7 +11,7 @@ defmodule Pinchflat.Media do
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Media.MediaQuery alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
@doc """ @doc """
Returns the list of media_items. Returns the list of media_items.
@@ -23,38 +23,56 @@ defmodule Pinchflat.Media do
end end
@doc """ @doc """
Returns a list of pending media_items for a given source, where Returns a list of media_items that are cullable based on the retention period
pending means the `media_filepath` is `nil` AND the media_item of the source they belong to.
matches the format selection rules of the parent media_profile.
See `build_format_clauses` but tl;dr is it _may_ filter based Returns [%MediaItem{}, ...]
on shorts or livestreams depending on the media_profile settings.
Returns [%MediaItem{}, ...].
""" """
def list_pending_media_items_for(%Source{} = source, opts \\ []) do def list_cullable_media_items do
limit = Keyword.get(opts, :limit, nil)
source = Repo.preload(source, :media_profile)
MediaQuery.new() MediaQuery.new()
|> MediaQuery.for_source(source) |> MediaQuery.with_media_filepath()
|> matching_download_criteria_for(source) |> MediaQuery.where_past_retention_period()
|> Repo.maybe_limit(limit) |> MediaQuery.where_culling_not_prevented()
|> Repo.all() |> Repo.all()
end end
@doc """ @doc """
Returns a list of downloaded media_items for a given source. Returns a list of media_items that are redownloadable based on the redownload delay
of the media_profile their source belongs to.
The logic is that a media_item is past_redownload_delay if the media_item's
upload_date is at least redownload_delay_days ago AND
`media_downloaded_at` - `redownload_delay_days` is before the media_item's `upload_date`.
This logic grabs media that we've recently downloaded AND is recently uploaded, but
doesn't grab media that we've recently downloaded and was uploaded a long time ago.
This also makes things work as expected when downloading media from a source for the
first time.
Returns [%MediaItem{}, ...]
"""
def list_redownloadable_media_items do
MediaQuery.new()
|> MediaQuery.with_media_downloaded_at()
|> MediaQuery.where_download_not_prevented()
|> MediaQuery.where_not_culled()
|> MediaQuery.where_media_not_redownloaded()
|> MediaQuery.where_past_redownload_delay()
|> Repo.all()
end
@doc """
Returns a list of pending media_items for a given source, where
pending means the `media_filepath` is `nil` AND the media_item
matches satisfies `MediaQuery.where_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_downloaded_media_items_for(%Source{} = source, opts \\ []) do def list_pending_media_items_for(%Source{} = source) do
limit = Keyword.get(opts, :limit, nil)
MediaQuery.new() MediaQuery.new()
|> MediaQuery.for_source(source) |> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath() |> MediaQuery.where_pending_download()
|> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@@ -72,7 +90,7 @@ defmodule Pinchflat.Media do
MediaQuery.new() MediaQuery.new()
|> MediaQuery.with_id(media_item.id) |> MediaQuery.with_id(media_item.id)
|> matching_download_criteria_for(media_item.source) |> MediaQuery.where_pending_download()
|> Repo.exists?() |> Repo.exists?()
end end
@@ -161,7 +179,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, _} = delete_media_files(media_item) {:ok, _} = do_delete_media_files(media_item)
end end
# Should delete these no matter what # Should delete these no matter what
@@ -169,6 +187,25 @@ 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.
""" """
@@ -176,7 +213,7 @@ defmodule Pinchflat.Media do
MediaItem.changeset(media_item, attrs) MediaItem.changeset(media_item, attrs)
end end
defp delete_media_files(media_item) do defp do_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()
@@ -186,7 +223,7 @@ defmodule Pinchflat.Media do
end) end)
|> List.flatten() |> List.flatten()
|> Enum.filter(&is_binary/1) |> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) |> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
{:ok, media_item} {:ok, media_item}
end end
@@ -198,14 +235,6 @@ defmodule Pinchflat.Media do
MediaMetadata.filepath_attributes() MediaMetadata.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end) |> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1) |> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) |> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
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 end
+22 -2
View File
@@ -30,7 +30,12 @@ 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,
:media_redownloaded_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(
@@ -42,7 +47,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
@@ -57,6 +62,7 @@ defmodule Pinchflat.Media.MediaItem do
field :livestream, :boolean, default: false field :livestream, :boolean, default: false
field :short_form_content, :boolean, default: false field :short_form_content, :boolean, default: false
field :media_downloaded_at, :utc_datetime field :media_downloaded_at, :utc_datetime
field :media_redownloaded_at, :utc_datetime
field :upload_date, :date field :upload_date, :date
field :duration_seconds, :integer field :duration_seconds, :integer
@@ -70,6 +76,10 @@ 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
@@ -96,4 +106,14 @@ 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
+113 -49
View File
@@ -3,13 +3,10 @@ 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 for media items. Each method should to compose queries. Each method should strive to do
strive to do _one_ thing. These don't need to be tested _one_ thing. These don't need to be tested as
as they are just building blocks for other functionality 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
@@ -17,7 +14,8 @@ defmodule Pinchflat.Media.MediaQuery do
# Prefixes: # Prefixes:
# - for_* - belonging to a certain record # - for_* - belonging to a certain record
# - with_* - for filtering based on full, concrete attributes # - join_* - for joining on a certain record
# - with_*, where_* - 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)
# #
# Suffixes: # Suffixes:
@@ -31,6 +29,50 @@ 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 where_past_retention_period(query) do
query
|> require_assoc(:source)
|> where(
[mi, source],
fragment("""
IFNULL(retention_period_days, 0) > 0 AND
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
""")
)
end
def where_past_redownload_delay(query) do
query
|> require_assoc(:source)
|> require_assoc(:media_profile)
|> where(
[_mi, _source, _media_profile],
# Returns media items where the upload_date is at least redownload_delay_days ago AND
# downloaded_at minus the redownload_delay_days is before the upload date
fragment("""
IFNULL(redownload_delay_days, 0) > 0 AND
DATETIME('now', '-' || redownload_delay_days || ' day') > upload_date AND
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < upload_date
""")
)
end
def where_culling_not_prevented(query) do
where(query, [mi], mi.prevent_culling == false)
end
def where_not_culled(query) do
where(query, [mi], is_nil(mi.culled_at))
end
def where_media_not_redownloaded(query) do
where(query, [mi], is_nil(mi.media_redownloaded_at))
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
@@ -39,6 +81,10 @@ defmodule Pinchflat.Media.MediaQuery do
where(query, [mi], mi.media_id in ^media_ids) where(query, [mi], mi.media_id in ^media_ids)
end end
def with_media_downloaded_at(query) do
where(query, [mi], not is_nil(mi.media_downloaded_at))
end
def with_media_filepath(query) do def with_media_filepath(query) do
where(query, [mi], not is_nil(mi.media_filepath)) where(query, [mi], not is_nil(mi.media_filepath))
end end
@@ -47,16 +93,23 @@ 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(query, nil), do: query def with_upload_date_after_source_cutoff(query) do
query
def with_upload_date_after(query, date) do |> require_assoc(:source)
where(query, [mi], mi.upload_date >= ^date) |> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
end end
def matching_title_regex(query, nil), do: query def where_download_not_prevented(query) do
where(query, [mi], mi.prevent_download == false)
end
def matching_title_regex(query, regex) do def matching_source_title_regex(query) do
where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^regex)) query
|> 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
@@ -77,44 +130,55 @@ defmodule Pinchflat.Media.MediaQuery do
) )
end end
# NOTE: this method breaks the contract set by other methods in that it def with_format_matching_profile_preference(query) do
# takes a media_profile struct instead of taking just the attributes it query
# cares about. Consider refactoring but low priority. |> require_assoc(:media_profile)
def with_format_preference(query, media_profile) do |> where(
mapped_struct = Map.from_struct(media_profile) fragment("""
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
finders = def where_pending_download(query) do
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic -> query
case {attr, media_profile} do |> where_download_not_prevented()
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} -> |> with_no_media_filepath()
dynamic( |> with_upload_date_after_source_cutoff()
[mi], |> with_format_matching_profile_preference()
^dynamic and (mi.livestream == true or mi.short_form_content == true) |> matching_source_title_regex()
) end
# Technically redundant, but makes the other clauses easier to parse defp require_assoc(query, identifier) do
# (redundant because this condition is the same as the condition above, just flipped) if has_named_binding?(query, identifier) do
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} -> query
dynamic else
do_require_assoc(query, identifier)
end
end
{{:shorts_behaviour, :only}, _} -> defp do_require_assoc(query, :source) do
dynamic([mi], ^dynamic and mi.short_form_content == true) from(mi in query, join: s in assoc(mi, :source), as: :source)
end
{{:livestream_behaviour, :only}, _} -> defp do_require_assoc(query, :media_profile) do
dynamic([mi], ^dynamic and mi.livestream == true) query
|> require_assoc(:source)
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only -> |> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
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
@@ -9,7 +9,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
needed needed
""" """
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
@doc """ @doc """
Returns the directory where metadata for a database record should be stored. Returns the directory where metadata for a database record should be stored.
@@ -36,7 +36,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
filepath = generate_filepath_for(database_record, "metadata.json.gz") filepath = generate_filepath_for(database_record, "metadata.json.gz")
{:ok, json} = Phoenix.json_library().encode(metadata_map) {:ok, json} = Phoenix.json_library().encode(metadata_map)
:ok = FilesystemHelpers.write_p!(filepath, json, [:compressed]) :ok = FilesystemUtils.write_p!(filepath, json, [:compressed])
filepath filepath
end end
@@ -54,15 +54,22 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
@doc """ @doc """
Downloads and stores a thumbnail for a media item, returning the filepath. Downloads and stores a thumbnail for a media item, returning the filepath.
Chooses the highest quality jpg thumbnail available.
Returns binary() Returns binary()
""" """
def download_and_store_thumbnail_for(database_record, metadata_map) do def download_and_store_thumbnail_for(database_record, metadata_map) do
thumbnail_url = metadata_map["thumbnail"] thumbnail_url =
metadata_map["thumbnails"]
|> Enum.filter(&(&1["preference"] && String.ends_with?(&1["url"], ".jpg")))
|> Enum.sort(&(&1["preference"] >= &2["preference"]))
|> List.first()
|> Map.get("url")
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url)) filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url) thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
:ok = FilesystemHelpers.write_p!(filepath, thumbnail_blob) :ok = FilesystemUtils.write_p!(filepath, thumbnail_blob)
filepath filepath
end end
+14 -12
View File
@@ -4,8 +4,10 @@ defmodule Pinchflat.Metadata.NfoBuilder do
use by Kodi/Jellyfin and other media center software. use by Kodi/Jellyfin and other media center software.
""" """
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
@doc """ @doc """
Builds an NFO file for a media item (read: single "episode") and Builds an NFO file for a media item (read: single "episode") and
@@ -16,7 +18,7 @@ defmodule Pinchflat.Metadata.NfoBuilder do
def build_and_store_for_media_item(filepath, metadata) do def build_and_store_for_media_item(filepath, metadata) do
nfo = build_for_media_item(metadata) nfo = build_for_media_item(metadata)
FilesystemHelpers.write_p!(filepath, nfo) FilesystemUtils.write_p!(filepath, nfo)
filepath filepath
end end
@@ -30,7 +32,7 @@ defmodule Pinchflat.Metadata.NfoBuilder do
def build_and_store_for_source(filepath, metadata) do def build_and_store_for_source(filepath, metadata) do
nfo = build_for_source(metadata) nfo = build_for_source(metadata)
FilesystemHelpers.write_p!(filepath, nfo) FilesystemUtils.write_p!(filepath, nfo)
filepath filepath
end end
@@ -42,12 +44,12 @@ defmodule Pinchflat.Metadata.NfoBuilder do
""" """
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?> <?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<episodedetails> <episodedetails>
<title>#{metadata["title"]}</title> <title>#{safe(metadata["title"])}</title>
<showtitle>#{metadata["uploader"]}</showtitle> <showtitle>#{safe(metadata["uploader"])}</showtitle>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid> <uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
<plot>#{metadata["description"]}</plot> <plot>#{safe(metadata["description"])}</plot>
<aired>#{upload_date}</aired> <aired>#{safe(upload_date)}</aired>
<season>#{upload_date.year}</season> <season>#{safe(upload_date.year)}</season>
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode> <episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
<genre>YouTube</genre> <genre>YouTube</genre>
</episodedetails> </episodedetails>
@@ -58,9 +60,9 @@ defmodule Pinchflat.Metadata.NfoBuilder do
""" """
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?> <?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<tvshow> <tvshow>
<title>#{metadata["title"]}</title> <title>#{safe(metadata["title"])}</title>
<plot>#{metadata["description"]}</plot> <plot>#{safe(metadata["description"])}</plot>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid> <uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
<genre>YouTube</genre> <genre>YouTube</genre>
</tvshow> </tvshow>
""" """
@@ -2,7 +2,7 @@ defmodule Pinchflat.Metadata.SourceImageParser do
@moduledoc """ @moduledoc """
Functions for parsing and storing source images. Functions for parsing and storing source images.
""" """
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
@doc """ @doc """
Given a base directory and source metadata, look for the appropriate images Given a base directory and source metadata, look for the appropriate images
@@ -62,7 +62,7 @@ defmodule Pinchflat.Metadata.SourceImageParser do
extension = Path.extname(tmp_filepath) extension = Path.extname(tmp_filepath)
final_filepath = Path.join([base_directory, "#{filename}#{extension}"]) final_filepath = Path.join([base_directory, "#{filename}#{extension}"])
FilesystemHelpers.cp_p!(tmp_filepath, final_filepath) FilesystemUtils.cp_p!(tmp_filepath, final_filepath)
{source_attr_name, final_filepath} {source_attr_name, final_filepath}
end end
@@ -0,0 +1,12 @@
defmodule Pinchflat.Notifications.AppriseCommandRunner do
@moduledoc """
A behaviour for running CLI commands against a notification backend (apprise).
Used so we can implement Mox for testing without actually running the
apprise command.
"""
@callback run(binary(), keyword()) :: :ok | {:error, binary()}
@callback run(List.t(), keyword()) :: :ok | {:error, binary()}
@callback version() :: {:ok, binary()} | {:error, binary()}
end
@@ -0,0 +1,65 @@
defmodule Pinchflat.Notifications.CommandRunner do
@moduledoc """
Runs apprise commands using the `System.cmd/3` function
"""
require Logger
alias Pinchflat.Utils.CliUtils
alias Pinchflat.Utils.FunctionUtils
alias Pinchflat.Notifications.AppriseCommandRunner
@behaviour AppriseCommandRunner
@doc """
Runs an apprise command and returns the string output.
Can take a single server string or a list of servers as well as additional
arguments to pass to the command.
Returns {:ok, binary()} | {:error, :no_servers} | {:error, binary()}
"""
@impl AppriseCommandRunner
def run(nil, _), do: {:error, :no_servers}
def run("", _), do: {:error, :no_servers}
def run([], _), do: {:error, :no_servers}
def run(endpoints, command_opts) do
endpoints = List.wrap(endpoints)
default_opts = [:verbose]
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
Logger.info("[apprise] called with: #{Enum.join(parsed_opts ++ endpoints, " ")}")
{output, return_code} = System.cmd(backend_executable(), parsed_opts ++ endpoints)
Logger.info("[apprise] response: #{output}")
case return_code do
0 -> {:ok, String.trim(output)}
_ -> {:error, String.trim(output)}
end
end
@doc """
Returns the version of apprise as a string.
Returns {:ok, binary()} | {:error, binary()}
"""
@impl AppriseCommandRunner
def version do
case CliUtils.wrap_cmd(backend_executable(), ["--version"]) do
{output, 0} ->
output
|> String.split(~r{\r?\n})
|> List.first()
|> String.replace("Apprise", "")
|> String.trim()
|> FunctionUtils.wrap_ok()
{output, _} ->
{:error, output}
end
end
defp backend_executable do
Application.get_env(:pinchflat, :apprise_executable)
end
end
@@ -0,0 +1,77 @@
defmodule Pinchflat.Notifications.SourceNotifications do
@moduledoc """
Contains utilities for sending notifications about sources
"""
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media.MediaQuery
@doc """
Wraps a function that may change the number of pending or downloaded
media items for a source, sending an apprise notification if
the count changes.
Returns the return value of the provided function
"""
def wrap_new_media_notification(servers, source, func) do
before_count = relevant_media_item_count(source)
retval = func.()
after_count = relevant_media_item_count(source)
send_new_media_notification(servers, source, after_count - before_count)
retval
end
@doc """
Sends a notification if the count of new media items has changed
Returns :ok
"""
def send_new_media_notification(_, _, count) when count <= 0, do: :ok
def send_new_media_notification(servers, source, changed_count) do
opts = [
title: "[Pinchflat] New media found",
body: "Found #{changed_count} new media item(s) for #{source.custom_name}. Downloading them now"
]
case backend_runner().run(servers, opts) do
{:ok, _} ->
Logger.info("Sent new media notification for source #{source.id}")
{:error, :no_servers} ->
Logger.info("No notification servers provided for source #{source.id}")
{:error, err} ->
Logger.error("Failed to send new media notification for source #{source.id}: #{err}")
end
:ok
end
defp relevant_media_item_count(source) do
pending_media_item_count(source) + downloaded_media_item_count(source)
end
defp pending_media_item_count(source) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.where_pending_download()
|> Repo.aggregate(:count)
end
defp downloaded_media_item_count(source) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.aggregate(:count)
end
defp backend_runner do
# This approach lets us mock the command for testing
Application.get_env(:pinchflat, :apprise_runner)
end
end
+6 -3
View File
@@ -5,7 +5,7 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
""" """
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
@@ -25,8 +25,11 @@ 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)
source MediaQuery.new()
|> Media.list_downloaded_media_items_for(limit: limit) |> MediaQuery.for_source(source)
|> 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
+2 -8
View File
@@ -5,6 +5,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
@datetime_format "%a, %d %b %Y %H:%M:%S %z" @datetime_format "%a, %d %b %Y %H:%M:%S %z"
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
alias Pinchflat.Utils.DatetimeUtils alias Pinchflat.Utils.DatetimeUtils
alias Pinchflat.Podcasts.PodcastHelpers alias Pinchflat.Podcasts.PodcastHelpers
alias PinchflatWeb.Router.Helpers, as: Routes alias PinchflatWeb.Router.Helpers, as: Routes
@@ -94,14 +96,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
""" """
end end
defp safe(nil), do: ""
defp safe(value) do
value
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
defp generate_self_link(url_base, source) do defp generate_self_link(url_base, source) do
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml") Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
end end
+5 -2
View File
@@ -26,12 +26,14 @@ defmodule Pinchflat.Profiles.MediaProfile do
shorts_behaviour shorts_behaviour
livestream_behaviour livestream_behaviour
preferred_resolution preferred_resolution
redownload_delay_days
)a )a
@required_fields ~w(name output_path_template)a @required_fields ~w(name output_path_template)a
schema "media_profiles" do schema "media_profiles" do
field :name, :string field :name, :string
field :redownload_delay_days, :integer
field :output_path_template, :string, field :output_path_template, :string,
default: "/{{ source_custom_name }}/{{ upload_yyyy_mm_dd }} {{ title }}/{{ title }} [{{ id }}].{{ ext }}" default: "/{{ source_custom_name }}/{{ upload_yyyy_mm_dd }} {{ title }}/{{ title }} [{{ id }}].{{ ext }}"
@@ -60,7 +62,6 @@ defmodule Pinchflat.Profiles.MediaProfile do
# See `build_format_clauses` in the Media context for more. # See `build_format_clauses` in the Media context for more.
field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
field :preferred_resolution, Ecto.Enum, values: ~w(2160p 1080p 720p 480p 360p audio)a, default: :"1080p" field :preferred_resolution, Ecto.Enum, values: ~w(2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
has_many :sources, Source has_many :sources, Source
@@ -75,10 +76,12 @@ defmodule Pinchflat.Profiles.MediaProfile do
|> validate_required(@required_fields) |> validate_required(@required_fields)
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room) # Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|> validate_format(:output_path_template, ext_regex(), message: "must end with .{{ ext }}") |> validate_format(:output_path_template, ext_regex(), message: "must end with .{{ ext }}")
|> validate_number(:redownload_delay_days, greater_than_or_equal_to: 0)
|> unique_constraint(:name) |> unique_constraint(:name)
end end
defp ext_regex do @doc false
def ext_regex do
~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/ ~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/
end end
end end
+2 -2
View File
@@ -7,7 +7,7 @@ defmodule Pinchflat.Release do
require Logger require Logger
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
def migrate do def migrate do
load_app() load_app()
@@ -39,7 +39,7 @@ defmodule Pinchflat.Release do
Logger.info("Checking permissions for #{dir}") Logger.info("Checking permissions for #{dir}")
filepath = Path.join([dir, ".keep"]) filepath = Path.join([dir, ".keep"])
case FilesystemHelpers.write_p(filepath, "") do case FilesystemUtils.write_p(filepath, "") do
:ok -> :ok ->
Logger.info("Permissions OK") Logger.info("Permissions OK")
+21 -9
View File
@@ -1,24 +1,36 @@
defmodule Pinchflat.Settings.Setting do defmodule Pinchflat.Settings.Setting do
@moduledoc """ @moduledoc """
A Setting is a key-value pair with a datatype used to track user-level settings. The Setting schema.
""" """
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
schema "settings" do @allowed_fields [
field :name, :string :onboarding,
field :value, :string :pro_enabled,
field :datatype, Ecto.Enum, values: ~w(boolean string integer float)a :yt_dlp_version,
:apprise_version,
:apprise_server
]
timestamps(type: :utc_datetime) @required_fields ~w(
onboarding
pro_enabled
)a
schema "settings" do
field :onboarding, :boolean, default: true
field :pro_enabled, :boolean, default: false
field :yt_dlp_version, :string
field :apprise_version, :string
field :apprise_server, :string
end end
@doc false @doc false
def changeset(setting, attrs) do def changeset(setting, attrs) do
setting setting
|> cast(attrs, [:name, :value, :datatype]) |> cast(attrs, @allowed_fields)
|> validate_required([:name, :value, :datatype]) |> validate_required(@required_fields)
|> unique_constraint([:name])
end end
end end
+50 -64
View File
@@ -2,94 +2,80 @@ defmodule Pinchflat.Settings do
@moduledoc """ @moduledoc """
The Settings context. The Settings context.
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Repo
alias Pinchflat.Settings.Setting alias Pinchflat.Settings.Setting
@doc """ @doc """
Returns the list of settings. Returns the only setting record. It _should_ be impossible
to create or delete this record, so it's assertive about
assuming it's the only one.
Returns [%Setting{}, ...] Returns %Setting{}
""" """
def list_settings do def record do
Repo.all(Setting) Setting
|> limit(1)
|> Repo.one()
end end
@doc """ @doc """
Creates or updates a setting, returning the parsed value. Updates the setting record.
Raises if an unsupported datatype is used. Optionally allows
specifying the datatype.
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)` Returns {:ok, %Setting{}} | {:error, %Ecto.Changeset{}}
""" """
def set!(name, value) do def update_setting(%Setting{} = setting, attrs) do
set!(name, value, infer_datatype(value)) setting
|> Setting.changeset(attrs)
|> Repo.update()
end end
def set!(name, value, datatype) do @doc """
# Only create if doesn't exist Updates a setting, returning the new value.
case Repo.get_by(Setting, name: to_string(name)) do Is setup to take a keyword list argument so you
nil -> create_setting!(name, value, datatype) can call it like `Settings.set(onboarding: true)`
setting -> update_setting!(setting, value, datatype)
Returns {:ok, value} | {:error, :invalid_key} | {:error, %Ecto.Changeset{}}
"""
def set([{attr, value}]) do
record()
|> update_setting(%{attr => value})
|> case do
{:ok, %{^attr => _}} -> {:ok, value}
{:ok, _} -> {:error, :invalid_key}
{:error, changeset} -> {:error, changeset}
end end
end end
@doc """ @doc """
Gets the parsed value of a setting. Raises if the setting does not exist. Gets the value of a setting.
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)` Returns {:ok, value} | {:error, :invalid_key}
"""
def get(name) do
case Map.fetch(record(), name) do
{:ok, value} -> {:ok, value}
:error -> {:error, :invalid_key}
end
end
@doc """
Gets the value of a setting, raising if it doesn't exist.
Returns value
""" """
def get!(name) do def get!(name) do
Setting case get(name) do
|> Repo.get_by!(name: to_string(name)) {:ok, value} -> value
|> read_setting() {:error, _} -> raise "Setting `#{name}` not found"
end
@doc """
Attempts to find a setting by name or creates a setting with value
if one doesn't exist, returning the parsed value. Optionally allows
specifying the datatype.
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)`
"""
def fetch!(name, value) do
fetch!(name, value, infer_datatype(value))
end
def fetch!(name, value, datatype) do
case Repo.get_by(Setting, name: to_string(name)) do
nil -> create_setting!(name, value, datatype)
setting -> read_setting(setting)
end end
end end
defp change_setting(setting, attrs) do @doc """
Returns `%Ecto.Changeset{}`
"""
def change_setting(%Setting{} = setting, attrs \\ %{}) do
Setting.changeset(setting, attrs) Setting.changeset(setting, attrs)
end end
defp create_setting!(name, value, datatype) do
%Setting{}
|> change_setting(%{name: to_string(name), value: to_string(value), datatype: datatype})
|> Repo.insert!()
|> read_setting()
end
defp update_setting!(setting, value, datatype) do
setting
|> change_setting(%{value: to_string(value), datatype: datatype})
|> Repo.update!()
|> read_setting()
end
defp read_setting(%{value: value, datatype: :string}), do: value
defp read_setting(%{value: value, datatype: :boolean}), do: value in ["true", "t", "1"]
defp read_setting(%{value: value, datatype: :integer}), do: String.to_integer(value)
defp read_setting(%{value: value, datatype: :float}), do: String.to_float(value)
defp infer_datatype(value) when is_boolean(value), do: :boolean
defp infer_datatype(value) when is_integer(value), do: :integer
defp infer_datatype(value) when is_float(value), do: :float
defp infer_datatype(value) when is_binary(value), do: :string
end end
@@ -11,18 +11,21 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
alias __MODULE__ alias __MODULE__
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.Notifications.SourceNotifications
@doc """ @doc """
Starts the source slow indexing worker and creates a task for the source. Starts the source slow indexing worker and creates a task for the source.
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}} Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
""" """
def kickoff_with_task(source, opts \\ []) do def kickoff_with_task(source, job_args \\ %{}, job_opts \\ []) do
%{id: source.id} %{id: source.id}
|> MediaCollectionIndexingWorker.new(opts) |> Map.merge(job_args)
|> MediaCollectionIndexingWorker.new(job_opts)
|> Tasks.create_job_with_task(source) |> Tasks.create_job_with_task(source)
end end
@@ -30,8 +33,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, but rescheduling is determined by the if it's never been indexed before or if `force` is set to `true`, but rescheduling
`index_frequency_minutes` field. is determined by the `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_.
@@ -58,9 +61,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
5. If the source uses fast indexing, that job is kicked off as well. It 5. If the source uses fast indexing, that job is kicked off as well. It
uses RSS to run a smaller, faster, and more frequent index. That job uses RSS to run a smaller, faster, and more frequent index. That job
handles rescheduling itself but largely has a similar behaviour to this handles rescheduling itself but largely has a similar behaviour to this
job in that it kicks off index and maybe download jobs. The biggest difference job in that it runs and index and maybe kicks off media download jobs.
is that an index job is kicked off _for each new media item_ as opposed Check out `FastIndexingWorker` comments for more.
to one larger index job. Check out `MediaIndexingWorker` comments for more.
6. If the job reschedules, the cycle from step 3 repeats until the heat death 6. If the job reschedules, the cycle from step 3 repeats until the heat death
of the universe. The user changing things like the index frequency can of the universe. The user changing things like the index frequency can
dequeue or reschedule jobs as well dequeue or reschedule jobs as well
@@ -71,25 +73,29 @@ 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}}) do def perform(%Oban.Job{args: %{"id" => source_id} = args}) 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
{index_freq, _} when index_freq > 0 -> {index_freq, _} when index_freq > 0 ->
# If the indexing is on a schedule simply run indexing and reschedule # If the indexing is on a schedule simply run indexing and reschedule
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source) perform_indexing_and_notification(source)
maybe_enqueue_fast_indexing_task(source) maybe_enqueue_fast_indexing_task(source)
reschedule_indexing(source) reschedule_indexing(source)
{_, nil} -> {_, nil} ->
# If the source has never been indexed, index it once # If the source has never been indexed, index it once
# even if it's not meant to reschedule # even if it's not meant to reschedule
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source) perform_indexing_and_notification(source)
:ok :ok
_ -> _ ->
# 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 # perform a no-op (unless forced)
if args["force"] do
perform_indexing_and_notification(source)
end
:ok :ok
end end
rescue rescue
@@ -97,6 +103,14 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale") Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
end end
defp perform_indexing_and_notification(source) do
apprise_server = Settings.get!(:apprise_server)
SourceNotifications.wrap_new_media_notification(apprise_server, source, fn ->
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end)
end
defp reschedule_indexing(source) do defp reschedule_indexing(source) do
next_run_in = source.index_frequency_minutes * 60 next_run_in = source.index_frequency_minutes * 60
@@ -16,7 +16,6 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.FileFollowerServer alias Pinchflat.SlowIndexing.FileFollowerServer
alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@@ -27,12 +26,11 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
Returns {:ok, %Task{}}. Returns {:ok, %Task{}}.
""" """
def kickoff_indexing_task(%Source{} = source) do def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) 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, "MediaCollectionIndexingWorker") Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
MediaCollectionIndexingWorker.kickoff_with_task(source) MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts)
end end
@doc """ @doc """
@@ -60,7 +58,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
def index_and_enqueue_download_for_media_items(%Source{} = source) do def index_and_enqueue_download_for_media_items(%Source{} = source) do
# See the method definition below for more info on how file watchers work # See the method definition below for more info on how file watchers work
# (important reading if you're not familiar with it) # (important reading if you're not familiar with it)
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source) {:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
# Reload because the source may have been updated during the (long-running) indexing process # Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed. # and important settings like `download_media` may have changed.
source = Repo.reload!(source) source = Repo.reload!(source)
@@ -84,15 +82,15 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
# lines (ie: you should gracefully fail if you can't parse a line). # lines (ie: you should gracefully fail if you can't parse a line).
# #
# This works in-tandem with the normal (blocking) media indexing behaviour. When # This works in-tandem with the normal (blocking) media indexing behaviour. When
# the `get_media_attributes_for_collection` method completes it'll return the FULL result to # the `setup_file_watcher_and_kickoff_indexing` method completes it'll return the
# the caller for parsing. Ideally, every item in the list will have already # FULL result to the caller for parsing. Ideally, every item in the list will have already
# been processed by the file follower, but if not, the caller handles creation # been processed by the file follower, but if not, the caller handles creation
# of any media items that were missed/initially failed. # of any media items that were missed/initially failed.
# #
# It attempts a graceful shutdown of the file follower after the indexing is done, # It attempts a graceful shutdown of the file follower after the indexing is done,
# but the FileFollowerServer will also stop itself if it doesn't see any activity # but the FileFollowerServer will also stop itself if it doesn't see any activity
# for a sufficiently long time. # for a sufficiently long time.
defp get_media_attributes_for_collection_and_setup_file_watcher(source) do defp setup_file_watcher_and_kickoff_indexing(source) do
{:ok, pid} = FileFollowerServer.start_link() {:ok, pid} = FileFollowerServer.start_link()
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
@@ -127,11 +125,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
case Media.create_media_item_from_backend_attrs(source, media_attrs) do case Media.create_media_item_from_backend_attrs(source, media_attrs) do
{:ok, %MediaItem{} = media_item} -> {:ok, %MediaItem{} = media_item} ->
if source.download_media && Media.pending_download?(media_item) do DownloadingHelpers.kickoff_download_if_pending(media_item)
Logger.debug("FileFollowerServer Handler: Enqueuing download task for #{inspect(media_attrs)}")
MediaDownloadWorker.kickoff_with_task(media_item)
end
{:error, changeset} -> {:error, changeset} ->
changeset changeset
+8
View File
@@ -29,8 +29,10 @@ 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
output_path_template_override
)a )a
# Expensive API calls are made when a source is inserted/updated so # Expensive API calls are made when a source is inserted/updated so
@@ -72,8 +74,10 @@ 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
field :output_path_template_override, :string
field :series_directory, :string field :series_directory, :string
field :nfo_filepath, :string field :nfo_filepath, :string
@@ -106,7 +110,11 @@ 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)
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|> 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
+16 -4
View File
@@ -13,12 +13,25 @@ defmodule Pinchflat.Sources do
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker alias Pinchflat.Metadata.SourceMetadataStorageWorker
@doc """
Returns the relevant output path template for a source.
Pulls from the source's override if present, otherwise uses the media profile's.
Returns binary()
"""
def output_path_template(source) do
source = Repo.preload(source, :media_profile)
media_profile = source.media_profile
source.output_path_template_override || media_profile.output_path_template
end
@doc """ @doc """
Returns the list of sources. Returns [%Source{}, ...] Returns the list of sources. Returns [%Source{}, ...]
""" """
@@ -153,7 +166,7 @@ defmodule Pinchflat.Sources do
Source.filepath_attributes() Source.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end) |> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1) |> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) |> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
end end
defp delete_internal_metadata_files(source) do defp delete_internal_metadata_files(source) do
@@ -163,7 +176,7 @@ defmodule Pinchflat.Sources do
SourceMetadata.filepath_attributes() SourceMetadata.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end) |> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1) |> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) |> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
end end
defp add_source_details_to_changeset(source, changeset) do defp add_source_details_to_changeset(source, changeset) do
@@ -275,7 +288,6 @@ defmodule Pinchflat.Sources do
%{index_frequency_minutes: _} -> %{index_frequency_minutes: _} ->
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, "MediaCollectionIndexingWorker") Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
_ -> _ ->
+31
View File
@@ -0,0 +1,31 @@
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
+65
View File
@@ -0,0 +1,65 @@
defmodule Pinchflat.Utils.CliUtils do
@moduledoc """
Utility methods for working with CLI executables
"""
alias Pinchflat.Utils.StringUtils
@doc """
Wraps a command in a shell script that will terminate
the command if stdin is closed. Useful for stopping
commands if the job runner is cancelled.
Delegates to `System.cmd/3` and any options/output
are passed through.
Returns {binary(), integer()}
"""
def wrap_cmd(command, args, opts \\ []) do
wrapper_command = Path.join(:code.priv_dir(:pinchflat), "cmd_wrapper.sh")
actual_command = [command] ++ args
System.cmd(wrapper_command, actual_command, opts)
end
@doc """
Parses a list of command options into a list of strings suitable for passing to
`System.cmd/3`.
We want to satisfy the following behaviours:
1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
2. If the key is a string, assume we want it as-is and don't convert it
3. If the key is accompanied by a value, append the value to the list
4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
Returns [binary()]
"""
def parse_options(command_opts) do
command_opts
|> List.wrap()
|> Enum.reduce([], &parse_option/2)
end
defp parse_option({k, v}, acc) when is_atom(k) do
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
parse_option({"--#{stringified_key}", v}, acc)
end
defp parse_option({k, v}, acc) when is_binary(k) do
acc ++ [k, to_string(v)]
end
defp parse_option(arg, acc) when is_atom(arg) do
stringified_arg =
arg
|> Atom.to_string()
|> StringUtils.to_kebab_case()
parse_option("--#{stringified_arg}", acc)
end
defp parse_option(arg, acc) when is_binary(arg) do
acc ++ [arg]
end
end
@@ -1,4 +1,4 @@
defmodule Pinchflat.Filesystem.FilesystemHelpers do defmodule Pinchflat.Utils.FilesystemUtils do
@moduledoc """ @moduledoc """
Utility methods for working with the filesystem Utility methods for working with the filesystem
""" """
+17
View File
@@ -0,0 +1,17 @@
defmodule Pinchflat.Utils.XmlUtils do
@moduledoc """
Utility methods for working with XML documents
"""
@doc """
Escapes invalid XML characters in a string
Returns binary()
"""
def safe(value) do
value
|> to_string()
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
end
+47 -43
View File
@@ -5,11 +5,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do
require Logger require Logger
alias Pinchflat.Utils.StringUtils alias Pinchflat.Utils.CliUtils
alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils alias Pinchflat.YtDlp.YtDlpCommandRunner
alias Pinchflat.YtDlp.BackendCommandRunner alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
@behaviour BackendCommandRunner @behaviour YtDlpCommandRunner
@doc """ @doc """
Runs a yt-dlp command and returns the string output. Saves the output to Runs a yt-dlp command and returns the string output. Saves the output to
@@ -23,20 +23,21 @@ defmodule Pinchflat.YtDlp.CommandRunner do
Returns {:ok, binary()} | {:error, output, status}. Returns {:ok, binary()} | {:error, output, status}.
""" """
@impl BackendCommandRunner @impl YtDlpCommandRunner
def run(url, command_opts, output_template, addl_opts \\ []) do def run(url, command_opts, output_template, addl_opts \\ []) do
# This approach lets us mock the command for testing # This approach lets us mock the command for testing
command = backend_executable() command = backend_executable()
# These must stay in exactly this order, hence why I'm giving it its own variable.
# Also, can't use RAM file since yt-dlp needs a concrete filepath.
output_filepath = Keyword.get(addl_opts, :output_filepath, FSUtils.generate_metadata_tmpfile(:json))
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
cookie_opts = build_cookie_options()
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts ++ cookie_opts)
output_filepath = generate_output_filepath(addl_opts)
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
external_file_opts = build_external_file_options()
# These must stay in exactly this order, hence why I'm giving it its own variable.
all_opts = command_opts ++ print_to_file_opts ++ external_file_opts
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}") Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do
{_, 0} -> {_, 0} ->
# IDEA: consider deleting the file after reading it. It's in the tmp dir, so it's not # IDEA: consider deleting the file after reading it. It's in the tmp dir, so it's not
# a huge deal, but it's still a good idea to clean up after ourselves. # a huge deal, but it's still a good idea to clean up after ourselves.
@@ -48,47 +49,50 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end end
end end
defp build_cookie_options do @doc """
base_dir = Application.get_env(:pinchflat, :extras_directory) Returns the version of yt-dlp as a string
cookie_file = Path.join(base_dir, "cookies.txt")
case File.read(cookie_file) do Returns {:ok, binary()} | {:error, binary()}
{:ok, cookie_data} -> """
if String.trim(cookie_data) != "", do: [cookies: cookie_file], else: [] @impl YtDlpCommandRunner
def version do
command = backend_executable()
{:error, _} -> case System.cmd(command, ["--version"]) do
[] {output, 0} ->
{:ok, String.trim(output)}
{output, _} ->
{:error, output}
end end
end end
# We want to satisfy the following behaviours: defp generate_output_filepath(addl_opts) do
# case Keyword.get(addl_opts, :output_filepath) do
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience) nil -> FSUtils.generate_metadata_tmpfile(:json)
# 2. If the key is a string, assume we want it as-is and don't convert it path -> path
# 3. If the key is accompanied by a value, append the value to the list end
# 4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
defp parse_options(command_opts) do
Enum.reduce(command_opts, [], &parse_option/2)
end end
defp parse_option({k, v}, acc) when is_atom(k) do defp build_external_file_options do
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k)) base_dir = Application.get_env(:pinchflat, :extras_directory)
filename_options_map = %{cookies: "cookies.txt"}
parse_option({"--#{stringified_key}", v}, acc) Enum.reduce(filename_options_map, [], fn {opt_name, filename}, acc ->
end filepath = Path.join(base_dir, filename)
defp parse_option({k, v}, acc) when is_binary(k) do case File.read(filepath) do
acc ++ [k, to_string(v)] {:ok, file_data} ->
end if String.trim(file_data) != "" do
[{opt_name, filepath} | acc]
else
acc
end
defp parse_option(arg, acc) when is_atom(arg) do {:error, _} ->
stringified_arg = StringUtils.to_kebab_case(Atom.to_string(arg)) acc
end
parse_option("--#{stringified_arg}", acc) end)
end
defp parse_option(arg, acc) when is_binary(arg) do
acc ++ [arg]
end end
defp backend_executable do defp backend_executable do
+2 -2
View File
@@ -35,10 +35,10 @@ defmodule Pinchflat.YtDlp.Media do
Returns {:ok, map()} | {:error, any, ...}. Returns {:ok, map()} | {:error, any, ...}.
""" """
def download(url, command_opts \\ []) do def download(url, command_opts \\ [], addl_opts \\ []) do
opts = [:no_simulate] ++ command_opts opts = [:no_simulate] ++ command_opts
with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j"), with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j", addl_opts),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json} {:ok, parsed_json}
else else
+2 -2
View File
@@ -6,7 +6,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
require Logger require Logger
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@doc """ @doc """
@@ -26,7 +26,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
# available formats since we're just getting the media details # available formats since we're just getting the media details
command_opts = [:simulate, :skip_download, :ignore_no_formats_error] command_opts = [:simulate, :skip_download, :ignore_no_formats_error]
output_template = YtDlpMedia.indexing_output_template() output_template = YtDlpMedia.indexing_output_template()
output_filepath = FilesystemHelpers.generate_metadata_tmpfile(:json) output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false) file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
if file_listener_handler do if file_listener_handler do
@@ -1,4 +1,4 @@
defmodule Pinchflat.YtDlp.BackendCommandRunner do defmodule Pinchflat.YtDlp.YtDlpCommandRunner do
@moduledoc """ @moduledoc """
A behaviour for running CLI commands against a downloader backend (yt-dlp). A behaviour for running CLI commands against a downloader backend (yt-dlp).
@@ -8,4 +8,5 @@ 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
@@ -247,6 +247,7 @@ defmodule PinchflatWeb.CoreComponents do
attr :label_suffix, :string, default: nil attr :label_suffix, :string, default: nil
attr :value, :any attr :value, :any
attr :help, :string, default: nil attr :help, :string, default: nil
attr :html_help, :boolean, default: false
attr :type, :string, attr :type, :string,
default: "text", default: "text",
@@ -298,7 +299,7 @@ defmodule PinchflatWeb.CoreComponents do
<%= @label %> <%= @label %>
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span> <span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
</label> </label>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -325,7 +326,7 @@ defmodule PinchflatWeb.CoreComponents do
</label> </label>
</div> </div>
</section> </section>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -356,7 +357,7 @@ defmodule PinchflatWeb.CoreComponents do
> >
</div> </div>
</div> </div>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
</div> </div>
@@ -387,7 +388,7 @@ defmodule PinchflatWeb.CoreComponents do
</select> </select>
<%= render_slot(@inner_block) %> <%= render_slot(@inner_block) %>
</div> </div>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -411,7 +412,7 @@ defmodule PinchflatWeb.CoreComponents do
]} ]}
{@rest} {@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea> ><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -438,7 +439,7 @@ defmodule PinchflatWeb.CoreComponents do
]} ]}
{@rest} {@rest}
/> />
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -14,7 +14,8 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
def tabbed_layout(assigns) do def tabbed_layout(assigns) do
~H""" ~H"""
<div <div
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }" x-data="{ openTab: getTabIndex(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">
@@ -22,7 +23,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 = #{idx}"} @click.prevent={"openTab = setTabIndex(#{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"
> >
@@ -33,7 +34,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
<%= render_slot(@tab_append) %> <%= render_slot(@tab_append) %>
</div> </div>
</header> </header>
<div class="mt-4"> <div class="mt-4 min-h-60">
<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>
@@ -24,6 +24,7 @@
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} /> <.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} /> <.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} /> <.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
<.sidebar_item icon="hero-cog-6-tooth" text="Settings" href={~p"/settings"} />
</ul> </ul>
</div> </div>
</nav> </nav>
@@ -38,12 +39,7 @@
target="_blank" target="_blank"
href="https://github.com/kieraneglin/pinchflat/wiki" href="https://github.com/kieraneglin/pinchflat/wiki"
/> />
<.sidebar_item <.sidebar_item icon="hero-cog" text="Github" target="_blank" href="https://github.com/kieraneglin/pinchflat" />
icon="hero-code-bracket"
text="Github"
target="_blank"
href="https://github.com/kieraneglin/pinchflat"
/>
<li> <li>
<span <span
class={[ class={[
@@ -58,8 +54,11 @@
</span> </span>
</li> </li>
<li> <li>
<span class="group relative flex items-center gap-2.5 px-4 py-2 text-sm"> <span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
v<%= Application.spec(:pinchflat)[:vsn] %> Pinchflat 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>
@@ -3,10 +3,11 @@ defmodule Pinchflat.UpgradeButtonLive do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<form phx-change="check_matching_text"> <form id="upgradeForm" phx-change="check_matching_text" phx-hook="supressEnterSubmission">
<.input type="text" name="unlock-pro-textbox" value="" /> <.input type="text" name="unlock-pro-textbox" value="" />
</form> </form>
<%!-- The setTimeout is so the modal has time to disappear before it's removed --%>
<.button <.button
class="w-full mt-4" class="w-full mt-4"
type="button" type="button"
@@ -30,7 +31,7 @@ defmodule Pinchflat.UpgradeButtonLive do
|> String.downcase() |> String.downcase()
if normalized_text == "got it!" do if normalized_text == "got it!" do
Settings.set!(:pro_enabled, true) Settings.set(pro_enabled: true)
{:noreply, update(socket, :button_disabled, fn _ -> false end)} {:noreply, update(socket, :button_disabled, fn _ -> false end)}
else else
@@ -0,0 +1,9 @@
defmodule PinchflatWeb.HealthController do
use PinchflatWeb, :controller
def check(conn, _params) do
conn
|> put_status(:ok)
|> json(%{status: "ok"})
end
end
@@ -6,6 +6,7 @@ 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 =
@@ -16,21 +17,50 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
render(conn, :show, media_item: media_item) render(conn, :show, media_item: media_item)
end end
def delete(conn, %{"id" => id} = params) do def edit(conn, %{"id" => id}) do
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_item(media_item, delete_files: delete_files) changeset = Media.change_media_item(media_item)
flash_message = render(conn, :edit, media_item: media_item, changeset: changeset)
if delete_files do end
"Record and files deleted successfully."
else def update(conn, %{"id" => id, "media_item" => params}) do
"Record deleted successfully. Files were not deleted." 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
prevent_download = Map.get(params, "prevent_download", false)
media_item = Media.get_media_item!(id)
{:ok, _} = Media.delete_media_files(media_item, %{prevent_download: prevent_download})
conn
|> put_flash(:info, "Files deleted successfully.")
|> redirect(to: ~p"/sources/#{media_item.source_id}")
end
def force_download(conn, %{"media_item_id" => id}) do
media_item = Media.get_media_item!(id)
:ok =
case MediaDownloadWorker.kickoff_with_task(media_item, %{force: true}) do
{:ok, _} -> :ok
{:error, :duplicate_job} -> :ok
err -> err
end end
conn conn
|> put_flash(:info, flash_message) |> put_flash(:info, "Download task enqueued.")
|> redirect(to: ~p"/sources/#{media_item.source_id}") |> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
end end
# See here for details on streaming files and range requests: # See here for details on streaming files and range requests:
@@ -3,6 +3,14 @@ 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
@@ -0,0 +1,32 @@
<.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>
@@ -0,0 +1,13 @@
<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>
@@ -0,0 +1,31 @@
<.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,25 +4,23 @@
<.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">
Media Item #<%= @media_item.id %> <%= StringUtils.truncate(@media_item.title, 35) %>
</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>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50"> <.actions_dropdown media_item={@media_item} />
<: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">
@@ -32,12 +30,13 @@
<.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>
<.inline_link href={~p"/sources/#{@media_item.source_id}"}> <.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
<%= @media_item.source.custom_name %> <%= @media_item.source.custom_name %>
</.inline_link> </.subtle_link>
</section> </section>
<.list_items_from_map map={Map.from_struct(@media_item)} /> <.list_items_from_map map={Map.from_struct(@media_item)} />
@@ -1,12 +1,19 @@
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 = Profiles.list_media_profiles() 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
@@ -32,12 +39,15 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
end end
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
media_profile = media_profile = Profiles.get_media_profile!(id)
id
|> Profiles.get_media_profile!()
|> Repo.preload(:sources)
render(conn, :show, media_profile: media_profile) sources =
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
@@ -59,6 +59,9 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
upload_year: nil, upload_year: nil,
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD", upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD",
source_custom_name: "the name of the sources that use this profile", source_custom_name: "the name of the sources that use this profile",
source_collection_id: "the YouTube ID of the sources that use this profile",
source_collection_name:
"the YouTube name of the sources that use this profile (often the same as source_custom_name)",
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'", source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
artist_name: "the name of the artist with fallbacks to other uploader fields" artist_name: "the name of the artist with fallbacks to other uploader fields"
} }
@@ -203,6 +203,17 @@
/> />
</section> </section>
<section x-data="{ presets: { default: null, media_center: 1, audio: null, archiving: 1 } }">
<.input
field={f[:redownload_delay_days]}
type="number"
label="Redownload Delay (days)"
min="0"
help="Delay in days until new media is redownloaded. Redownloading new media can improve its quality or SponsorBlock tags. Leave blank to not redownload"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
</section>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <h3 class="mt-8 text-2xl text-black dark:text-white">
Media Center Options Media Center Options
</h3> </h3>
@@ -50,9 +50,11 @@
</div> </div>
</:tab> </:tab>
<:tab title="Sources"> <:tab title="Sources">
<.table rows={@media_profile.sources} table_class="text-black dark:text-white"> <.table rows={@sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name"> <:col :let={source} label="Name">
<%= source.custom_name || source.collection_name %> <.subtle_link href={~p"/sources/#{source.id}"}>
<%= 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?">
@@ -11,7 +11,7 @@ defmodule PinchflatWeb.Pages.PageController do
done_onboarding = params["onboarding"] == "0" done_onboarding = params["onboarding"] == "0"
force_onboarding = params["onboarding"] == "1" force_onboarding = params["onboarding"] == "1"
if done_onboarding, do: Settings.set!(:onboarding, false) if done_onboarding, do: Settings.set(onboarding: false)
if force_onboarding || Settings.get!(:onboarding) do if force_onboarding || Settings.get!(:onboarding) do
render_onboarding_page(conn) render_onboarding_page(conn)
@@ -30,7 +30,7 @@ defmodule PinchflatWeb.Pages.PageController do
end end
defp render_onboarding_page(conn) do defp render_onboarding_page(conn) do
Settings.set!(:onboarding, true) Settings.set(onboarding: true)
conn conn
|> render(:onboarding_checklist, |> render(:onboarding_checklist,
@@ -2,7 +2,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media.MediaQuery
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,10 +20,15 @@ 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.
# We only need one since we're using the internal metadata image which # This is used to fetch a fallback cover image
# we know exists. # if the source doesn't have any usable images
media_items = Media.list_downloaded_media_items_for(source, limit: 1) media_items =
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, _} ->
@@ -0,0 +1,26 @@
defmodule PinchflatWeb.Settings.SettingController do
use PinchflatWeb, :controller
alias Pinchflat.Settings
def show(conn, _params) do
setting = Settings.record()
changeset = Settings.change_setting(setting)
render(conn, "show.html", changeset: changeset)
end
def update(conn, %{"setting" => setting_params}) do
setting = Settings.record()
case Settings.update_setting(setting, setting_params) do
{:ok, _} ->
conn
|> put_flash(:info, "Settings updated successfully.")
|> redirect(to: ~p"/settings")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "show.html", changeset: changeset)
end
end
end
@@ -0,0 +1,20 @@
defmodule PinchflatWeb.Settings.SettingHTML do
use PinchflatWeb, :html
embed_templates "setting_html/*"
@doc """
Renders a setting form.
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
def setting_form(assigns)
def apprise_server_help do
url = "https://github.com/caronc/apprise/wiki/URLBasics"
classes = "underline decoration-bodydark decoration-1 hover:decoration-white"
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{classes}" target="_blank">Apprise docs</a> for more information)
end
end
@@ -0,0 +1,21 @@
<.simple_form :let={f} for={@changeset} action={@action}>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Notification Settings
</h3>
<.input
field={f[:apprise_server]}
type="text"
label="Apprise Server"
help={apprise_server_help()}
html_help={true}
inputclass="font-mono text-sm"
placeholder="https://discordapp.com/api/webhooks/{WebhookID}/{WebhookToken}"
/>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Settings</.button>
</.simple_form>
@@ -0,0 +1,12 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex gap-3 items-center">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Settings
</h2>
</div>
</div>
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="max-w-full overflow-x-auto">
<.setting_form changeset={@changeset} action={~p"/settings"} />
</div>
</div>
@@ -4,15 +4,21 @@ 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.Profiles alias Pinchflat.MediaQuery
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 = Repo.preload(Sources.list_sources(), :media_profile) sources =
Source
|> order_by(asc: :custom_name)
|> Repo.all()
|> Repo.preload(:media_profile)
render(conn, :index, sources: sources) render(conn, :index, sources: sources)
end end
@@ -49,9 +55,26 @@ 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 = Repo.preload(Tasks.list_pending_tasks_for(source), :job) pending_tasks =
pending_media = Media.list_pending_media_items_for(source, limit: 100) source
downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100) |> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable])
|> Repo.preload(:job)
pending_media =
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.where_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,
@@ -104,12 +127,30 @@ 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
Profiles.list_media_profiles() MediaProfile
|> 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)
@@ -28,4 +28,20 @@ defmodule PinchflatWeb.Sources.SourceHTML do
def rss_feed_url(conn, source) do def rss_feed_url(conn, source) do
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml" url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end end
def output_path_template_override_placeholders(media_profiles) do
media_profiles
|> Enum.map(&{&1.id, &1.output_path_template})
|> Map.new()
|> Phoenix.json_library().encode!()
end
def output_path_template_override_help do
help_button_classes = "underline decoration-bodydark decoration-1 hover:decoration-white cursor-pointer"
help_button = ~s{<span class="#{help_button_classes}" x-on:click="$dispatch('load-template')">Click here</span>}
"""
Must end with .{{ ext }}. Same rules as Media Profile output path templates. #{help_button} to load your media profile's output template
"""
end
end end
@@ -0,0 +1,55 @@
<.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,6 +22,13 @@
<: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,43 +20,7 @@
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab_append> <:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50"> <.actions_dropdown source={@source} conn={@conn} />
<: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">
@@ -64,9 +28,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>
<.inline_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 %>
</.inline_link> </.subtle_link>
</section> </section>
<.list_items_from_map map={Map.from_struct(@source)} /> <.list_items_from_map map={Map.from_struct(@source)} />
@@ -77,10 +41,17 @@
<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">
<%= StringUtils.truncate(media_item.title, 50) %> <.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
<%= 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" /> <.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}/edit"}
icon="hero-pencil-square"
class="mx-1"
/>
</:col> </:col>
</.table> </.table>
<% else %> <% else %>
@@ -92,10 +63,17 @@
<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">
<%= StringUtils.truncate(media_item.title, 50) %> <.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
<%= 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" /> <.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}/edit"}
icon="hero-pencil-square"
class="mx-1"
/>
</:col> </:col>
</.table> </.table>
<% else %> <% else %>
@@ -9,105 +9,139 @@
Oops, something went wrong! Please check the errors below. Oops, something went wrong! Please check the errors below.
</.error> </.error>
<section class="flex justify-between items-center mt-8"> <section x-data="{ mediaProfileId: null }">
<h3 class=" text-2xl text-black dark:text-white"> <section class="flex justify-between items-center mt-8">
General Options <h3 class=" text-2xl text-black dark:text-white">
</h3> General Options
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode"> </h3>
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span> <span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
</span> Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
</section> </span>
</section>
<.input
field={f[:custom_name]}
type="text"
label="Custom Name"
help="Something descriptive. Does not impact indexing or downloading"
/>
<.input field={f[:original_url]} type="text" label="Source URL" help="URL of a channel or playlist (required)" />
<.input
field={f[:media_profile_id]}
options={Enum.map(@media_profiles, &{&1.name, &1.id})}
type="select"
label="Media Profile"
help="Sets your preferences for what media to look for and how to store it"
/>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Indexing Options
</h3>
<section x-data="{ fastIndexingEnabled: null }">
<.input <.input
field={f[:index_frequency_minutes]} field={f[:custom_name]}
options={friendly_index_frequencies()} type="text"
type="select" label="Custom Name"
label="Index Frequency" help="Something descriptive. Does not impact indexing or downloading"
x-bind:disabled="fastIndexingEnabled == true"
x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))"
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting"
/> />
<div phx-click={show_modal("upgrade-modal")}> <.input field={f[:original_url]} type="text" label="Source URL" help="URL of a channel or playlist (required)" />
<.input
field={f[:media_profile_id]}
options={Enum.map(@media_profiles, &{&1.name, &1.id})}
type="select"
label="Media Profile"
help="Sets your preferences for what media to look for and how to store it"
x-model.fill="mediaProfileId"
/>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Indexing Options
</h3>
<section x-data="{ fastIndexingEnabled: null }">
<.input <.input
field={f[:fast_index]} field={f[:index_frequency_minutes]}
type="toggle" options={friendly_index_frequencies()}
label="Use Fast Indexing" type="select"
label_suffix="(pro)" label="Index Frequency"
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info" x-bind:disabled="fastIndexingEnabled == true"
x-init=" x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))"
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting"
/>
<div phx-click={show_modal("upgrade-modal")}>
<.input
field={f[:fast_index]}
type="toggle"
label="Use Fast Indexing"
label_suffix="(pro)"
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. Does not work with private playlists. See below for more info"
x-init="
// `enabled` is the data attribute that the toggle uses internally // `enabled` is the data attribute that the toggle uses internally
fastIndexingEnabled = enabled fastIndexingEnabled = enabled
$watch('enabled', value => fastIndexingEnabled = !!value) $watch('enabled', value => fastIndexingEnabled = !!value)
" "
/> />
</div> </div>
</section> </section>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Downloading Options
</h3>
<.input
field={f[:download_media]}
type="toggle"
label="Download Media"
help="Unchecking still indexes media but it won't be downloaded until you enable this option"
/>
<.input
field={f[:download_cutoff_date]}
type="text"
label="Download Cutoff Date"
placeholder="YYYY-MM-DD"
maxlength="10"
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
title="YYYY-MM-DD"
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
/>
<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 Downloading Options
</h3> </h3>
<p class="text-sm mt-2">
Tread carefully
</p>
<.input <.input
field={f[:title_filter_regex]} field={f[:download_media]}
type="text" type="toggle"
label="Title Filter Regex" label="Download Media"
placeholder="(?i)^How to Bike$" help="Unchecking still indexes media but it won't be downloaded until you enable this option"
help="A PCRE-compatible regex. Only media with titles that match this regex will be downloaded. Look up 'SQLean Regex docs' for more"
/> />
<.input
field={f[:download_cutoff_date]}
type="text"
label="Download Cutoff Date"
placeholder="YYYY-MM-DD"
maxlength="10"
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
title="YYYY-MM-DD"
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">
<h3 class="mt-8 text-2xl text-black dark:text-white">
Advanced Options
</h3>
<p class="text-sm mt-2">
Tread carefully
</p>
<.input
field={f[:title_filter_regex]}
type="text"
label="Title Filter Regex"
placeholder="(?i)^How to Bike$"
help="A PCRE-compatible regex. Only media with titles that match this regex will be downloaded. Look up 'SQLean Regex docs' for more"
/>
<section
x-data={
"""
{
placeholders: JSON.parse('#{output_path_template_override_placeholders(@media_profiles)}'),
inputValue: null
}
"""
}
x-on:load-template="inputValue = placeholders[mediaProfileId]"
>
<.input
field={f[:output_path_template_override]}
type="text"
inputclass="font-mono"
label="Output path template override"
help={output_path_template_override_help()}
html_help={true}
x-bind:placeholder="placeholders[mediaProfileId]"
x-model.fill="inputValue"
/>
</section>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Source</.button>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
<.fast_indexing_help />
</div>
</section> </section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Source</.button>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
<.fast_indexing_help />
</div>
</.simple_form> </.simple_form>
+1 -1
View File
@@ -20,7 +20,7 @@ defmodule PinchflatWeb.Endpoint do
plug Plug.Static, plug Plug.Static,
at: "/", at: "/",
from: :pinchflat, from: :pinchflat,
gzip: false, gzip: Mix.env() == :prod,
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 -1
View File
@@ -30,9 +30,15 @@ defmodule PinchflatWeb.Router do
resources "/media_profiles", MediaProfiles.MediaProfileController resources "/media_profiles", MediaProfiles.MediaProfileController
resources "/search", Searches.SearchController, only: [:show], singleton: true resources "/search", Searches.SearchController, only: [:show], singleton: true
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
resources "/sources", Sources.SourceController do resources "/sources", Sources.SourceController do
resources "/media", MediaItems.MediaItemController, only: [:show, :delete] post "/force_download", Sources.SourceController, :force_download
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
@@ -47,6 +53,13 @@ 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
scope "/", PinchflatWeb do
pipe_through :api
get "/healthcheck", HealthController, :check
end
scope "/dev" do scope "/dev" do
pipe_through :browser pipe_through :browser
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "0.1.6", version: "0.1.14",
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,
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# This script is a wrapper for other programs
# that ensures they are killed when stdin closes
# (eg: a job terminates)
# Start the program in the background
exec "$@" &
pid1=$!
# Silence warnings from here on
exec >/dev/null 2>&1
# Read from stdin in the background and
# kill running program when stdin closes
exec 0<&0 $(
while read; do :; done
kill -KILL $pid1
) &
pid2=$!
# Clean up
wait $pid1
ret=$?
kill -KILL $pid2
exit $ret
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddRetentionPeriodToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :retention_period_days, :integer
end
end
end
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,7 @@
defmodule Pinchflat.Repo.Migrations.RenameSettingsTable do
use Ecto.Migration
def change do
rename table(:settings), to: table(:settings_backup)
end
end
@@ -0,0 +1,29 @@
defmodule Pinchflat.Repo.Migrations.CreateNewSettings do
use Ecto.Migration
def up do
create table(:settings) do
add :onboarding, :boolean, default: true, null: false
add :pro_enabled, :boolean, default: false, null: false
add :yt_dlp_version, :string
end
# Make an initial record because this will be the only one ever inserted
execute "INSERT INTO settings (onboarding, pro_enabled, yt_dlp_version) VALUES (true, false, NULL)"
# Set the value of onboarding to the previous version set in `settings_backup`
execute """
UPDATE settings
SET onboarding = COALESCE((SELECT value = 'true' FROM settings_backup WHERE name = 'onboarding'), true)
"""
execute """
UPDATE settings
SET pro_enabled = COALESCE((SELECT value = 'true' FROM settings_backup WHERE name = 'pro_enabled'), false)
"""
end
def down do
drop table(:settings)
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddAppriseServersToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :apprise_server, :string
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddAppriseVersionToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :apprise_version, :string
end
end
end
@@ -0,0 +1,13 @@
defmodule Pinchflat.Repo.Migrations.AddRedownloadedFields do
use Ecto.Migration
def change do
alter table(:media_profiles) do
add :redownload_delay_days, :integer
end
alter table(:media_items) do
add :media_redownloaded_at, :utc_datetime
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddOutputTemplateToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :output_path_template_override, :string
end
end
end
+11 -7
View File
@@ -79,7 +79,7 @@ ARG PORT=8945
RUN apt-get update -y RUN apt-get update -y
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
ffmpeg curl git openssh-client nano ffmpeg curl git openssh-client nano python3 python3-pip
RUN apt-get clean && rm -f /var/lib/apt/lists/*_* RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
# Download and update YT-DLP # Download and update YT-DLP
@@ -87,19 +87,22 @@ RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o
RUN chmod a+rx /usr/local/bin/yt-dlp RUN chmod a+rx /usr/local/bin/yt-dlp
RUN yt-dlp -U RUN yt-dlp -U
# Download Apprise
RUN python3 -m pip install -U apprise --break-system-packages
# Download Mutagen for music thumbnail generation
RUN python3 -m pip install -U mutagen --break-system-packages
# Set the locale # Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8 ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en 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"
@@ -108,7 +111,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 --chown=nobody:root /app/_build/${MIX_ENV}/rel/pinchflat ./ COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
# NEVER do this if you're running in an environment where you don't trust the user # 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.
@@ -121,12 +124,13 @@ COPY --from=builder --chown=nobody:root /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"]
@@ -1,46 +0,0 @@
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
@@ -1,25 +1,77 @@
defmodule Pinchflat.Boot.PreJobStartupTasksTest do defmodule Pinchflat.Boot.PreJobStartupTasksTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Mox
import Pinchflat.JobFixtures
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Settings.Setting
alias Pinchflat.Boot.PreJobStartupTasks alias Pinchflat.Boot.PreJobStartupTasks
describe "apply_default_settings" do setup do
setup do stub(YtDlpRunnerMock, :version, fn -> {:ok, "1"} end)
Repo.delete_all(Setting) stub(AppriseRunnerMock, :version, fn -> {:ok, "2"} end)
:ok :ok
end
describe "reset_executing_jobs" do
test "resets executing jobs" do
job = job_fixture()
Repo.update_all(Oban.Job, set: [state: "executing"])
assert Repo.reload!(job).state == "executing"
PreJobStartupTasks.init(%{})
assert Repo.reload!(job).state == "retryable"
end
end
describe "create_blank_yt_dlp_files" do
test "creates a blank cookie file" do
base_dir = Application.get_env(:pinchflat, :extras_directory)
filepath = Path.join(base_dir, "cookies.txt")
File.rm(filepath)
refute File.exists?(filepath)
PreJobStartupTasks.init(%{})
assert File.exists?(filepath)
end end
test "sets default settings" do test "creates a blank yt-dlp config file" do
assert_raise Ecto.NoResultsError, fn -> Settings.get!(:onboarding) end base_dir = Application.get_env(:pinchflat, :extras_directory)
assert_raise Ecto.NoResultsError, fn -> Settings.get!(:pro_enabled) end filepath = Path.join([base_dir, "yt-dlp-configs", "base-config.txt"])
File.rm(filepath)
PreJobStartupTasks.start_link() refute File.exists?(filepath)
assert Settings.get!(:onboarding) PreJobStartupTasks.init(%{})
refute Settings.get!(:pro_enabled)
assert File.exists?(filepath)
end
end
describe "apply_default_settings" do
test "sets yt_dlp version" do
Settings.set(yt_dlp_version: nil)
refute Settings.get!(:yt_dlp_version)
PreJobStartupTasks.init(%{})
assert Settings.get!(:yt_dlp_version)
end
test "sets apprise version" do
Settings.set(apprise_version: nil)
refute Settings.get!(:apprise_version)
PreJobStartupTasks.init(%{})
assert Settings.get!(:apprise_version)
end end
end end
end end
@@ -4,7 +4,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
alias Pinchflat.Sources
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadOptionBuilder alias Pinchflat.Downloading.DownloadOptionBuilder
setup do setup do
@@ -30,6 +32,20 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:output, "/tmp/test/media/#{media_item.source.custom_name}.%(ext)s"} in res assert {:output, "/tmp/test/media/#{media_item.source.custom_name}.%(ext)s"} in res
end end
test "uses source's output override if present", %{media_item: media_item} do
source = media_item.source
{:ok, _} = Sources.update_source(source, %{output_path_template_override: "override.%(ext)s"})
media_item =
media_item
|> Repo.reload()
|> Repo.preload(source: :media_profile)
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:output, "/tmp/test/media/override.%(ext)s"} in res
end
end end
describe "build/1 when testing default options" do describe "build/1 when testing default options" do
@@ -38,6 +54,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert :no_progress in res assert :no_progress in res
assert :windows_filenames in res assert :windows_filenames in res
assert :force_overwrites in res
assert {:parse_metadata, "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"} in res
end end
end end
@@ -133,6 +151,20 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:output, "thumbnail:/tmp/test/media/%(title)S-thumb.%(ext)s"} in res assert {:output, "thumbnail:/tmp/test/media/%(title)S-thumb.%(ext)s"} in res
end end
test "appends -thumb to source's output path override, if present", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{download_thumbnail: true})
{:ok, _} = Sources.update_source(media_item.source, %{output_path_template_override: "override.%(ext)s"})
media_item =
media_item
|> Repo.reload()
|> Repo.preload(source: :media_profile)
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:output, "thumbnail:/tmp/test/media/override-thumb.%(ext)s"} in res
end
test "converts thumbnail to jpg when download_thumbnail is true", %{media_item: media_item} do test "converts thumbnail to jpg when download_thumbnail is true", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{download_thumbnail: true}) media_item = update_media_profile_attribute(media_item, %{download_thumbnail: true})
@@ -209,6 +241,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:ok, res} = DownloadOptionBuilder.build(media_item) assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res
assert {:remux_video, "mp4"} in res
end) end)
end end
@@ -219,6 +252,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert :extract_audio in res assert :extract_audio in res
assert {:format, "bestaudio[ext=m4a]"} in res assert {:format, "bestaudio[ext=m4a]"} in res
refute {:remux_video, "mp4"} in res
end end
end end
@@ -261,12 +296,111 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end end
end end
describe "build/1 when testing config file options" do
setup do
base_dir = Path.join(Application.get_env(:pinchflat, :extras_directory), "yt-dlp-configs")
{:ok, %{base_dir: base_dir}}
end
test "includes base config file if it's present", %{media_item: media_item, base_dir: base_dir} do
filepath = Path.join(base_dir, "base-config.txt")
FilesystemUtils.write_p!(filepath, "base config")
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:config_locations, filepath} in res
end
test "includes media profile config file if it's present", %{media_item: media_item, base_dir: base_dir} do
media_profile = media_item.source.media_profile
filepath = Path.join(base_dir, "media-profile-#{media_profile.id}-config.txt")
FilesystemUtils.write_p!(filepath, "profile config")
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:config_locations, filepath} in res
end
test "includes source config file if it's present", %{media_item: media_item, base_dir: base_dir} do
source = media_item.source
filepath = Path.join(base_dir, "source-#{source.id}-config.txt")
FilesystemUtils.write_p!(filepath, "profile config")
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:config_locations, filepath} in res
end
test "includes media item config file if it's present", %{media_item: media_item, base_dir: base_dir} do
filepath = Path.join(base_dir, "media-item-#{media_item.id}-config.txt")
FilesystemUtils.write_p!(filepath, "media item config")
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:config_locations, filepath} in res
end
test "does not include config file options if they are not present", %{media_item: media_item} do
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute :config_locations in res
end
test "does not return a config file if it's blank", %{media_item: media_item, base_dir: base_dir} do
filepath = Path.join(base_dir, "base-config.txt")
FilesystemUtils.write_p!(filepath, " \n \n ")
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute :config_locations in res
end
test "returns config files in order of precedence", %{media_item: media_item, base_dir: base_dir} do
source = media_item.source
media_profile = source.media_profile
base_filepath = Path.join(base_dir, "base-config.txt")
source_filepath = Path.join(base_dir, "source-#{source.id}-config.txt")
media_item_filepath = Path.join(base_dir, "media-item-#{media_item.id}-config.txt")
media_profile_filepath = Path.join(base_dir, "media-profile-#{media_profile.id}-config.txt")
FilesystemUtils.write_p!(base_filepath, "config")
FilesystemUtils.write_p!(source_filepath, "config")
FilesystemUtils.write_p!(media_item_filepath, "config")
FilesystemUtils.write_p!(media_profile_filepath, "config")
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
expected_order = [
{:config_locations, base_filepath},
{:config_locations, media_profile_filepath},
{:config_locations, source_filepath},
{:config_locations, media_item_filepath}
]
assert Enum.filter(res, fn
{:config_locations, _} -> true
_ -> false
end) == expected_order
end
end
describe "build_output_path_for/1" do describe "build_output_path_for/1" do
test "builds an output path for a source", %{media_item: media_item} do test "builds an output path for a source", %{media_item: media_item} do
path = DownloadOptionBuilder.build_output_path_for(media_item.source) path = DownloadOptionBuilder.build_output_path_for(media_item.source)
assert path == "/tmp/test/media/%(title)S.%(ext)s" assert path == "/tmp/test/media/%(title)S.%(ext)s"
end end
test "uses source's output override if present", %{media_item: media_item} do
source = media_item.source
{:ok, source} = Sources.update_source(source, %{output_path_template_override: "override.%(ext)s"})
path = DownloadOptionBuilder.build_output_path_for(source)
assert path == "/tmp/test/media/override.%(ext)s"
end
end end
defp update_media_profile_attribute(media_item_with_preloads, attrs) do defp update_media_profile_attribute(media_item_with_preloads, attrs) do
@@ -4,6 +4,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
import Mox import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
@@ -72,4 +73,44 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
assert [] = Tasks.list_tasks_for(media_item) assert [] = Tasks.list_tasks_for(media_item)
end end
end end
describe "kickoff_download_if_pending/1" do
setup do
media_item = media_item_fixture(media_filepath: nil)
{:ok, media_item: media_item}
end
test "enqueues a download job", %{media_item: media_item} do
assert {:ok, _} = DownloadingHelpers.kickoff_download_if_pending(media_item)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
end
test "creates and returns a download task record", %{media_item: media_item} do
assert {:ok, task} = DownloadingHelpers.kickoff_download_if_pending(media_item)
assert [found_task] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker")
assert task.id == found_task.id
end
test "does not enqueue a download job if the source does not allow it" do
source = source_fixture(%{download_media: false})
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
assert {:error, :should_not_download} = DownloadingHelpers.kickoff_download_if_pending(media_item)
refute_enqueued(worker: MediaDownloadWorker)
end
test "does not enqueue a download job if the media item does not match the format rules" do
profile = media_profile_fixture(%{livestream_behaviour: :exclude})
source = source_fixture(%{media_profile_id: profile.id})
media_item = media_item_fixture(source_id: source.id, media_filepath: nil, livestream: true)
assert {:error, :should_not_download} = DownloadingHelpers.kickoff_download_if_pending(media_item)
refute_enqueued(worker: MediaDownloadWorker)
end
end
end end
@@ -4,8 +4,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
import Mox import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
alias Pinchflat.Media
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
setup :verify_on_exit! setup :verify_on_exit!
@@ -34,21 +35,40 @@ 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
test "it saves attributes to the media_item", %{media_item: media_item} do test "it saves attributes to the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
assert media_item.media_filepath == nil assert media_item.media_filepath == nil
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
assert Repo.reload(media_item).media_filepath != nil media_item = Repo.reload(media_item)
assert media_item.media_filepath != nil
end end
test "it saves the metadata to the media_item", %{media_item: media_item} do test "it saves the metadata to the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -65,7 +85,19 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "it sets the job to retryable if the download fails", %{media_item: media_item} do test "it sets the job to retryable if the download fails", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error"} end)
Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
assert job.state == "retryable"
end)
end
test "sets the job to retryable if the download failed and was retried", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, "Unable to communicate with SponsorBlock", 1}
end)
Oban.Testing.with_testing_mode(:inline, fn -> Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id})) {:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
@@ -75,23 +107,40 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "it ensures error are returned in a 2-item tuple", %{media_item: media_item} do 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) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error", 1} end)
assert {:error, :download_failed} = perform_job(MediaDownloadWorker, %{id: media_item.id}) assert {:error, :download_failed} = perform_job(MediaDownloadWorker, %{id: media_item.id})
end 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, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false}) Sources.update_source(media_item.source, %{download_media: false})
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
end end
test "does not download if the media item is set to not download", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl -> :ok end)
Media.update_media_item(media_item, %{prevent_download: true})
perform_job(MediaDownloadWorker, %{id: media_item.id})
end
test "downloads anyway if forced", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false})
Media.update_media_item(media_item, %{prevent_download: true})
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, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
FilesystemHelpers.write_p!(metadata["filepath"], "test") FilesystemUtils.write_p!(metadata["filepath"], "test")
{:ok, Phoenix.json_library().encode!(metadata)} {:ok, Phoenix.json_library().encode!(metadata)}
end) end)
@@ -102,6 +151,28 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
assert media_item.media_size_bytes > 0 assert media_item.media_size_bytes > 0
end end
test "saves redownloaded_at if this is for a redownload", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id, redownload?: true})
media_item = Repo.reload(media_item)
assert media_item.media_redownloaded_at != nil
end
test "doesn't save redownloaded_at if this is not for a redownload", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id})
media_item = Repo.reload(media_item)
assert media_item.media_redownloaded_at == nil
end
test "does not blow up if the record doesn't exist" do test "does not blow up if the record doesn't exist" do
assert :ok = perform_job(MediaDownloadWorker, %{id: 0}) assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
end end
@@ -25,9 +25,11 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3" do describe "download_for_media_item/3" do
test "it calls the backend runner", %{media_item: media_item} do test "it calls the backend runner", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn url, _opts, ot -> expect(YtDlpRunnerMock, :run, fn url, _opts, ot, addl ->
assert url == media_item.original_url assert url == media_item.original_url
assert ot == "after_move:%()j" assert ot == "after_move:%()j"
assert [{:output_filepath, filepath}] = addl
assert is_binary(filepath)
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -36,7 +38,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "it saves the metadata filepath to the database", %{media_item: media_item} do test "it saves the metadata filepath to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -47,18 +49,56 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg" assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg"
end end
test "errors are passed through", %{media_item: media_item} do test "non-recoverable errors are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, :some_error} {:error, :some_error, 1}
end) end)
assert {:error, :some_error} = MediaDownloader.download_for_media_item(media_item) assert {:error, :some_error} = MediaDownloader.download_for_media_item(media_item)
end end
test "unknown errors are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, :some_error}
end)
assert {:error, message} = MediaDownloader.download_for_media_item(media_item)
assert message == "Unknown error: {:error, :some_error}"
end
end
describe "download_for_media_item/3 when testing retries" do
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, message, 1}
end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
end
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
[{:output_filepath, filepath}] = addl
File.write(filepath, render_metadata(:media_metadata))
{:error, message, 1}
end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
media_item = Repo.reload(media_item)
assert DateTime.diff(DateTime.utc_now(), media_item.media_downloaded_at) < 2
assert String.ends_with?(media_item.media_filepath, ".mkv")
end
end end
describe "download_for_media_item/3 when testing media_item attributes" do describe "download_for_media_item/3 when testing media_item attributes" do
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -100,7 +140,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "it extracts the thumbnail_filepath", %{media_item: media_item} do test "it extracts the thumbnail_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
thumbnail_filepath = thumbnail_filepath =
@@ -124,7 +164,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "it extracts the metadata_filepath", %{media_item: media_item} do test "it extracts the metadata_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
infojson_filepath = metadata["infojson_filename"] infojson_filepath = metadata["infojson_filename"]
@@ -143,7 +183,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing NFO generation" do describe "download_for_media_item/3 when testing NFO generation" do
setup do setup do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -0,0 +1,44 @@
defmodule Pinchflat.Downloading.MediaRedownloadWorkerTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.Downloading.MediaRedownloadWorker
describe "perform/1" do
test "kicks off a task for redownloadable media items" do
media_profile = media_profile_fixture(%{redownload_delay_days: 4})
source = source_fixture(%{media_profile_id: media_profile.id, inserted_at: now_minus(10, :days)})
media_item =
media_item_fixture(%{
source_id: source.id,
upload_date: now_minus(6, :days),
media_downloaded_at: now_minus(5, :days)
})
perform_job(MediaRedownloadWorker, %{})
assert [_] = all_enqueued(worker: MediaDownloadWorker, args: %{id: media_item.id, redownload?: true})
end
test "does not kickoff a task for non-redownloadable media items" do
media_profile = media_profile_fixture(%{redownload_delay_days: 4})
source = source_fixture(%{media_profile_id: media_profile.id, inserted_at: now_minus(10, :days)})
_media_item =
media_item_fixture(%{
source_id: source.id,
upload_date: now_minus(6, :days),
media_downloaded_at: now_minus(1, :day)
})
perform_job(MediaRedownloadWorker, %{})
assert [] = all_enqueued(worker: MediaDownloadWorker)
end
end
end
@@ -0,0 +1,70 @@
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
@@ -9,39 +9,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.MediaIndexingWorker
alias Pinchflat.FastIndexing.FastIndexingHelpers alias Pinchflat.FastIndexing.FastIndexingHelpers
setup :verify_on_exit! setup :verify_on_exit!
@media_url "https://www.youtube.com/watch?v=test_1" describe "kickoff_download_tasks_from_youtube_rss_feed/1" do
describe "kickoff_indexing_tasks_from_youtube_rss_feed/1" do
setup do
{:ok, [source: source_fixture()]}
end
test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
assert :ok = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
assert [worker] = all_enqueued(worker: MediaIndexingWorker)
assert worker.args["id"] == source.id
assert worker.args["media_url"] == "https://www.youtube.com/watch?v=test_1"
end
test "does not enqueue a new worker for the source's media IDs we already know about", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
media_item_fixture(source_id: source.id, media_id: "test_1")
assert :ok = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
refute_enqueued(worker: MediaIndexingWorker)
end
end
describe "index_and_enqueue_download_for_media_item/2" do
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, media_attributes_return_fixture()} {:ok, media_attributes_return_fixture()}
@@ -50,41 +22,50 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
{:ok, [source: source_fixture()]} {:ok, [source: source_fixture()]}
end end
test "creates a new media item based on the URL", %{source: source} do test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do
assert Repo.aggregate(MediaItem, :count) == 0 expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
assert {:ok, _} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url)
assert Repo.aggregate(MediaItem, :count) == 1 assert [media_item] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
assert [worker] = all_enqueued(worker: MediaDownloadWorker)
assert worker.args["id"] == media_item.id
end end
test "won't duplicate media_items based on media_id and source", %{source: source} do test "does not enqueue a new worker for the source's media IDs we already know about", %{source: source} do
assert {:ok, mi_1} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
assert {:ok, mi_2} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url) media_item_fixture(source_id: source.id, media_id: "test_1")
assert Repo.aggregate(MediaItem, :count) == 1 assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
assert mi_1.id == mi_2.id
end
test "enqueues a download job", %{source: source} do
assert {:ok, media_item} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
end
test "creates a download task record", %{source: source} do
assert {:ok, media_item} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url)
assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker")
end
test "does not enqueue a download job if the source does not allow it" do
source = source_fixture(%{download_media: false})
assert {:ok, _} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url)
refute_enqueued(worker: MediaDownloadWorker) refute_enqueued(worker: MediaDownloadWorker)
end end
test "returns the found media items", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
end
test "does not enqueue a download job if the source does not allow it" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
source = source_fixture(%{download_media: false})
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
refute_enqueued(worker: MediaDownloadWorker)
end
test "creates a download task record", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
assert [media_item] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker")
end
test "does not enqueue a download job if the media item does not match the format rules" do test "does not enqueue a download job if the media item does not match the format rules" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
profile = media_profile_fixture(%{shorts_behaviour: :exclude}) profile = media_profile_fixture(%{shorts_behaviour: :exclude})
source = source_fixture(%{media_profile_id: profile.id}) source = source_fixture(%{media_profile_id: profile.id})
@@ -104,7 +85,8 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
{:ok, output} {:ok, output}
end) end)
assert {:ok, _media_item} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url) assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
refute_enqueued(worker: MediaDownloadWorker) refute_enqueued(worker: MediaDownloadWorker)
end end
end end
@@ -4,6 +4,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
import Mox import Mox
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.FastIndexing.FastIndexingWorker
@@ -74,4 +75,58 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
assert :ok = perform_job(FastIndexingWorker, %{id: 0}) assert :ok = perform_job(FastIndexingWorker, %{id: 0})
end end
end end
describe "perform/1 when testing notifications" do
setup do
Settings.set(apprise_server: "server_1")
:ok
end
test "sends a notification if new media was found" do
source = source_fixture(fast_index: true)
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, render_metadata(:media_metadata)} end)
expect(AppriseRunnerMock, :run, fn servers, opts ->
assert "server_1" = servers
assert is_binary(Keyword.get(opts, :title))
assert is_binary(Keyword.get(opts, :body))
{:ok, ""}
end)
perform_job(FastIndexingWorker, %{id: source.id})
end
test "doesn't send a notification if new media is not found" do
source = source_fixture(fast_index: true)
expect(HTTPClientMock, :get, fn _url -> {:ok, ""} end)
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
perform_job(FastIndexingWorker, %{id: source.id})
end
test "doesn't send a notification if the source doesn't download media" do
source = source_fixture(fast_index: true, download_media: false)
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, render_metadata(:media_metadata)} end)
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
perform_job(FastIndexingWorker, %{id: source.id})
end
test "doesn't send a notification if the media isn't pending download" do
source = source_fixture(fast_index: true, title_filter_regex: "foobar")
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, render_metadata(:media_metadata)} end)
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
perform_job(FastIndexingWorker, %{id: source.id})
end
end
end end

Some files were not shown because too many files have changed in this diff Show More