Compare commits

...

23 Commits

Author SHA1 Message Date
Kieran Eglin 115f675ae3 Bumped version 2024-12-30 17:42:18 -08:00
Kieran f51b219860 [Bugfix] Improve OPML route security (#535)
* WIP - moved plugs; set up a new token-protected route plug

* Added a route_token column to settings model

* Hooked up token_protected_route plug to database

* Hooked up new OPML route to UI; turned RSS and OPML feed buttons into links

* Docs, tests

* Added a note about the phoenix bug
2024-12-30 17:40:23 -08:00
Kieran Eglin 246ca3b299 Bumped version 2024-12-29 10:14:03 -08:00
Robert Kleinschuster c9bd1ea7bd Added OPML Endpoint for podcast rss feeds (#512)
* Added OPML Endpoint for podcast rss feeds

* changed opml route and added controller test for opml endpoint

* add copy opml feed button

* add copy opml feed button - correct url

* fix html indenting

* add indentation to opml

Co-authored-by: Kieran <kieran.eglin@gmail.com>

* use convention for unused controller params

Co-authored-by: Kieran <kieran.eglin@gmail.com>

* add test for opml_sources helper function

* change opml endpoint to be more inline with the other routes

---------

Co-authored-by: robs <git@robs.social>
Co-authored-by: Kieran <kieran.eglin@gmail.com>
2024-12-20 11:47:03 -08:00
Kieran a2a70fcce2 [Bugfix] Respect cookies preference when performing download pre-check (#517)
* Updated get_downloadable_status to pass yt cookies

* Updated tests
2024-12-17 11:18:47 -08:00
Kieran Eglin 0be469dcb0 Added --warnings-as-errors to mix test but only when called by ex_check 2024-12-13 12:41:06 -08:00
Kieran 023f449dbe [Housekeeping] Pass the current action when calling the yt-dlp runner (#514)
* Updated yt-dlp runner to take an action type

* Added actions to all callers of the yt-dlp runner

* [SQUASH] updated test files to use new mocking strategy

* Removed unneeded alias
2024-12-13 12:29:05 -08:00
Robert Kleinschuster e9d365ee9e use css truncate everywhere and remove StringUtils.truncate function (#513)
Co-authored-by: robs <git@robs.social>
2024-12-13 12:23:12 -08:00
Kieran 53e106dac2 [Enhancement] Add sorting, pagination, and new attributes to sources index table (#510)
* WIP - started improving handling of sorting for sources index table

* WIP - Added UI to table to indicate sort column and direction

* Refactored toggle liveview into a livecomponent

* Added sorting for all table attrs

* Added pagination to the sources table

* Added tests for updated liveviews and live components

* Add tests for new helper methods

* Added fancy new CSS to my sources table

* Added size to sources table

* Adds relative div to ensure that sorting arrow doesn't run away

* Fixed da tests
2024-12-13 09:49:00 -08:00
Robert Kleinschuster e56f39a158 Separate tabs for pending and downloaded in media history (#508)
* Separate tabs for pending and downloaded in media history
 - closes #504

* correct query for pending state and remove unecessary assign

* correct pending where

---------

Co-authored-by: robs <git@robs.social>
2024-12-13 08:58:42 -08:00
Robert Kleinschuster 01c9afa1c3 truncate table columns via css (#507)
- closes #506

Co-authored-by: robs <git@robs.social>
2024-12-13 08:57:32 -08:00
Kieran Eglin 5371b5d236 Bumped version 2024-12-10 14:32:20 -08:00
Kieran 83b1fa23d0 [Housekeeping] Update config options to enhance NixOS compatibility (#493)
* Make more paths configurable

* Updated tzdata default path to use the specified 'extras' path
2024-12-10 14:19:34 -08:00
Kieran f8e7b2e42d Added support for 1440p (#498) 2024-12-06 11:13:22 -08:00
Kieran a5b65061f0 [Housekeeping] Bump Phoenix LiveView to 1.0.0 (#495)
* bumped liveview to 1.0.0

* Converted interpolation to new syntax
2024-12-06 10:37:15 -08:00
Kieran 0fb971dd0a Added presets to cutoff date selector (#494) 2024-12-04 12:32:47 -08:00
Kieran Eglin 75fb1a6ab9 Bumped version 2024-11-27 10:43:07 -08:00
Kieran 652fcccb4a [Enhancement] Add audio track language selection to Media Profile (#487)
* Moved quality options to their own module

* Added language and format selection to quality option builder

* [WIP] migrating tests

* Added audio_lang to media_profile table

* Renamed column; added format options and tests

* Adds UI for audio_track to the media profile form

* Adds a version string to in-app streams to help with cache busting
2024-11-27 10:39:29 -08:00
Kieran bfb27427ce [Bugfix] Ensure livestreams aren't downloaded until they're finished processing (#485)
* Added logic to ignore downloads that aren't in the right live state

* Added tests for get_downloadable_status method

* Added tests for media downloader module

* Added tests to download worker modeule
2024-11-26 11:56:33 -08:00
Kieran d9c48370df [Enhancement] Adds ability to enable/disable sources (#481)
* [Unrelated] updated module name for existing liveview module

* Updated toggle component and moved MP index table to a liveview

* [WIP] reverted MP index table; added source count to MP index

* Moved new live table to sources index

* Added 'enabled' boolean to sources

* Got 'enabled' logic working re: downloading pending media

* Updated sources context to do the right thing when a source is updated

* Docs and tests

* Updated slow indexing to maintain its old schedule if re-enabled

* Hooked up the enabled toggle to the sources page

* [Unrelated] added direct links to various tabs on the sources table

* More tests

* Removed unneeded guard in

* Removed outdated comment
2024-11-21 14:38:37 -08:00
Duong Nguyen 4c8c0461be [Enhancement] Add option to use existing Media Profile as template for new profile (#466)
* Add option to use existing Media Profile as template for new profile

* Forgot to commit the edit form too

* Reset deletion mark on Source controller

* Add test for new preload profile feature

* mix check
2024-11-20 10:25:53 -08:00
Kieran a02f71f304 [Enhancement] Add support for yt-dlp plugins + add lifecycle script event for app boot (#465)
* Added new script type to pre-job startup tasks

* Updated Dockerfile to create the needful directories

* added tests
2024-11-08 15:38:00 -08:00
Kieran 83c10b2b00 [Enhancement] Track the predicted final filepath for indexed media items (#461)
* Added ability to pass additional yt-dlp options to indexing step

* Added predicted_filename to media struct

* WIP added ability to predict filepath to source indexing

* renamed predicted_filepath

* Added the ability to predict filepath when fast indexing

* Add predicted_media_filepath to media items table

* Addressed TODOs
2024-11-08 09:42:59 -08:00
118 changed files with 2788 additions and 934 deletions
+11
View File
@@ -35,3 +35,14 @@ window.markVersionAsSeen = (versionString) => {
window.isVersionSeen = (versionString) => { window.isVersionSeen = (versionString) => {
return localStorage.getItem('seenVersion') === versionString return localStorage.getItem('seenVersion') === versionString
} }
window.dispatchFor = (elementOrId, eventName, detail = {}) => {
const element =
typeof elementOrId === 'string' ? document.getElementById(elementOrId) : elementOrId
// This is needed to ensure the DOM has updated before dispatching the event.
// Doing so ensures that the latest DOM state is what's sent to the server
setTimeout(() => {
element.dispatchEvent(new Event(eventName, { bubbles: true, detail }))
}, 0)
}
+1 -1
View File
@@ -39,7 +39,7 @@ let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
} }
}, },
hooks: { hooks: {
supressEnterSubmission: { 'supress-enter-submission': {
mounted() { mounted() {
this.el.addEventListener('keypress', (event) => { this.el.addEventListener('keypress', (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
+9 -4
View File
@@ -55,11 +55,16 @@ config :pinchflat, Oban,
] ]
if config_env() == :prod do if config_env() == :prod do
config_path = "/config" # Various paths. These ones shouldn't be tweaked if running in Docker
media_path = System.get_env("MEDIA_PATH", "/downloads")
config_path = System.get_env("CONFIG_PATH", "/config")
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"])) db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"])) log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"])) metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"])) extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
tmpfile_path = System.get_env("TMPFILE_PATH", Path.join([System.tmp_dir!(), "pinchflat", "data"]))
# This one can be changed if you want
tz_data_path = System.get_env("TZ_DATA_PATH", Path.join([extras_path, "elixir_tz_data"]))
# For running PF as a podcast host on self-hosted environments # For running PF as a podcast host on self-hosted environments
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0 expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
# For testing alternate journal modes (see issue #137) # For testing alternate journal modes (see issue #137)
@@ -73,10 +78,10 @@ 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"), apprise_executable: System.find_executable("apprise"),
media_directory: "/downloads", media_directory: media_path,
metadata_directory: metadata_path, metadata_directory: metadata_path,
extras_directory: extras_path, extras_directory: extras_path,
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]), tmpfile_directory: tmpfile_path,
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"), dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
expose_feed_endpoints: expose_feed_endpoints, expose_feed_endpoints: expose_feed_endpoints,
# This is configured in application.ex # This is configured in application.ex
@@ -84,7 +89,7 @@ if config_env() == :prod do
log_path: log_path, log_path: log_path,
base_route_path: base_route_path base_route_path: base_route_path
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/config/extras/elixir_tz_data") config :tzdata, :data_dir, tz_data_path
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,
database: db_path, database: db_path,
+3 -1
View File
@@ -88,6 +88,7 @@ RUN apt-get update -y && \
ca-certificates \ ca-certificates \
python3-mutagen \ python3-mutagen \
curl \ curl \
zip \
openssh-client \ openssh-client \
nano \ nano \
python3 \ python3 \
@@ -116,7 +117,8 @@ ENV LC_ALL en_US.UTF-8
WORKDIR "/app" WORKDIR "/app"
# Set up data volumes # Set up data volumes
RUN mkdir /config /downloads /etc/elixir_tzdata_data && chmod ugo+rw /etc/elixir_tzdata_data RUN mkdir -p /config /downloads /etc/elixir_tzdata_data /etc/yt-dlp/plugins && \
chmod ugo+rw /etc/elixir_tzdata_data /etc/yt-dlp /etc/yt-dlp/plugins
# set runner ENV # set runner ENV
ENV MIX_ENV="prod" ENV MIX_ENV="prod"
@@ -16,6 +16,8 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
def start_link(opts \\ []) do def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, opts) GenServer.start_link(__MODULE__, %{}, opts)
end end
@@ -36,6 +38,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
create_blank_yt_dlp_files() create_blank_yt_dlp_files()
create_blank_user_script_file() create_blank_user_script_file()
apply_default_settings() apply_default_settings()
run_app_init_script()
{:ok, state} {:ok, state}
end end
@@ -95,6 +98,12 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
Settings.set(apprise_version: apprise_version) Settings.set(apprise_version: apprise_version)
end end
defp run_app_init_script do
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
runner.run(:app_init, %{})
end
defp yt_dlp_runner do defp yt_dlp_runner do
Application.get_env(:pinchflat, :yt_dlp_runner) Application.get_env(:pinchflat, :yt_dlp_runner)
end end
@@ -4,10 +4,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
""" """
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.OutputPathBuilder alias Pinchflat.Downloading.OutputPathBuilder
alias Pinchflat.Downloading.QualityOptionBuilder
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
@@ -34,21 +34,38 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
@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. Uses the source's override output path template if it exists. or media_item's media profile. Uses the source's override output path template if it exists.
Accepts a %MediaItem{} or %Source{} struct. If a %Source{} struct is passed, it Accepts a %MediaItem{} or %Source{} struct. If a %Source{} struct is passed, it
will use a default %MediaItem{} struct with the given source. will use a default %MediaItem{} struct with the given source.
Returns binary() Returns binary()
""" """
def build_output_path_for(%Source{} = source_with_preloads) do
build_output_path_for(%MediaItem{source: source_with_preloads})
end
def build_output_path_for(%MediaItem{} = media_item_with_preloads) do def build_output_path_for(%MediaItem{} = media_item_with_preloads) do
output_path_template = Sources.output_path_template(media_item_with_preloads.source) output_path_template = Sources.output_path_template(media_item_with_preloads.source)
build_output_path(output_path_template, media_item_with_preloads) build_output_path(output_path_template, media_item_with_preloads)
end end
def build_output_path_for(%Source{} = source_with_preloads) do @doc """
build_output_path_for(%MediaItem{source: source_with_preloads}) Builds the quality options for yt-dlp to download media based on the given source's
or media_item's media profile. Useful for helping predict final filepath of downloaded
media.
returns [Keyword.t()]
"""
def build_quality_options_for(%Source{} = source_with_preloads) do
build_quality_options_for(%MediaItem{source: source_with_preloads})
end
def build_quality_options_for(%MediaItem{} = media_item_with_preloads) do
media_profile = media_item_with_preloads.source.media_profile
quality_options(media_profile)
end end
defp default_options(override_opts) do defp default_options(override_opts) do
@@ -125,27 +142,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end end
defp quality_options(media_profile) do defp quality_options(media_profile) do
vcodec = Settings.get!(:video_codec_preference) QualityOptionBuilder.build(media_profile)
acodec = Settings.get!(:audio_codec_preference)
container = media_profile.media_container
case media_profile.preferred_resolution do
# Also be aware that :audio disabled all embedding options for subtitles
:audio ->
[:extract_audio, format_sort: "+acodec:#{acodec}", audio_format: container || "best"]
resolution_atom ->
{resolution_string, _} =
resolution_atom
|> Atom.to_string()
|> Integer.parse()
[
# Since Plex doesn't support reading metadata from MKV
remux_video: container || "mp4",
format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}"
]
end
end end
defp sponsorblock_options(media_profile) do defp sponsorblock_options(media_profile) do
@@ -94,6 +94,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
{:recovered, _} -> {:recovered, _} ->
{:error, :retry} {:error, :retry}
{:error, :unsuitable_for_download} ->
{:ok, :non_retry}
{:error, message} -> {:error, message} ->
action_on_error(message) action_on_error(message)
end end
+14 -2
View File
@@ -37,6 +37,13 @@ defmodule Pinchflat.Downloading.MediaDownloader do
{:ok, parsed_json} -> {:ok, parsed_json} ->
update_media_item_from_parsed_json(media_with_preloads, parsed_json) update_media_item_from_parsed_json(media_with_preloads, parsed_json)
{:error, :unsuitable_for_download} ->
Logger.warning(
"Media item ##{media_with_preloads.id} isn't suitable for download yet. May be an active or processing live stream"
)
{:error, :unsuitable_for_download}
{:error, message, _exit_code} -> {:error, message, _exit_code} ->
Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}") Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
@@ -106,9 +113,14 @@ defmodule Pinchflat.Downloading.MediaDownloader do
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts) {:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
runner_opts = [output_filepath: output_filepath, use_cookies: item_with_preloads.source.use_cookies] use_cookies = item_with_preloads.source.use_cookies
runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies]
YtDlpMedia.download(url, options, runner_opts) case YtDlpMedia.get_downloadable_status(url, use_cookies: use_cookies) do
{:ok, :downloadable} -> YtDlpMedia.download(url, options, runner_opts)
{:ok, :ignorable} -> {:error, :unsuitable_for_download}
err -> err
end
end end
defp recoverable_errors do defp recoverable_errors do
@@ -0,0 +1,66 @@
defmodule Pinchflat.Downloading.QualityOptionBuilder do
@moduledoc """
A standalone builder module for building quality-related options for yt-dlp to download media.
Currently exclusively used in DownloadOptionBuilder since this logic is too complex to just
place in the main module.
"""
alias Pinchflat.Settings
alias Pinchflat.Profiles.MediaProfile
@doc """
Builds the quality-related options for yt-dlp to download media based on the given media profile
Includes things like container, preferred format/codec, and audio track options.
"""
def build(%MediaProfile{preferred_resolution: :audio, media_container: container} = media_profile) do
acodec = Settings.get!(:audio_codec_preference)
[
:extract_audio,
format_sort: "+acodec:#{acodec}",
audio_format: container || "best",
format: build_format_string(media_profile)
]
end
def build(%MediaProfile{preferred_resolution: resolution_atom, media_container: container} = media_profile) do
vcodec = Settings.get!(:video_codec_preference)
acodec = Settings.get!(:audio_codec_preference)
{resolution_string, _} = resolution_atom |> Atom.to_string() |> Integer.parse()
[
# Since Plex doesn't support reading metadata from MKV
remux_video: container || "mp4",
format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}",
format: build_format_string(media_profile)
]
end
defp build_format_string(%MediaProfile{preferred_resolution: :audio, audio_track: audio_track}) do
if audio_track do
"bestaudio[#{build_format_modifier(audio_track)}]/bestaudio/best"
else
"bestaudio/best"
end
end
defp build_format_string(%MediaProfile{audio_track: audio_track}) do
if audio_track do
"bestvideo+bestaudio[#{build_format_modifier(audio_track)}]/bestvideo*+bestaudio/best"
else
"bestvideo*+bestaudio/best"
end
end
# Reminder to self: this conflicts with `--extractor-args "youtube:lang=<LANG>"`
# since that will translate the format_notes as well, which means they may not match.
# At least that's what happens now - worth a re-check if I have to come back to this
defp build_format_modifier("original"), do: "format_note*=original"
defp build_format_modifier("default"), do: "format_note*='(default)'"
# This uses the carat to anchor the language to the beginning of the string
# since that's what's needed to match `en` to `en-US` and `en-GB`, etc. The user
# can always specify the full language code if they want.
defp build_format_modifier(language_code), do: "language^=#{language_code}"
end
@@ -11,13 +11,27 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.YoutubeRss alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.FastIndexing.YoutubeApi alias Pinchflat.FastIndexing.YoutubeApi
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.Downloading.DownloadOptionBuilder
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@doc """
Kicks off a new fast indexing task for a source. This will delete any existing fast indexing
tasks for the source before starting a new one.
Returns {:ok, %Task{}}
"""
def kickoff_indexing_task(%Source{} = source) do
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker", include_executing: true)
FastIndexingWorker.kickoff_with_task(source)
end
@doc """ @doc """
Fetches new media IDs for a source from YT's API or RSS, indexes them, and kicks off downloading Fetches new media IDs for a source from YT's API or RSS, indexes them, and kicks off downloading
tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the
@@ -27,6 +41,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
downloaded_. downloaded_.
""" """
def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do
# The media_profile is needed to determine the quality options to _then_ determine a more
# accurate predicted filepath
source = Repo.preload(source, [:media_profile])
{:ok, media_ids} = get_recent_media_ids(source) {:ok, media_ids} = get_recent_media_ids(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)
@@ -68,7 +86,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
defp create_media_item_from_media_id(source, media_id) do defp create_media_item_from_media_id(source, media_id) do
url = "https://www.youtube.com/watch?v=#{media_id}" url = "https://www.youtube.com/watch?v=#{media_id}"
case YtDlpMedia.get_media_attributes(url, use_cookies: source.use_cookies) do command_opts =
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
DownloadOptionBuilder.build_quality_options_for(source)
case YtDlpMedia.get_media_attributes(url, command_opts, use_cookies: source.use_cookies) do
{:ok, media_attrs} -> {:ok, media_attrs} ->
Media.create_media_item_from_backend_attrs(source, media_attrs) Media.create_media_item_from_backend_attrs(source, media_attrs)
@@ -12,6 +12,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
@behaviour UserScriptCommandRunner @behaviour UserScriptCommandRunner
@event_types [ @event_types [
:app_init,
:media_pre_download, :media_pre_download,
:media_downloaded, :media_downloaded,
:media_deleted :media_deleted
+2
View File
@@ -31,6 +31,7 @@ defmodule Pinchflat.Media.MediaItem do
:uploaded_at, :uploaded_at,
:upload_date_index, :upload_date_index,
:duration_seconds, :duration_seconds,
:predicted_media_filepath,
# these fields are captured only on download # these fields are captured only on download
:media_downloaded_at, :media_downloaded_at,
:media_filepath, :media_filepath,
@@ -76,6 +77,7 @@ defmodule Pinchflat.Media.MediaItem do
field :duration_seconds, :integer field :duration_seconds, :integer
field :playlist_index, :integer, default: 0 field :playlist_index, :integer, default: 0
field :predicted_media_filepath, :string
field :media_filepath, :string field :media_filepath, :string
field :media_size_bytes, :integer field :media_size_bytes, :integer
field :thumbnail_filepath, :string field :thumbnail_filepath, :string
@@ -0,0 +1,40 @@
defmodule Pinchflat.Podcasts.OpmlFeedBuilder do
@moduledoc """
Methods for building an OPML feed for a list of sources.
"""
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
alias PinchflatWeb.Router.Helpers, as: Routes
@doc """
Builds an OPML feed for a given list of sources.
Returns an XML document as a string.
"""
def build(url_base, sources) do
sources_xml =
Enum.map(
sources,
&"""
<outline type="rss" text="#{safe(&1.custom_name)}" xmlUrl="#{safe(source_route(url_base, &1))}" />
"""
)
"""
<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>All Sources</title>
</head>
<body>
#{Enum.join(sources_xml, "\n")}
</body>
</opml>
"""
end
defp source_route(url_base, source) do
Path.join(url_base, "#{Routes.podcast_path(PinchflatWeb.Endpoint, :rss_feed, source.uuid)}.xml")
end
end
+14
View File
@@ -5,11 +5,25 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
""" """
use Pinchflat.Media.MediaQuery use Pinchflat.Media.MediaQuery
use Pinchflat.Sources.SourcesQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
@doc """
Returns a list of sources that are not marked for deletion.
Returns: [%Source{}]
"""
def opml_sources() do
SourcesQuery.new()
|> select([s], %{custom_name: s.custom_name, uuid: s.uuid})
|> where([s], is_nil(s.marked_for_deletion_at))
|> order_by(asc: :custom_name)
|> Repo.all()
end
@doc """ @doc """
Returns a list of media items that have been downloaded to disk Returns a list of media items that have been downloaded to disk
and have been proven to still exist there. and have been proven to still exist there.
+3 -1
View File
@@ -26,6 +26,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
sponsorblock_categories sponsorblock_categories
shorts_behaviour shorts_behaviour
livestream_behaviour livestream_behaviour
audio_track
preferred_resolution preferred_resolution
media_container media_container
redownload_delay_days redownload_delay_days
@@ -65,7 +66,8 @@ 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(4320p 2160p 1080p 720p 480p 360p audio)a, default: :"1080p" field :audio_track, :string
field :preferred_resolution, Ecto.Enum, values: ~w(4320p 2160p 1440p 1080p 720p 480p 360p audio)a, default: :"1080p"
field :media_container, :string, default: nil field :media_container, :string, default: nil
field :marked_for_deletion_at, :utc_datetime field :marked_for_deletion_at, :utc_datetime
+29
View File
@@ -0,0 +1,29 @@
defmodule Pinchflat.Profiles.ProfilesQuery do
@moduledoc """
Query helpers for the Profiles 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.Profiles.MediaProfile
# This allows the module to be aliased and query methods to be used
# all in one go
# usage: use Pinchflat.Profiles.ProfilesQuery
defmacro __using__(_opts) do
quote do
import Ecto.Query, warn: false
alias unquote(__MODULE__)
end
end
def new do
MediaProfile
end
end
+2
View File
@@ -29,6 +29,8 @@ defmodule Pinchflat.Release do
[ [
"/config", "/config",
"/downloads", "/downloads",
"/etc/yt-dlp",
"/etc/yt-dlp/plugins",
Application.get_env(:pinchflat, :media_directory), Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :tmpfile_directory), Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :extras_directory), Application.get_env(:pinchflat, :extras_directory),
+1
View File
@@ -31,6 +31,7 @@ defmodule Pinchflat.Settings.Setting do
field :apprise_version, :string field :apprise_version, :string
field :apprise_server, :string field :apprise_server, :string
field :youtube_api_key, :string field :youtube_api_key, :string
field :route_token, :string
field :video_codec_preference, :string field :video_codec_preference, :string
field :audio_codec_preference, :string field :audio_codec_preference, :string
@@ -16,6 +16,7 @@ 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.DownloadOptionBuilder
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@@ -24,13 +25,28 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
Starts tasks for indexing a source's media regardless of the source's indexing Starts tasks for indexing a source's media regardless of the source's indexing
frequency. It's assumed the caller will check for indexing frequency. frequency. It's assumed the caller will check for indexing frequency.
Returns {:ok, %Task{}}. Returns {:ok, %Task{}}
""" """
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
job_offset_seconds = calculate_job_offset_seconds(source)
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: true) Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: true)
MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts) MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts ++ [schedule_in: job_offset_seconds])
end
@doc """
A helper method to delete all indexing-related tasks for a source.
Optionally, you can include executing tasks in the deletion process.
Returns :ok
"""
def delete_indexing_tasks(%Source{} = source, opts \\ []) do
include_executing = Keyword.get(opts, :include_executing, false)
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker", include_executing: include_executing)
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: include_executing)
end end
@doc """ @doc """
@@ -56,6 +72,9 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
Returns [%MediaItem{} | %Ecto.Changeset{}] Returns [%MediaItem{} | %Ecto.Changeset{}]
""" """
def index_and_enqueue_download_for_media_items(%Source{} = source) do def index_and_enqueue_download_for_media_items(%Source{} = source) do
# The media_profile is needed to determine the quality options to _then_ determine a more
# accurate predicted filepath
source = Repo.preload(source, [:media_profile])
# 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} = setup_file_watcher_and_kickoff_indexing(source) {:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
@@ -94,8 +113,13 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers 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
command_opts =
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
DownloadOptionBuilder.build_quality_options_for(source)
runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies] runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies]
result = MediaCollection.get_media_attributes_for_collection(source.original_url, runner_opts) result = MediaCollection.get_media_attributes_for_collection(source.original_url, command_opts, runner_opts)
FileFollowerServer.stop(pid) FileFollowerServer.stop(pid)
@@ -132,4 +156,14 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
changeset changeset
end end
end end
# Find the difference between the current time and the last time the source was indexed
defp calculate_job_offset_seconds(%Source{last_indexed_at: nil}), do: 0
defp calculate_job_offset_seconds(source) do
offset_seconds = DateTime.diff(DateTime.utc_now(), source.last_indexed_at, :second)
index_frequency_seconds = source.index_frequency_minutes * 60
max(0, index_frequency_seconds - offset_seconds)
end
end end
+2
View File
@@ -15,6 +15,7 @@ defmodule Pinchflat.Sources.Source do
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
@allowed_fields ~w( @allowed_fields ~w(
enabled
collection_name collection_name
collection_id collection_id
collection_type collection_type
@@ -64,6 +65,7 @@ defmodule Pinchflat.Sources.Source do
)a )a
schema "sources" do schema "sources" do
field :enabled, :boolean, default: true
# 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
# relations. This is only used to prevent an enumeration attack on the streaming # relations. This is only used to prevent an enumeration attack on the streaming
# and RSS feed endpoints since those _must_ be public (ie: no basic auth) # and RSS feed endpoints since those _must_ be public (ie: no basic auth)
+59 -17
View File
@@ -15,8 +15,8 @@ defmodule Pinchflat.Sources do
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.FastIndexing.FastIndexingHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker alias Pinchflat.Metadata.SourceMetadataStorageWorker
@doc """ @doc """
@@ -255,19 +255,40 @@ defmodule Pinchflat.Sources do
end end
end end
# If the source is NOT new (ie: updated) and the download_media flag has changed, # If the source is new (ie: not persisted), do nothing
defp maybe_handle_media_tasks(%{data: %{__meta__: %{state: state}}}, _source) when state != :loaded do
:ok
end
# If the source is NOT new (ie: updated),
# enqueue or dequeue media download tasks as necessary. # enqueue or dequeue media download tasks as necessary.
defp maybe_handle_media_tasks(changeset, source) do defp maybe_handle_media_tasks(changeset, source) do
case {changeset.data, changeset.changes} do current_changes = changeset.changes
{%{__meta__: %{state: :loaded}}, %{download_media: true}} -> applied_changes = Ecto.Changeset.apply_changes(changeset)
# We need both current_changes and applied_changes to determine
# the course of action to take. For example, we only care if a source is supposed
# to be `enabled` or not - we don't care if that information comes from the
# current changes or if that's how it already was in the database.
# Rephrased, we're essentially using it in place of `get_field/2`
case {current_changes, applied_changes} do
{%{download_media: true}, %{enabled: true}} ->
DownloadingHelpers.enqueue_pending_download_tasks(source) DownloadingHelpers.enqueue_pending_download_tasks(source)
{%{__meta__: %{state: :loaded}}, %{download_media: false}} -> {%{enabled: true}, %{download_media: true}} ->
DownloadingHelpers.enqueue_pending_download_tasks(source)
{%{download_media: false}, _} ->
DownloadingHelpers.dequeue_pending_download_tasks(source)
{%{enabled: false}, _} ->
DownloadingHelpers.dequeue_pending_download_tasks(source) DownloadingHelpers.dequeue_pending_download_tasks(source)
_ -> _ ->
:ok nil
end end
:ok
end end
defp maybe_run_indexing_task(changeset, source) do defp maybe_run_indexing_task(changeset, source) do
@@ -301,13 +322,22 @@ defmodule Pinchflat.Sources do
end end
defp maybe_update_slow_indexing_task(changeset, source) do defp maybe_update_slow_indexing_task(changeset, source) do
case changeset.changes do # See comment in `maybe_handle_media_tasks` as to why we need these
%{index_frequency_minutes: mins} when mins > 0 -> current_changes = changeset.changes
applied_changes = Ecto.Changeset.apply_changes(changeset)
case {current_changes, applied_changes} do
{%{index_frequency_minutes: mins}, %{enabled: true}} when mins > 0 ->
SlowIndexingHelpers.kickoff_indexing_task(source) SlowIndexingHelpers.kickoff_indexing_task(source)
%{index_frequency_minutes: _} -> {%{enabled: true}, %{index_frequency_minutes: mins}} when mins > 0 ->
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") SlowIndexingHelpers.kickoff_indexing_task(source)
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
{%{index_frequency_minutes: _}, _} ->
SlowIndexingHelpers.delete_indexing_tasks(source, include_executing: true)
{%{enabled: false}, _} ->
SlowIndexingHelpers.delete_indexing_tasks(source, include_executing: true)
_ -> _ ->
:ok :ok
@@ -315,13 +345,25 @@ defmodule Pinchflat.Sources do
end end
defp maybe_update_fast_indexing_task(changeset, source) do defp maybe_update_fast_indexing_task(changeset, source) do
case changeset.changes do # See comment in `maybe_handle_media_tasks` as to why we need these
%{fast_index: true} -> current_changes = changeset.changes
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") applied_changes = Ecto.Changeset.apply_changes(changeset)
FastIndexingWorker.kickoff_with_task(source)
%{fast_index: false} -> # This technically could be simplified since `maybe_update_slow_indexing_task`
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") # has some overlap re: deleting pending tasks, but I'm keeping it separate
# for clarity and explicitness.
case {current_changes, applied_changes} do
{%{fast_index: true}, %{enabled: true}} ->
FastIndexingHelpers.kickoff_indexing_task(source)
{%{enabled: true}, %{fast_index: true}} ->
FastIndexingHelpers.kickoff_indexing_task(source)
{%{fast_index: false}, _} ->
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker", include_executing: true)
{%{enabled: false}, _} ->
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker", include_executing: true)
_ -> _ ->
:ok :ok
+5 -11
View File
@@ -26,19 +26,13 @@ defmodule Pinchflat.Utils.StringUtils do
end end
@doc """ @doc """
Truncates a string to the given length and adds `...` if the string is longer than the given length. Wraps a string in double braces. Useful as a UI helper now that
Will break on a word boundary. Nothing happens if the string is shorter than the given length. LiveView 1.0.0 allows `{}` for interpolation so now we can't use braces
directly in the view.
Returns binary() Returns binary()
""" """
def truncate(string, length) do def double_brace(string) do
if String.length(string) > length do "{{ #{string} }}"
string
|> String.slice(0..(length - 1))
|> String.replace(~r/\s+\S*$/, "")
|> Kernel.<>("...")
else
string
end
end end
end end
+4 -1
View File
@@ -3,6 +3,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
Runs yt-dlp commands using the `System.cmd/3` function Runs yt-dlp commands using the `System.cmd/3` function
""" """
require Logger
alias Pinchflat.Utils.CliUtils alias Pinchflat.Utils.CliUtils
alias Pinchflat.YtDlp.YtDlpCommandRunner alias Pinchflat.YtDlp.YtDlpCommandRunner
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
@@ -24,7 +26,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
Returns {:ok, binary()} | {:error, output, status}. Returns {:ok, binary()} | {:error, output, status}.
""" """
@impl YtDlpCommandRunner @impl YtDlpCommandRunner
def run(url, command_opts, output_template, addl_opts \\ []) do def run(url, action_name, command_opts, output_template, addl_opts \\ []) do
Logger.debug("Running yt-dlp command for action: #{action_name}")
# This approach lets us mock the command for testing # This approach lets us mock the command for testing
command = backend_executable() command = backend_executable()
+45 -10
View File
@@ -11,7 +11,8 @@ defmodule Pinchflat.YtDlp.Media do
:livestream, :livestream,
:short_form_content, :short_form_content,
:uploaded_at, :uploaded_at,
:duration_seconds :duration_seconds,
:predicted_media_filepath
] ]
defstruct [ defstruct [
@@ -23,7 +24,8 @@ defmodule Pinchflat.YtDlp.Media do
:short_form_content, :short_form_content,
:uploaded_at, :uploaded_at,
:duration_seconds, :duration_seconds,
:playlist_index :playlist_index,
:predicted_media_filepath
] ]
alias __MODULE__ alias __MODULE__
@@ -39,7 +41,7 @@ defmodule Pinchflat.YtDlp.Media do
def download(url, command_opts \\ [], addl_opts \\ []) do def download(url, command_opts \\ [], addl_opts \\ []) do
all_command_opts = [:no_simulate] ++ command_opts all_command_opts = [:no_simulate] ++ command_opts
with {:ok, output} <- backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts), with {:ok, output} <- backend_runner().run(url, :download, all_command_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
@@ -47,6 +49,27 @@ defmodule Pinchflat.YtDlp.Media do
end end
end end
@doc """
Determines if the media at the given URL is ready to be downloaded.
Common examples of non-downloadable media are upcoming or in-progress live streams.
Returns {:ok, :downloadable | :ignorable} | {:error, any}
"""
def get_downloadable_status(url, addl_opts \\ []) do
action = :get_downloadable_status
command_opts = [:simulate, :skip_download]
case backend_runner().run(url, action, command_opts, "%(.{live_status})j", addl_opts) do
{:ok, output} ->
output
|> Phoenix.json_library().decode!()
|> parse_downloadable_status()
err ->
err
end
end
@doc """ @doc """
Downloads a thumbnail for a single piece of media. Usually used for Downloads a thumbnail for a single piece of media. Usually used for
downloading thumbnails for internal use downloading thumbnails for internal use
@@ -58,20 +81,21 @@ defmodule Pinchflat.YtDlp.Media do
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since # NOTE: it doesn't seem like this command actually returns anything in `after_move` since
# we aren't downloading the main media file # we aren't downloading the main media file
backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts) backend_runner().run(url, :download_thumbnail, all_command_opts, "after_move:%()j", addl_opts)
end end
@doc """ @doc """
Returns a map representing the media at the given URL. Returns a map representing the media at the given URL.
Optionally takes a list of additional command options to pass to yt-dlp
or configuration-related options to pass to the runner.
Returns {:ok, %Media{}} | {:error, any, ...}. Returns {:ok, %Media{}} | {:error, any, ...}.
""" """
def get_media_attributes(url, addl_opts \\ []) do def get_media_attributes(url, command_opts \\ [], addl_opts \\ []) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner) all_command_opts = [:simulate, :skip_download] ++ command_opts
command_opts = [:simulate, :skip_download]
output_template = indexing_output_template() output_template = indexing_output_template()
case runner.run(url, command_opts, output_template, addl_opts) do case backend_runner().run(url, :get_media_attributes, all_command_opts, output_template, addl_opts) do
{:ok, output} -> {:ok, output} ->
output output
|> Phoenix.json_library().decode!() |> Phoenix.json_library().decode!()
@@ -91,7 +115,7 @@ defmodule Pinchflat.YtDlp.Media do
if something is a short via the URL again if something is a short via the URL again
""" """
def indexing_output_template do def indexing_output_template do
"%(.{id,title,live_status,original_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j" "%(.{id,title,live_status,original_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index,filename})j"
end end
@doc """ @doc """
@@ -110,7 +134,8 @@ defmodule Pinchflat.YtDlp.Media do
duration_seconds: response["duration"] && round(response["duration"]), duration_seconds: response["duration"] && round(response["duration"]),
short_form_content: response["original_url"] && short_form_content?(response), short_form_content: response["original_url"] && short_form_content?(response),
uploaded_at: response["upload_date"] && parse_uploaded_at(response), uploaded_at: response["upload_date"] && parse_uploaded_at(response),
playlist_index: response["playlist_index"] || 0 playlist_index: response["playlist_index"] || 0,
predicted_media_filepath: response["filename"]
} }
end end
@@ -142,6 +167,16 @@ defmodule Pinchflat.YtDlp.Media do
defp parse_uploaded_at(%{"upload_date" => nil}), do: nil defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"]) defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"])
defp parse_downloadable_status(response) do
case response["live_status"] do
status when status in ["is_live", "is_upcoming", "post_live"] -> {:ok, :ignorable}
status when status in ["was_live", "not_live"] -> {:ok, :downloadable}
# This preserves my tenuous support for non-youtube sources.
nil -> {:ok, :downloadable}
_ -> {:error, "Unknown live status: #{response["live_status"]}"}
end
end
defp backend_runner do defp backend_runner do
# This approach lets us mock the command for testing # This approach lets us mock the command for testing
Application.get_env(:pinchflat, :yt_dlp_runner) Application.get_env(:pinchflat, :yt_dlp_runner)
+12 -6
View File
@@ -11,31 +11,35 @@ defmodule Pinchflat.YtDlp.MediaCollection do
@doc """ @doc """
Returns a list of maps representing the media in the collection. Returns a list of maps representing the media in the collection.
Optionally takes a list of additional command options to pass to yt-dlp
or configuration-related options to pass to the runner.
Options: Runner Options:
- :file_listener_handler - a function that will be called with the path to the - :file_listener_handler - a function that will be called with the path to the
file that will be written to when yt-dlp is done. This is useful for file that will be written to when yt-dlp is done. This is useful for
setting up a file watcher to know when the file is ready to be read. setting up a file watcher to know when the file is ready to be read.
- :use_cookies - whether or not to use user-provided cookies when fetching the media details
Returns {:ok, [map()]} | {:error, any, ...}. Returns {:ok, [map()]} | {:error, any, ...}.
""" """
def get_media_attributes_for_collection(url, addl_opts \\ []) do def get_media_attributes_for_collection(url, command_opts \\ [], addl_opts \\ []) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner) runner = Application.get_env(:pinchflat, :yt_dlp_runner)
# `ignore_no_formats_error` is necessary because yt-dlp will error out if # `ignore_no_formats_error` is necessary because yt-dlp will error out if
# the first video has not released yet (ie: is a premier). We don't care about # the first video has not released yet (ie: is a premier). We don't care about
# 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, :no_warnings] all_command_opts = [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings] ++ command_opts
use_cookies = Keyword.get(addl_opts, :use_cookies, false) use_cookies = Keyword.get(addl_opts, :use_cookies, false)
output_template = YtDlpMedia.indexing_output_template() output_template = YtDlpMedia.indexing_output_template()
output_filepath = FilesystemUtils.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)
runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies] runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies]
action = :get_media_attributes_for_collection
if file_listener_handler do if file_listener_handler do
file_listener_handler.(output_filepath) file_listener_handler.(output_filepath)
end end
case runner.run(url, command_opts, output_template, runner_opts) do case runner.run(url, action, all_command_opts, output_template, runner_opts) do
{:ok, output} -> {:ok, output} ->
parsed_lines = parsed_lines =
output output
@@ -79,8 +83,9 @@ defmodule Pinchflat.YtDlp.MediaCollection do
all_command_opts = default_opts ++ command_opts all_command_opts = default_opts ++ command_opts
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j" output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
action = :get_source_details
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts), with {:ok, output} <- backend_runner().run(source_url, action, all_command_opts, output_template, addl_opts),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, format_source_details(parsed_json)} {:ok, format_source_details(parsed_json)}
else else
@@ -118,8 +123,9 @@ defmodule Pinchflat.YtDlp.MediaCollection do
all_command_opts = [:skip_download] ++ command_opts all_command_opts = [:skip_download] ++ command_opts
output_template = "playlist:%()j" output_template = "playlist:%()j"
action = :get_source_metadata
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts), with {:ok, output} <- backend_runner().run(source_url, action, all_command_opts, output_template, 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
@@ -6,7 +6,7 @@ defmodule Pinchflat.YtDlp.YtDlpCommandRunner do
yt-dlp command. yt-dlp command.
""" """
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()} @callback run(binary(), atom(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
@callback run(binary(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()} @callback run(binary(), atom(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
@callback version() :: {:ok, binary()} | {:error, binary()} @callback version() :: {:ok, binary()} | {:error, binary()}
end end
+1
View File
@@ -100,6 +100,7 @@ defmodule PinchflatWeb do
import PinchflatWeb.CustomComponents.TextComponents import PinchflatWeb.CustomComponents.TextComponents
import PinchflatWeb.CustomComponents.TableComponents import PinchflatWeb.CustomComponents.TableComponents
import PinchflatWeb.CustomComponents.ButtonComponents import PinchflatWeb.CustomComponents.ButtonComponents
import Pinchflat.Utils.StringUtils, only: [double_brace: 1]
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Utils.StringUtils alias Pinchflat.Utils.StringUtils
+51 -50
View File
@@ -82,7 +82,7 @@ defmodule PinchflatWeb.CoreComponents do
</button> </button>
</div> </div>
<div id={"#{@id}-content"}> <div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</div> </div>
</div> </div>
</div> </div>
@@ -126,9 +126,9 @@ defmodule PinchflatWeb.CoreComponents do
]}> ]}>
<main> <main>
<h5 :if={@title} class="mb-2 text-lg font-bold"> <h5 :if={@title} class="mb-2 text-lg font-bold">
<%= @title %> {@title}
</h5> </h5>
<p class="mt-2 text-md leading-5 opacity-80"><%= msg %></p> <p class="mt-2 text-md leading-5 opacity-80">{msg}</p>
</main> </main>
<button <button
type="button" type="button"
@@ -208,9 +208,9 @@ defmodule PinchflatWeb.CoreComponents do
def simple_form(assigns) do def simple_form(assigns) do
~H""" ~H"""
<.form :let={f} for={@for} as={@as} {@rest}> <.form :let={f} for={@for} as={@as} {@rest}>
<%= render_slot(@inner_block, f) %> {render_slot(@inner_block, f)}
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6"> <div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
<%= render_slot(action, f) %> {render_slot(action, f)}
</div> </div>
</.form> </.form>
""" """
@@ -297,11 +297,11 @@ defmodule PinchflatWeb.CoreComponents do
class={["rounded focus:ring-0", @inputclass]} class={["rounded focus:ring-0", @inputclass]}
{@rest} {@rest}
/> />
<%= @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}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @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>
""" """
end end
@@ -310,7 +310,7 @@ defmodule PinchflatWeb.CoreComponents do
~H""" ~H"""
<div phx-feedback-for={@name}> <div phx-feedback-for={@name}>
<.label for={@id}> <.label for={@id}>
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span> {@label}<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
</.label> </.label>
<section class="grid grid-cols-1 gap-2 md:grid-cols-2 max-w-prose mb-4 ml-1"> <section class="grid grid-cols-1 gap-2 md:grid-cols-2 max-w-prose mb-4 ml-1">
<div :for={{option_name, option_value} <- @options} class="flex items-center"> <div :for={{option_name, option_value} <- @options} class="flex items-center">
@@ -323,12 +323,12 @@ defmodule PinchflatWeb.CoreComponents do
class={["rounded focus:ring-offset-0 ring-offset-0 focus:ring-0 h-5 w-5 ", @inputclass]} class={["rounded focus:ring-offset-0 ring-offset-0 focus:ring-0 h-5 w-5 ", @inputclass]}
/> />
<label for={"#{@id}-#{option_value}"} class="ml-2 cursor-pointer select-none"> <label for={"#{@id}-#{option_value}"} class="ml-2 cursor-pointer select-none">
<%= option_name %> {option_name}
</label> </label>
</div> </div>
</section> </section>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @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>
""" """
end end
@@ -340,14 +340,15 @@ defmodule PinchflatWeb.CoreComponents do
end) end)
~H""" ~H"""
<div x-data={"{ enabled: #{@checked}}"}> <div x-data={"{ enabled: #{@checked} }"} class="" phx-update="ignore" id={"#{@id}-wrapper"}>
<.label for={@id}> <.label :if={@label} for={@id}>
<%= @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>
<div class="relative"> <div class="relative flex flex-col">
<input type="hidden" id={@id} name={@name} x-bind:value="enabled" {@rest} /> <input type="hidden" id={@id} name={@name} x-bind:value="enabled" {@rest} />
<div class="inline-block cursor-pointer" @click="enabled = !enabled"> <%!-- This triggers a `change` event on the hidden input when the toggle is clicked --%>
<div class="inline-block cursor-pointer" @click={"enabled = !enabled; dispatchFor('#{@id}', 'change')"}>
<div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div> <div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div>
<div <div
x-bind:class="enabled && '!right-1 !translate-x-full'" x-bind:class="enabled && '!right-1 !translate-x-full'"
@@ -358,8 +359,8 @@ defmodule PinchflatWeb.CoreComponents do
> >
</div> </div>
</div> </div>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @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>
""" """
@@ -369,7 +370,7 @@ defmodule PinchflatWeb.CoreComponents do
~H""" ~H"""
<div phx-feedback-for={@name}> <div phx-feedback-for={@name}>
<.label :if={@label} for={@id}> <.label :if={@label} for={@id}>
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span> {@label}<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
</.label> </.label>
<div class="flex"> <div class="flex">
<select <select
@@ -384,13 +385,13 @@ defmodule PinchflatWeb.CoreComponents do
multiple={@multiple} multiple={@multiple}
{@rest} {@rest}
> >
<option :if={@prompt} value=""><%= @prompt %></option> <option :if={@prompt} value="">{@prompt}</option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %> {Phoenix.HTML.Form.options_for_select(@options, @value)}
</select> </select>
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</div> </div>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @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>
""" """
end end
@@ -399,7 +400,7 @@ defmodule PinchflatWeb.CoreComponents do
~H""" ~H"""
<div phx-feedback-for={@name}> <div phx-feedback-for={@name}>
<.label for={@id}> <.label for={@id}>
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span> {@label}<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
</.label> </.label>
<textarea <textarea
id={@id} id={@id}
@@ -413,8 +414,8 @@ 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}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @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>
""" """
end end
@@ -424,7 +425,7 @@ defmodule PinchflatWeb.CoreComponents do
~H""" ~H"""
<div phx-feedback-for={@name}> <div phx-feedback-for={@name}>
<.label for={@id}> <.label for={@id}>
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span> {@label}<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
</.label> </.label>
<div class="flex items-center"> <div class="flex items-center">
<input <input
@@ -441,10 +442,10 @@ defmodule PinchflatWeb.CoreComponents do
]} ]}
{@rest} {@rest}
/> />
<%= render_slot(@input_append) %> {render_slot(@input_append)}
</div> </div>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @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>
""" """
end end
@@ -457,7 +458,7 @@ defmodule PinchflatWeb.CoreComponents do
def help(assigns) do def help(assigns) do
~H""" ~H"""
<p class="mt-1 text-sm leading-5"> <p class="mt-1 text-sm leading-5">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</p> </p>
""" """
end end
@@ -471,7 +472,7 @@ defmodule PinchflatWeb.CoreComponents do
def label(assigns) do def label(assigns) do
~H""" ~H"""
<label for={@for} class="mt-5 mb-2 inline-block text-md font-medium text-black dark:text-white"> <label for={@for} class="mt-5 mb-2 inline-block text-md font-medium text-black dark:text-white">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</label> </label>
""" """
end end
@@ -485,7 +486,7 @@ defmodule PinchflatWeb.CoreComponents do
~H""" ~H"""
<p class="mt-1 mb-5 flex gap-3 text-md leading-6 text-rose-600 phx-no-feedback:hidden"> <p class="mt-1 mb-5 flex gap-3 text-md leading-6 text-rose-600 phx-no-feedback:hidden">
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</p> </p>
""" """
end end
@@ -504,13 +505,13 @@ defmodule PinchflatWeb.CoreComponents do
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}> <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
<div> <div>
<h1 class="text-lg font-semibold leading-8 text-zinc-800"> <h1 class="text-lg font-semibold leading-8 text-zinc-800">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</h1> </h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600"> <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
<%= render_slot(@subtitle) %> {render_slot(@subtitle)}
</p> </p>
</div> </div>
<div class="flex-none"><%= render_slot(@actions) %></div> <div class="flex-none">{render_slot(@actions)}</div>
</header> </header>
""" """
end end
@@ -550,9 +551,9 @@ defmodule PinchflatWeb.CoreComponents do
<table class="w-[40rem] mt-11 sm:w-full"> <table class="w-[40rem] mt-11 sm:w-full">
<thead class="text-sm text-left leading-6 text-zinc-500"> <thead class="text-sm text-left leading-6 text-zinc-500">
<tr> <tr>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th> <th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
<th :if={@action != []} class="relative p-0 pb-4"> <th :if={@action != []} class="relative p-0 pb-4">
<span class="sr-only"><%= gettext("Actions") %></span> <span class="sr-only">{gettext("Actions")}</span>
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -570,7 +571,7 @@ defmodule PinchflatWeb.CoreComponents do
<div class="block py-4 pr-6"> <div class="block py-4 pr-6">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" /> <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}> <span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
<%= render_slot(col, @row_item.(row)) %> {render_slot(col, @row_item.(row))}
</span> </span>
</div> </div>
</td> </td>
@@ -578,7 +579,7 @@ defmodule PinchflatWeb.CoreComponents do
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium"> <div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" /> <span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
<span :for={action <- @action} class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"> <span :for={action <- @action} class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
<%= render_slot(action, @row_item.(row)) %> {render_slot(action, @row_item.(row))}
</span> </span>
</div> </div>
</td> </td>
@@ -607,8 +608,8 @@ defmodule PinchflatWeb.CoreComponents do
<div class="mt-2 mb-14"> <div class="mt-2 mb-14">
<dl class="-my-4 divide-y dark:divide-strokedark"> <dl class="-my-4 divide-y dark:divide-strokedark">
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8"> <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none dark:text-white"><%= item.title %></dt> <dt class="w-1/4 flex-none dark:text-white">{item.title}</dt>
<dd class="dark:text-white"><%= render_slot(item) %></dd> <dd class="dark:text-white">{render_slot(item)}</dd>
</div> </div>
</dl> </dl>
</div> </div>
@@ -643,12 +644,12 @@ defmodule PinchflatWeb.CoreComponents do
~H""" ~H"""
<ul> <ul>
<li :for={{k, v} <- @iterable_attributes} class="mb-2 w-2/3"> <li :for={{k, v} <- @iterable_attributes} class="mb-2 w-2/3">
<strong><%= k %>:</strong> <strong>{k}:</strong>
<code class="inline-block text-sm font-mono text-gray p-0.5 mx-0.5"> <code class="inline-block text-sm font-mono text-gray p-0.5 mx-0.5">
<%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %> <%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %>
<TextComponents.inline_link href={v}><%= v %></TextComponents.inline_link> <TextComponents.inline_link href={v}>{v}</TextComponents.inline_link>
<% else %> <% else %>
<%= v %> {v}
<% end %> <% end %>
</code> </code>
</li> </li>
@@ -671,7 +672,7 @@ defmodule PinchflatWeb.CoreComponents do
<div class="mt-16"> <div class="mt-16">
<.link href={@href} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"> <.link href={@href} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
<.icon name="hero-arrow-left-solid" class="h-3 w-3" /> <.icon name="hero-arrow-left-solid" class="h-3 w-3" />
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</.link> </.link>
</div> </div>
""" """
@@ -26,7 +26,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
~H""" ~H"""
<button <button
class={[ class={[
"text-center font-medium text-white", "text-center font-medium text-white whitespace-nowrap",
"#{@rounding} inline-flex items-center justify-center px-8 py-4", "#{@rounding} inline-flex items-center justify-center px-8 py-4",
"#{@color}", "#{@color}",
"hover:bg-opacity-90 lg:px-8 xl:px-10", "hover:bg-opacity-90 lg:px-8 xl:px-10",
@@ -37,7 +37,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
disabled={@disabled} disabled={@disabled}
{@rest} {@rest}
> >
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</button> </button>
""" """
end end
@@ -66,7 +66,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
"font-medium text-white hover:bg-opacity-95" "font-medium text-white hover:bg-opacity-95"
]} ]}
> >
<%= @text %> {@text}
<CoreComponents.icon <CoreComponents.icon
name="hero-chevron-down" name="hero-chevron-down"
class="fill-current duration-200 ease-linear mt-1" class="fill-current duration-200 ease-linear mt-1"
@@ -81,7 +81,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
<ul class="flex flex-col"> <ul class="flex flex-col">
<li :for={option <- @option}> <li :for={option <- @option}>
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer"> <span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
<%= render_slot(option) %> {render_slot(option)}
</span> </span>
</li> </li>
</ul> </ul>
@@ -126,7 +126,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
> >
<span class="border-light absolute -top-1 left-1/2 -z-10 h-2 w-2 -translate-x-1/2 rotate-45 rounded-sm bg-meta-4"> <span class="border-light absolute -top-1 left-1/2 -z-10 h-2 w-2 -translate-x-1/2 rotate-45 rounded-sm bg-meta-4">
</span> </span>
<span><%= @tooltip %></span> <span>{@tooltip}</span>
</div> </div>
</div> </div>
""" """
@@ -34,16 +34,16 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
x-bind:class={"openTab === '#{tab.id}' ? activeClasses : inactiveClasses"} x-bind:class={"openTab === '#{tab.id}' ? 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"
> >
<span class="text-xl"><%= tab.title %></span> <span class="text-xl">{tab.title}</span>
</a> </a>
</div> </div>
<div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center"> <div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center">
<%= render_slot(@tab_append) %> {render_slot(@tab_append)}
</div> </div>
</header> </header>
<div class="mt-4 min-h-60"> <div class="mt-4 min-h-60">
<div :for={tab <- @tab} x-show={"openTab === '#{tab.id}'"} class="font-medium leading-relaxed"> <div :for={tab <- @tab} x-show={"openTab === '#{tab.id}'"} class="font-medium leading-relaxed">
<%= render_slot(tab) %> {render_slot(tab)}
</div> </div>
</div> </div>
</div> </div>
@@ -16,6 +16,8 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
""" """
attr :rows, :list, required: true attr :rows, :list, required: true
attr :table_class, :string, default: "" attr :table_class, :string, default: ""
attr :sort_key, :string, default: nil
attr :sort_direction, :string, default: nil
attr :row_item, :any, attr :row_item, :any,
default: &Function.identity/1, default: &Function.identity/1,
@@ -24,6 +26,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
slot :col, required: true do slot :col, required: true do
attr :label, :string attr :label, :string
attr :class, :string attr :class, :string
attr :sort_key, :string
end end
def table(assigns) do def table(assigns) do
@@ -31,8 +34,20 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<table class={["w-full table-auto bg-boxdark", @table_class]}> <table class={["w-full table-auto bg-boxdark", @table_class]}>
<thead> <thead>
<tr class="text-left bg-meta-4"> <tr class="text-left bg-meta-4">
<th :for={col <- @col} class="px-4 py-4 font-medium text-white xl:pl-11"> <th
<%= col[:label] %> :for={col <- @col}
class={["px-4 py-4 font-medium text-white", col[:sort_key] && "cursor-pointer"]}
phx-click={col[:sort_key] && "sort_update"}
phx-value-sort_key={col[:sort_key]}
>
<div class="relative">
{col[:label]}
<.icon
:if={to_string(@sort_key) == col[:sort_key]}
name={if @sort_direction == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
class="w-3 h-3 mt-2 ml-1 absolute"
/>
</div>
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -41,11 +56,11 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<td <td
:for={col <- @col} :for={col <- @col}
class={[ class={[
"px-4 py-5 pl-9 xl:pl-11", "px-4 py-5",
col[:class] col[:class]
]} ]}
> >
<%= render_slot(col, @row_item.(row)) %> {render_slot(col, @row_item.(row))}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -70,9 +85,9 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<li> <li>
<span <span
class={[ class={[
"flex h-8 w-8 items-center justify-center rounded", "pagination-prev h-8 w-8 items-center justify-center rounded",
@page_number != 1 && "cursor-pointer hover:bg-primary hover:text-white", @page_number != 1 && "cursor-pointer hover:bg-primary hover:text-white",
@page_number == 1 && "cursor-not-allowed" @page_number <= 1 && "cursor-not-allowed"
]} ]}
phx-click={@page_number != 1 && "page_change"} phx-click={@page_number != 1 && "page_change"}
phx-value-direction="dec" phx-value-direction="dec"
@@ -88,9 +103,9 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<li> <li>
<span <span
class={[ class={[
"flex h-8 w-8 items-center justify-center rounded", "pagination-next flex h-8 w-8 items-center justify-center rounded",
@page_number != @total_pages && "cursor-pointer hover:bg-primary hover:text-white", @page_number != @total_pages && "cursor-pointer hover:bg-primary hover:text-white",
@page_number == @total_pages && "cursor-not-allowed" @page_number >= @total_pages && "cursor-not-allowed"
]} ]}
phx-click={@page_number != @total_pages && "page_change"} phx-click={@page_number != @total_pages && "page_change"}
phx-value-direction="inc" phx-value-direction="inc"
@@ -2,6 +2,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.Component
alias Pinchflat.Utils.NumberUtils
alias PinchflatWeb.CoreComponents alias PinchflatWeb.CoreComponents
@doc """ @doc """
@@ -12,7 +13,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def inline_code(assigns) do def inline_code(assigns) do
~H""" ~H"""
<code class="inline-block text-sm font-mono text-gray bg-boxdark rounded-md p-0.5 mx-0.5 text-nowrap"> <code class="inline-block text-sm font-mono text-gray bg-boxdark rounded-md p-0.5 mx-0.5 text-nowrap">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</code> </code>
""" """
end end
@@ -26,7 +27,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def inline_link(assigns) do def inline_link(assigns) do
~H""" ~H"""
<.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300"> <.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</.link> </.link>
""" """
end end
@@ -41,7 +42,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def subtle_link(assigns) do def subtle_link(assigns) do
~H""" ~H"""
<.link href={@href} target={@target} class="underline decoration-bodydark decoration-1 hover:decoration-white"> <.link href={@href} target={@target} class="underline decoration-bodydark decoration-1 hover:decoration-white">
<%= render_slot(@inner_block) %> {render_slot(@inner_block)}
</.link> </.link>
""" """
end end
@@ -82,7 +83,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
assigns = Map.put(assigns, :text, formatted_text) assigns = Map.put(assigns, :text, formatted_text)
~H""" ~H"""
<span><%= @text %></span> <span>{@text}</span>
""" """
end end
@@ -98,7 +99,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
assigns = Map.put(assigns, :timezone, timezone) assigns = Map.put(assigns, :timezone, timezone)
~H""" ~H"""
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time> <time>{Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format)}</time>
""" """
end end
@@ -109,7 +110,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def localized_number(assigns) do def localized_number(assigns) do
~H""" ~H"""
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}><%= @number %></span> <span x-data x-text={"Intl.NumberFormat().format(#{@number})"}>{@number}</span>
""" """
end end
@@ -122,7 +123,27 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def pluralize(assigns) do def pluralize(assigns) do
~H""" ~H"""
<%= @word %><%= if @count == 1, do: "", else: @suffix %> {@word}{if @count == 1, do: "", else: @suffix}
"""
end
@doc """
Renders a human-readable byte size
"""
attr :byte_size, :integer, required: true
def readable_filesize(assigns) do
{num, suffix} = NumberUtils.human_byte_size(assigns.byte_size, precision: 2)
assigns =
Map.merge(assigns, %{
num: num,
suffix: suffix
})
~H"""
<.localized_number number={@num} /> {@suffix}
""" """
end end
end end
+2 -2
View File
@@ -61,7 +61,7 @@ defmodule PinchflatWeb.Layouts do
x-on:click="selected = !selected" x-on:click="selected = !selected"
> >
<span class="flex items-center gap-2.5"> <span class="flex items-center gap-2.5">
<.icon name={@icon} /> <%= @text %> <.icon name={@icon} /> {@text}
</span> </span>
<span class="text-bodydark2"> <span class="text-bodydark2">
<.icon name="hero-chevron-down" x-bind:class="{ 'rotate-180': selected }" /> <.icon name="hero-chevron-down" x-bind:class="{ 'rotate-180': selected }" />
@@ -103,7 +103,7 @@ defmodule PinchflatWeb.Layouts do
@class @class
]} ]}
> >
<.icon :if={@icon} name={@icon} /> <%= @text %> <.icon :if={@icon} name={@icon} /> {@text}
</.link> </.link>
""" """
end end
@@ -6,7 +6,7 @@
<main> <main>
<div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10"> <div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
<.flash_group flash={@flash} /> <.flash_group flash={@flash} />
<%= @inner_content %> {@inner_content}
</div> </div>
</main> </main>
</div> </div>
@@ -11,7 +11,7 @@
<main> <main>
<div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10"> <div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
<.flash_group flash={@flash} /> <.flash_group flash={@flash} />
<%= @inner_content %> {@inner_content}
</div> </div>
</main> </main>
</div> </div>
@@ -67,7 +67,7 @@
class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm" class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm"
x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"} x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
> >
<span>Pinchflat <%= Application.spec(:pinchflat)[:vsn] %></span> <span>Pinchflat {Application.spec(:pinchflat)[:vsn]}</span>
<a <a
href="https://github.com/kieraneglin/pinchflat/releases" href="https://github.com/kieraneglin/pinchflat/releases"
target="_blank" target="_blank"
@@ -79,7 +79,7 @@
</a> </a>
</span> </span>
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm"> <span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
yt-dlp <%= Settings.get!(:yt_dlp_version) %> yt-dlp {Settings.get!(:yt_dlp_version)}
</span> </span>
</li> </li>
</ul> </ul>
@@ -3,7 +3,7 @@ defmodule Pinchflat.UpgradeButtonLive do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<form id="upgradeForm" phx-change="check_matching_text" phx-hook="supressEnterSubmission"> <form id="upgradeForm" phx-change="check_matching_text" phx-hook="supress-enter-submission">
<.input type="text" name="unlock-pro-textbox" value="" /> <.input type="text" name="unlock-pro-textbox" value="" />
</form> </form>
@@ -25,6 +25,6 @@
into the text box and press the button. into the text box and press the button.
</p> </p>
<%= live_render(@conn, Pinchflat.UpgradeButtonLive) %> {live_render(@conn, Pinchflat.UpgradeButtonLive)}
</section> </section>
</.modal> </.modal>
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<.live_title> <.live_title>
<%= assigns[:page_title] || "Pinchflat" %> {assigns[:page_title] || "Pinchflat"}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-03-20"} /> <link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-03-20"} />
@@ -21,7 +21,7 @@
class="dark text-bodydark bg-boxdark-2" class="dark text-bodydark bg-boxdark-2"
data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")} data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")}
> >
<%= @inner_content %> {@inner_content}
<.donate_modal conn={@conn} /> <.donate_modal conn={@conn} />
<template x-if="!proEnabled && !onboarding"> <template x-if="!proEnabled && !onboarding">
@@ -1,6 +1,6 @@
<div class="mb-6 flex gap-3 flex-row items-center"> <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"> <h2 class="text-title-md2 font-bold text-black dark:text-white ml-4 truncate">
Editing "<%= StringUtils.truncate(@media_item.title, 35) %>" Editing "{@media_item.title}"
</h2> </h2>
</div> </div>
@@ -1,13 +1,13 @@
<%= if media_type(@media_item) == :video do %> <%= if media_type(@media_item) == :video do %>
<video controls class="max-h-128 w-full"> <video controls class="max-h-128 w-full">
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" /> <source src={~p"/media/#{@media_item.uuid}/stream?v=#{DateTime.to_unix(@media_item.updated_at)}"} type="video/mp4" />
Your browser does not support the video element. Your browser does not support the video element.
</video> </video>
<% end %> <% end %>
<%= if media_type(@media_item) == :audio do %> <%= if media_type(@media_item) == :audio do %>
<audio controls class="w-full"> <audio controls class="w-full">
<source src={~p"/media/#{@media_item.uuid}/stream"} type="audio/mpeg" /> <source src={~p"/media/#{@media_item.uuid}/stream?v=#{DateTime.to_unix(@media_item.updated_at)}"} type="audio/mpeg" />
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>
<% end %> <% end %>
@@ -1,10 +1,10 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between"> <div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex gap-3 items-center"> <div class="flex gap-3 items-center min-w-0">
<.link href={~p"/sources/#{@media_item.source_id}"}> <.link href={~p"/sources/#{@media_item.source_id}"}>
<.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 truncate">
<%= StringUtils.truncate(@media_item.title, 35) %> {@media_item.title}
</h2> </h2>
</div> </div>
@@ -31,15 +31,18 @@
<.media_preview media_item={@media_item} /> <.media_preview media_item={@media_item} />
</div> </div>
<aside class="mt-4"> <aside class="mt-4">
<h2 class="text-xl mb-2"><%= @media_item.title %></h2> <h2 class="text-xl mb-2">{@media_item.title}</h2>
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div> <div>Uploaded: {DateTime.to_date(@media_item.uploaded_at)}</div>
<div> <div>
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}> <span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link> <.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
<span class="mx-2">or</span> <span class="mx-2">or</span>
</span> </span>
<span> <span>
<.subtle_link href={~p"/media/#{@media_item.uuid}/stream"} target="_blank"> <.subtle_link
href={~p"/media/#{@media_item.uuid}/stream?v=#{DateTime.to_unix(@media_item.updated_at)}"}
target="_blank"
>
Open Local Stream Open Local Stream
</.subtle_link> </.subtle_link>
</span> </span>
@@ -55,7 +58,7 @@
<section> <section>
<strong>Source:</strong> <strong>Source:</strong>
<.subtle_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}
</.subtle_link> </.subtle_link>
<.list_items_from_map map={Map.from_struct(@media_item)} /> <.list_items_from_map map={Map.from_struct(@media_item)} />
</section> </section>
@@ -65,10 +68,10 @@
<%= if match?([_|_], @media_item.tasks) do %> <%= if match?([_|_], @media_item.tasks) do %>
<.table rows={@media_item.tasks} table_class="text-black dark:text-white"> <.table rows={@media_item.tasks} table_class="text-black dark:text-white">
<:col :let={task} label="Worker"> <:col :let={task} label="Worker">
<%= task.job.worker %> {task.job.worker}
</:col> </:col>
<:col :let={task} label="State"> <:col :let={task} label="State">
<%= task.job.state %> {task.job.state}
</:col> </:col>
<:col :let={task} label="Scheduled At"> <:col :let={task} label="Scheduled At">
<.datetime_in_zone datetime={task.job.scheduled_at} /> <.datetime_in_zone datetime={task.job.scheduled_at} />
@@ -1,26 +1,51 @@
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
use Pinchflat.Sources.SourcesQuery use Pinchflat.Sources.SourcesQuery
use Pinchflat.Profiles.ProfilesQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Sources.Source
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Profiles.MediaProfileDeletionWorker alias Pinchflat.Profiles.MediaProfileDeletionWorker
def index(conn, _params) do def index(conn, _params) do
media_profiles = media_profiles_query =
MediaProfile from mp in MediaProfile,
|> where([mp], is_nil(mp.marked_for_deletion_at)) as: :media_profile,
|> order_by(asc: :name) where: is_nil(mp.marked_for_deletion_at),
|> Repo.all() order_by: [asc: mp.name],
select: map(mp, ^MediaProfile.__schema__(:fields)),
select_merge: %{
source_count:
subquery(
from s in Source,
where: s.media_profile_id == parent_as(:media_profile).id,
select: count(s.id)
)
}
render(conn, :index, media_profiles: media_profiles) render(conn, :index, media_profiles: Repo.all(media_profiles_query))
end end
def new(conn, _params) do def new(conn, params) do
changeset = Profiles.change_media_profile(%MediaProfile{}) # Preload an existing media profile for faster creation
cs_struct =
case to_string(params["template_id"]) do
"" -> %MediaProfile{}
template_id -> Repo.get(MediaProfile, template_id) || %MediaProfile{}
end
render(conn, :new, changeset: changeset, layout: get_onboarding_layout()) render(conn, :new,
layout: get_onboarding_layout(),
changeset:
Profiles.change_media_profile(%MediaProfile{
cs_struct
| id: nil,
name: nil,
marked_for_deletion_at: nil
})
)
end end
def create(conn, %{"media_profile" => media_profile_params}) do def create(conn, %{"media_profile" => media_profile_params}) do
@@ -10,6 +10,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
""" """
attr :changeset, Ecto.Changeset, required: true attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true attr :action, :string, required: true
attr :method, :string, required: true
def media_profile_form(assigns) def media_profile_form(assigns)
@@ -25,6 +26,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
[ [
{"8k", "4320p"}, {"8k", "4320p"},
{"4k", "2160p"}, {"4k", "2160p"},
{"1440p", "1440p"},
{"1080p", "1080p"}, {"1080p", "1080p"},
{"720p", "720p"}, {"720p", "720p"},
{"480p", "480p"}, {"480p", "480p"},
@@ -11,6 +11,11 @@
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span> <span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span> </span>
</:option> </:option>
<:option>
<.link href={~p"/media_profiles/new?template_id=#{@media_profile}"} method="get">
Use as Template
</.link>
</:option>
<:option> <:option>
<div class="h-px w-full bg-bodydark2"></div> <div class="h-px w-full bg-bodydark2"></div>
</:option> </:option>
@@ -2,15 +2,15 @@
<.link href={~p"/media_profiles"}> <.link href={~p"/media_profiles"}>
<.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 truncate">
Editing "<%= @media_profile.name %>" Editing "{@media_profile.name}"
</h2> </h2>
</div> </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="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"> <div class="max-w-full">
<div class="flex flex-col gap-10"> <div class="flex flex-col gap-10">
<.media_profile_form changeset={@changeset} action={~p"/media_profiles/#{@media_profile}"} /> <.media_profile_form changeset={@changeset} action={~p"/media_profiles/#{@media_profile}"} method="patch" />
</div> </div>
</div> </div>
</div> </div>
@@ -10,18 +10,22 @@
</.link> </.link>
</nav> </nav>
</div> </div>
<div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark"> <div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10 min-w-max"> <div class="flex flex-col gap-10 min-w-max">
<.table rows={@media_profiles} table_class="text-black dark:text-white"> <.table rows={@media_profiles} table_class="text-black dark:text-white">
<:col :let={media_profile} label="Name"> <:col :let={media_profile} label="Name" class="truncate max-w-xs">
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}> <.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
<%= media_profile.name %> {media_profile.name}
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={media_profile} label="Preferred Resolution"> <:col :let={media_profile} label="Preferred Resolution">
<%= media_profile.preferred_resolution %> {media_profile.preferred_resolution}
</:col>
<:col :let={media_profile} label="Sources">
<.subtle_link href={~p"/media_profiles/#{media_profile.id}/#tab-sources"}>
<.localized_number number={media_profile.source_count} />
</.subtle_link>
</:col> </:col>
<:col :let={media_profile} label="" class="flex justify-end"> <:col :let={media_profile} label="" class="flex justify-end">
<.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mr-4" /> <.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mr-4" />
@@ -2,6 +2,7 @@
:let={f} :let={f}
for={@changeset} for={@changeset}
action={@action} action={@action}
method={@method}
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }" x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))" x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
> >
@@ -124,6 +125,16 @@
/> />
</section> </section>
<section x-show="advancedMode">
<.input
field={f[:audio_track]}
placeholder="de"
type="text"
label="Audio Track Language"
help="Only works if there are multiple audio tracks. Use either a language code, 'original' for the original audio track, or 'default' for YouTube's preference. Or just leave it blank"
/>
</section>
<h3 class="mt-10 text-2xl text-black dark:text-white"> <h3 class="mt-10 text-2xl text-black dark:text-white">
Thumbnail Options Thumbnail Options
</h3> </h3>
@@ -8,7 +8,7 @@
<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="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"> <div class="max-w-full">
<div class="flex flex-col gap-10"> <div class="flex flex-col gap-10">
<.media_profile_form changeset={@changeset} action={~p"/media_profiles"} /> <.media_profile_form changeset={@changeset} action={~p"/media_profiles"} method="post" />
</div> </div>
</div> </div>
</div> </div>
@@ -6,7 +6,9 @@
<ul class="list-disc list-inside ml-2 md:ml-5"> <ul class="list-disc list-inside ml-2 md:ml-5">
<li> <li>
Liquid-style: Liquid-style:
<.inline_code>/{{ channel }}/{{ title }} - {{ id }}.{{ ext }}</.inline_code> <.inline_code>
{"/#{double_brace("channel")}/#{double_brace("title")} - #{double_brace("id")}.#{double_brace("ext")}"}
</.inline_code>
</li> </li>
<li> <li>
<code class="text-sm">yt-dlp</code>-style <code class="text-sm">yt-dlp</code>-style
@@ -17,7 +19,7 @@
</li> </li>
<li> <li>
Any bare words: Any bare words:
<.inline_code>/videos/1080p/{{ id }}.{{ ext }}</.inline_code> <.inline_code>{"/videos/1080p/#{double_brace("id")}.#{double_brace("ext")}"}</.inline_code>
</li> </li>
</ul> </ul>
<p class="my-2"> <p class="my-2">
@@ -25,7 +27,7 @@
<em>any</em> <em>any</em>
single-word <code class="text-sm">yt-dlp</code> single-word <code class="text-sm">yt-dlp</code>
option can be used as liquid-style and it's automatically made filepath-safe. For example, the option can be used as liquid-style and it's automatically made filepath-safe. For example, the
<.inline_code>{{ duration }}</.inline_code> <.inline_code>{double_brace("duration")}</.inline_code>
option is translated to option is translated to
<.inline_code>%(duration)S</.inline_code> <.inline_code>%(duration)S</.inline_code>
</p> </p>
@@ -33,12 +35,14 @@
<strong>Major 🔑:</strong> <strong>Major 🔑:</strong>
these syntaxes can be mixed and matched freely! I prefer to use liquid-style and bare words these syntaxes can be mixed and matched freely! I prefer to use liquid-style and bare words
but I'll include <code class="text-sm">yt-dlp</code>-style when I need more control. For example: but I'll include <code class="text-sm">yt-dlp</code>-style when I need more control. For example:
<.inline_code>/1080p/{{ channel }}/{{ title }}-(%(subtitles.en.-1.ext)s).{{ ext }}</.inline_code> <.inline_code>
{"/1080p/#{double_brace("channel")}/#{double_brace("title")}-(%(subtitles.en.-1.ext)s).#{double_brace("ext")}"}
</.inline_code>
</p> </p>
<p class="my-2"> <p class="my-2">
<strong>NOTE:</strong> <strong>NOTE:</strong>
Your template <em>must</em> Your template <em>must</em>
end with an extension option (<.inline_code>.{{ ext }}</.inline_code> end with an extension option (<.inline_code>.{double_brace("ext")}</.inline_code>
or or
<.inline_code>.%(ext)S</.inline_code>). <.inline_code>.%(ext)S</.inline_code>).
Downloading won't work as expected without it. Downloading won't work as expected without it.
@@ -59,21 +63,21 @@
<h3 class="text-lg font-bold my-2">Media Center Custom Aliases</h3> <h3 class="text-lg font-bold my-2">Media Center Custom Aliases</h3>
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose"> <ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
<li :for={{k, v} <- media_center_custom_output_template_options()} class="mt-1"> <li :for={{k, v} <- media_center_custom_output_template_options()} class="mt-1">
<.inline_code>{{ <%= k %> }}</.inline_code> <.inline_code>{double_brace(k)}</.inline_code>
<span :if={v}>- <%= html_escape({:safe, v}) %></span> <span :if={v}>- {html_escape({:safe, v})}</span>
</li> </li>
</ul> </ul>
<h3 class="text-lg font-bold mb-2">Other Custom Aliases</h3> <h3 class="text-lg font-bold mb-2">Other Custom Aliases</h3>
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose"> <ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
<li :for={{k, v} <- other_custom_output_template_options()} class="mt-1"> <li :for={{k, v} <- other_custom_output_template_options()} class="mt-1">
<.inline_code>{{ <%= k %> }}</.inline_code> <.inline_code>{double_brace(k)}</.inline_code>
<span :if={v}>- <%= html_escape({:safe, v}) %></span> <span :if={v}>- {html_escape({:safe, v})}</span>
</li> </li>
</ul> </ul>
<h3 class="text-lg font-bold mb-2">Common Options</h3> <h3 class="text-lg font-bold mb-2">Common Options</h3>
<ul class="list-disc list-inside ml-2 md:ml-5"> <ul class="list-disc list-inside ml-2 md:ml-5">
<li :for={opt <- common_output_template_options()} class="mt-1"> <li :for={opt <- common_output_template_options()} class="mt-1">
<.inline_code>{{ <%= opt %> }}</.inline_code> <.inline_code>{double_brace(opt)}</.inline_code>
</li> </li>
</ul> </ul>
</section> </section>
@@ -1,10 +1,10 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between"> <div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex items-center"> <div class="flex items-center min-w-0">
<.link href={~p"/media_profiles"}> <.link href={~p"/media_profiles"}>
<.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-2"> <h2 class="text-title-md2 font-bold text-black dark:text-white ml-2 truncate">
<%= @media_profile.name %> {@media_profile.name}
</h2> </h2>
</div> </div>
@@ -33,10 +33,10 @@
<.table rows={@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">
<.subtle_link href={~p"/sources/#{source.id}"}> <.subtle_link href={~p"/sources/#{source.id}"}>
<%= source.custom_name || source.collection_name %> {source.custom_name || source.collection_name}
</.subtle_link> </.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?">
<.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>
@@ -1,23 +1,5 @@
defmodule PinchflatWeb.Pages.PageHTML do defmodule PinchflatWeb.Pages.PageHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
alias Pinchflat.Utils.NumberUtils
embed_templates "page_html/*" embed_templates "page_html/*"
attr :media_filesize, :integer, required: true
def readable_media_filesize(assigns) do
{num, suffix} = NumberUtils.human_byte_size(assigns.media_filesize, precision: 2)
assigns =
Map.merge(assigns, %{
num: num,
suffix: suffix
})
~H"""
<.localized_number number={@num} /> <%= @suffix %>
"""
end
end end
@@ -28,23 +28,23 @@ defmodule Pinchflat.Pages.HistoryTableLive do
</span> </span>
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.table rows={@records} table_class="text-white"> <.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title"> <:col :let={media_item} label="Title" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}> <.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}>
<%= StringUtils.truncate(media_item.title, 35) %> {media_item.title}
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={media_item} label="Upload Date"> <:col :let={media_item} label="Upload Date">
<%= DateTime.to_date(media_item.uploaded_at) %> {DateTime.to_date(media_item.uploaded_at)}
</:col> </:col>
<:col :let={media_item} label="Indexed At"> <:col :let={media_item} label="Indexed At">
<%= format_datetime(media_item.inserted_at) %> {format_datetime(media_item.inserted_at)}
</:col> </:col>
<:col :let={media_item} label="Downloaded At"> <:col :let={media_item} label="Downloaded At">
<%= format_datetime(media_item.media_downloaded_at) %> {format_datetime(media_item.media_downloaded_at)}
</:col> </:col>
<:col :let={media_item} label="Source"> <:col :let={media_item} label="Source" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{media_item.source_id}"}> <.subtle_link href={~p"/sources/#{media_item.source_id}"}>
<%= StringUtils.truncate(media_item.source.custom_name, 35) %> {media_item.source.custom_name}
</.subtle_link> </.subtle_link>
</:col> </:col>
</.table> </.table>
@@ -56,9 +56,9 @@ defmodule Pinchflat.Pages.HistoryTableLive do
""" """
end end
def mount(_params, _session, socket) do def mount(_params, session, socket) do
page = 1 page = 1
base_query = generate_base_query() base_query = generate_base_query(session["media_state"])
pagination_attrs = fetch_pagination_attributes(base_query, page) pagination_attrs = fetch_pagination_attributes(base_query, page)
{:ok, assign(socket, Map.merge(pagination_attrs, %{base_query: base_query}))} {:ok, assign(socket, Map.merge(pagination_attrs, %{base_query: base_query}))}
@@ -97,10 +97,17 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|> Repo.preload(:source) |> Repo.preload(:source)
end end
defp generate_base_query do defp generate_base_query("pending") do
MediaQuery.new() MediaQuery.new()
|> MediaQuery.require_assoc(:media_profile) |> MediaQuery.require_assoc(:media_profile)
|> where(^dynamic(^MediaQuery.downloaded() or ^MediaQuery.pending())) |> where(^dynamic(^MediaQuery.pending()))
|> order_by(desc: :id)
end
defp generate_base_query("downloaded") do
MediaQuery.new()
|> MediaQuery.require_assoc(:media_profile)
|> where(^dynamic(^MediaQuery.downloaded()))
|> order_by(desc: :id) |> order_by(desc: :id)
end end
@@ -33,7 +33,7 @@
<span class="flex flex-col items-center py-2"> <span class="flex flex-col items-center py-2">
<span class="text-md font-medium">Library Size</span> <span class="text-md font-medium">Library Size</span>
<h4 class="text-title-md font-bold text-white"> <h4 class="text-title-md font-bold text-white">
<.readable_media_filesize media_filesize={@media_item_size} /> <.readable_filesize byte_size={@media_item_size} />
</h4> </h4>
</span> </span>
</div> </div>
@@ -41,14 +41,27 @@
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5"> <div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
<span class="text-2xl font-medium mb-4">Media History</span> <span class="text-2xl font-medium mb-4">Media History</span>
<section class="mt-6"> <.tabbed_layout>
<%= live_render(@conn, Pinchflat.Pages.HistoryTableLive) %> <:tab title="Downloaded" id="downloaded">
</section> {live_render(
@conn,
Pinchflat.Pages.HistoryTableLive,
session: %{"media_state" => "downloaded"}
)}
</:tab>
<:tab title="Pending" id="pending">
{live_render(
@conn,
Pinchflat.Pages.HistoryTableLive,
session: %{"media_state" => "pending"}
)}
</:tab>
</.tabbed_layout>
</div> </div>
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5"> <div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
<span class="text-2xl font-medium mb-4">Active Tasks</span> <span class="text-2xl font-medium mb-4">Active Tasks</span>
<section class="mt-6 min-h-80"> <section class="mt-6 min-h-80">
<%= live_render(@conn, Pinchflat.Pages.JobTableLive) %> {live_render(@conn, Pinchflat.Pages.JobTableLive)}
</section> </section>
</div> </div>
@@ -19,18 +19,18 @@ defmodule Pinchflat.Pages.JobTableLive do
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.table rows={@tasks} table_class="text-white"> <.table rows={@tasks} table_class="text-white">
<:col :let={task} label="Task"> <:col :let={task} label="Task">
<%= worker_to_task_name(task.job.worker) %> {worker_to_task_name(task.job.worker)}
</:col> </:col>
<:col :let={task} label="Subject"> <:col :let={task} label="Subject" class="truncate max-w-xs">
<.subtle_link href={task_to_link(task)}> <.subtle_link href={task_to_link(task)}>
<%= StringUtils.truncate(task_to_record_name(task), 35) %> {task_to_record_name(task)}
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={task} label="Attempt No."> <:col :let={task} label="Attempt No.">
<%= task.job.attempt %> {task.job.attempt}
</:col> </:col>
<:col :let={task} label="Started At"> <:col :let={task} label="Started At">
<%= format_datetime(task.job.attempted_at) %> {format_datetime(task.job.attempted_at)}
</:col> </:col>
</.table> </.table>
</div> </div>
@@ -6,8 +6,19 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Podcasts.RssFeedBuilder alias Pinchflat.Podcasts.RssFeedBuilder
alias Pinchflat.Podcasts.OpmlFeedBuilder
alias Pinchflat.Podcasts.PodcastHelpers alias Pinchflat.Podcasts.PodcastHelpers
def opml_feed(conn, _params) do
url_base = url(conn, ~p"/")
xml = OpmlFeedBuilder.build(url_base, PodcastHelpers.opml_sources())
conn
|> put_resp_content_type("application/opml+xml")
|> put_resp_header("content-disposition", "inline")
|> send_resp(200, xml)
end
def rss_feed(conn, %{"uuid" => uuid}) do def rss_feed(conn, %{"uuid" => uuid}) do
source = Repo.get_by!(Source, uuid: uuid) source = Repo.get_by!(Source, uuid: uuid)
url_base = url(conn, ~p"/") url_base = url(conn, ~p"/")
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.Searches.SearchHTML do
~H""" ~H"""
<%= for fragment <- @split_string do %> <%= for fragment <- @split_string do %>
<%= render_fragment(fragment) %> {render_fragment(fragment)}
<% end %> <% end %>
""" """
end end
@@ -1,6 +1,6 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between"> <div class="mb-6 flex gap-3 flex-row items-center justify-between">
<h2 class="text-title-md2 font-bold text-black dark:text-white"> <h2 class="text-title-md2 font-bold text-black dark:text-white truncate">
Results for "<%= StringUtils.truncate(@search_term, 50) %>" Results for "{@search_term}"
</h2> </h2>
</div> </div>
@@ -9,9 +9,9 @@
<div class="flex flex-col gap-10 dark:text-white"> <div class="flex flex-col gap-10 dark:text-white">
<%= if match?([_|_], @search_results) do %> <%= if match?([_|_], @search_results) do %>
<.table rows={@search_results} table_class="text-black dark:text-white"> <.table rows={@search_results} table_class="text-black dark:text-white">
<:col :let={result} label="Title"> <:col :let={result} label="Title" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{result.source_id}/media/#{result.id}"}> <.subtle_link href={~p"/sources/#{result.source_id}/media/#{result.id}"}>
<%= StringUtils.truncate(result.title, 35) %> {result.title}
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={result} label="Excerpt"> <:col :let={result} label="Excerpt">
@@ -19,11 +19,11 @@
</span> </span>
</section> </section>
<%= live_render( {live_render(
@conn, @conn,
Pinchflat.Settings.AppriseServerLive, Pinchflat.Settings.AppriseServerLive,
session: %{"value" => f[:apprise_server].value} session: %{"value" => f[:apprise_server].value}
) %> )}
</section> </section>
<section class="mt-8"> <section class="mt-8">
@@ -1,12 +1,11 @@
defmodule PinchflatWeb.Sources.SourceController do defmodule PinchflatWeb.Sources.SourceController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
use Pinchflat.Media.MediaQuery use Pinchflat.Sources.SourcesQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Media.FileSyncingWorker alias Pinchflat.Media.FileSyncingWorker
alias Pinchflat.Sources.SourceDeletionWorker alias Pinchflat.Sources.SourceDeletionWorker
@@ -15,33 +14,7 @@ defmodule PinchflatWeb.Sources.SourceController do
alias Pinchflat.Metadata.SourceMetadataStorageWorker alias Pinchflat.Metadata.SourceMetadataStorageWorker
def index(conn, _params) do def index(conn, _params) do
source_query = render(conn, :index)
from s in Source,
as: :source,
inner_join: mp in assoc(s, :media_profile),
where: is_nil(s.marked_for_deletion_at) and is_nil(mp.marked_for_deletion_at),
preload: [media_profile: mp],
order_by: [asc: s.custom_name],
select: map(s, ^Source.__schema__(:fields)),
select_merge: %{
downloaded_count:
subquery(
from m in MediaItem,
where: m.source_id == parent_as(:source).id,
where: ^MediaQuery.downloaded(),
select: count(m.id)
),
pending_count:
subquery(
from m in MediaItem,
join: s in assoc(m, :source),
where: m.source_id == parent_as(:source).id,
where: ^MediaQuery.pending(),
select: count(m.id)
)
}
render(conn, :index, sources: Repo.all(source_query))
end end
def new(conn, params) do def new(conn, params) do
@@ -67,7 +40,8 @@ defmodule PinchflatWeb.Sources.SourceController do
collection_name: nil, collection_name: nil,
collection_id: nil, collection_id: nil,
collection_type: nil, collection_type: nil,
original_url: nil original_url: nil,
marked_for_deletion_at: nil
}) })
) )
end end
@@ -27,10 +27,28 @@ defmodule PinchflatWeb.Sources.SourceHTML do
] ]
end end
def cutoff_date_presets do
[
{"7 days", compute_date_offset(7)},
{"14 days", compute_date_offset(14)},
{"30 days", compute_date_offset(30)},
{"60 days", compute_date_offset(60)},
{"90 days", compute_date_offset(90)},
{"180 days", compute_date_offset(180)},
{"365 days", compute_date_offset(365)}
]
end
def rss_feed_url(conn, source) do def rss_feed_url(conn, source) do
# NOTE: The reason for this concatenation is to avoid what appears to be a bug in Phoenix
# See: https://github.com/phoenixframework/phoenix/issues/6033
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml" url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end end
def opml_feed_url(conn) do
url(conn, ~p"/sources/opml.xml?#{[route_token: Settings.get!(:route_token)]}")
end
def output_path_template_override_placeholders(media_profiles) do def output_path_template_override_placeholders(media_profiles) do
media_profiles media_profiles
|> Enum.map(&{&1.id, &1.output_path_template}) |> Enum.map(&{&1.id, &1.output_path_template})
@@ -55,4 +73,13 @@ defmodule PinchflatWeb.Sources.SourceHTML do
Must end with .{{ ext }}. Same rules as Media Profile output path templates. #{help_button} to load your media profile's output template Must end with .{{ ext }}. Same rules as Media Profile output path templates. #{help_button} to load your media profile's output template
""" """
end end
defp compute_date_offset(days) do
timezone = Application.get_env(:pinchflat, :timezone)
timezone
|> Timex.now()
|> Timex.shift(days: -days)
|> Timex.format!("{YYYY}-{0M}-{0D}")
end
end end
@@ -1,18 +1,16 @@
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50"> <.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option> <:option>
<span <.link href={rss_feed_url(@conn, @source)} x-data="{ copied: false }" x-on:click={~s"
x-data="{ copied: false }" $event.preventDefault();
x-on:click={"
copyWithCallbacks( copyWithCallbacks(
'#{rss_feed_url(@conn, @source)}', '#{rss_feed_url(@conn, @source)}',
() => copied = true, () => copied = true,
() => copied = false () => copied = false
) )
"} "}>
>
Copy RSS Feed Copy RSS Feed
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span> <span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span> </.link>
</:option> </:option>
<:option> <:option>
<span x-data="{ copied: false }" x-on:click={~s" <span x-data="{ copied: false }" x-on:click={~s"
@@ -2,8 +2,8 @@
<.link href={~p"/sources"}> <.link href={~p"/sources"}>
<.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 truncate">
Editing "<%= @source.custom_name %>" Editing "{@source.custom_name}"
</h2> </h2>
</div> </div>
@@ -1,6 +1,17 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between"> <div class="mb-6 flex gap-3 flex-row items-center justify-between">
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2> <h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
<nav> <nav class="flex items-center justify-between gap-6">
<.link href={opml_feed_url(@conn)} x-data="{ copied: false }" x-on:click={~s"
$event.preventDefault();
copyWithCallbacks(
'#{opml_feed_url(@conn)}',
() => copied = true,
() => copied = false
)
"}>
Copy OPML <span class="hidden sm:inline">Feed</span>
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</.link>
<.link href={~p"/sources/new"}> <.link href={~p"/sources/new"}>
<.button color="bg-primary" rounding="rounded-lg"> <.button color="bg-primary" rounding="rounded-lg">
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span> <span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
@@ -11,33 +22,12 @@
<div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark"> <div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10 min-w-max"> {live_render(@conn, PinchflatWeb.Sources.SourceLive.IndexTableLive,
<.table rows={@sources} table_class="text-black dark:text-white"> session: %{
<:col :let={source} label="Name"> "initial_sort_key" => :custom_name,
<.subtle_link href={~p"/sources/#{source.id}"}> "initial_sort_direction" => :asc,
<%= StringUtils.truncate(source.custom_name || source.collection_name, 35) %> "results_per_page" => 10
</.subtle_link> }
</:col> )}
<:col :let={source} label="Type"><%= source.collection_type %></:col>
<:col :let={source} label="Pending"><.localized_number number={source.pending_count} /></:col>
<:col :let={source} label="Downloaded"><.localized_number number={source.downloaded_count} /></:col>
<:col :let={source} label="Retention">
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
<.localized_number number={source.retention_period_days} />
<.pluralize count={source.retention_period_days} word="day" />
<% else %>
<span class="text-lg">∞</span>
<% end %>
</:col>
<:col :let={source} label="Media Profile">
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
<%= source.media_profile.name %>
</.subtle_link>
</:col>
<:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
</:col>
</.table>
</div>
</div> </div>
</div> </div>
@@ -0,0 +1,103 @@
defmodule PinchflatWeb.Sources.IndexTableLive do
use PinchflatWeb, :live_view
use Pinchflat.Media.MediaQuery
use Pinchflat.Sources.SourcesQuery
alias Pinchflat.Repo
alias Pinchflat.Sources
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
def render(assigns) do
~H"""
<.table rows={@sources} table_class="text-white">
<:col :let={source} label="Name" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{source.id}"}>
{source.custom_name || source.collection_name}
</.subtle_link>
</:col>
<:col :let={source} label="Pending">
<.subtle_link href={~p"/sources/#{source.id}/#tab-pending"}>
<.localized_number number={source.pending_count} />
</.subtle_link>
</:col>
<:col :let={source} label="Downloaded">
<.subtle_link href={~p"/sources/#{source.id}/#tab-downloaded"}>
<.localized_number number={source.downloaded_count} />
</.subtle_link>
</:col>
<:col :let={source} label="Retention">
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
<.localized_number number={source.retention_period_days} />
<.pluralize count={source.retention_period_days} word="day" />
<% else %>
<span class="text-lg">∞</span>
<% end %>
</:col>
<:col :let={source} label="Media Profile">
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
{source.media_profile.name}
</.subtle_link>
</:col>
<:col :let={source} label="Enabled?">
<.input
name={"source[#{source.id}][enabled]"}
value={source.enabled}
id={"source_#{source.id}_enabled"}
phx-hook="formless-input"
data-subscribe="change"
data-event-name="toggle_enabled"
data-identifier={source.id}
type="toggle"
/>
</:col>
<:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
</:col>
</.table>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, %{sources: get_sources()})}
end
def handle_event("formless-input", %{"event" => "toggle_enabled"} = params, socket) do
source = Sources.get_source!(params["id"])
should_enable = params["value"] == "true"
{:ok, _} = Sources.update_source(source, %{enabled: should_enable})
{:noreply, assign(socket, %{sources: get_sources()})}
end
defp get_sources do
query =
from s in Source,
as: :source,
inner_join: mp in assoc(s, :media_profile),
where: is_nil(s.marked_for_deletion_at) and is_nil(mp.marked_for_deletion_at),
preload: [media_profile: mp],
order_by: [asc: s.custom_name],
select: map(s, ^Source.__schema__(:fields)),
select_merge: %{
downloaded_count:
subquery(
from m in MediaItem,
where: m.source_id == parent_as(:source).id,
where: ^MediaQuery.downloaded(),
select: count(m.id)
),
pending_count:
subquery(
from m in MediaItem,
join: s in assoc(m, :source),
where: m.source_id == parent_as(:source).id,
where: ^MediaQuery.pending(),
select: count(m.id)
)
}
Repo.all(query)
end
end
@@ -1,4 +1,4 @@
defmodule Pinchflat.Sources.MediaItemTableLive do defmodule PinchflatWeb.Sources.MediaItemTableLive do
use PinchflatWeb, :live_view use PinchflatWeb, :live_view
use Pinchflat.Media.MediaQuery use Pinchflat.Media.MediaQuery
@@ -46,16 +46,16 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
</div> </div>
</header> </header>
<.table rows={@records} table_class="text-white"> <.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title"> <:col :let={media_item} label="Title" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}> <.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
<%= StringUtils.truncate(media_item.title, 50) %> {media_item.title}
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?"> <:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} /> <.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
</:col> </:col>
<:col :let={media_item} label="Upload Date"> <:col :let={media_item} label="Upload Date">
<%= DateTime.to_date(media_item.uploaded_at) %> {DateTime.to_date(media_item.uploaded_at)}
</:col> </:col>
<:col :let={media_item} label="" class="flex justify-end"> <:col :let={media_item} label="" class="flex justify-end">
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" /> <.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
@@ -1,10 +1,10 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between"> <div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex gap-3 items-center"> <div class="flex gap-3 items-center min-w-0">
<.link href={~p"/sources"}> <.link href={~p"/sources"}>
<.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 truncate">
<%= @source.custom_name %> {@source.custom_name}
</h2> </h2>
</div> </div>
@@ -29,7 +29,7 @@
<section> <section>
<strong>Media Profile:</strong> <strong>Media Profile:</strong>
<.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}
</.subtle_link> </.subtle_link>
</section> </section>
@@ -37,34 +37,34 @@
</div> </div>
</:tab> </:tab>
<:tab title="Pending" id="pending"> <:tab title="Pending" id="pending">
<%= live_render( {live_render(
@conn, @conn,
Pinchflat.Sources.MediaItemTableLive, PinchflatWeb.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "pending"} session: %{"source_id" => @source.id, "media_state" => "pending"}
) %> )}
</:tab> </:tab>
<:tab title="Downloaded" id="downloaded"> <:tab title="Downloaded" id="downloaded">
<%= live_render( {live_render(
@conn, @conn,
Pinchflat.Sources.MediaItemTableLive, PinchflatWeb.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "downloaded"} session: %{"source_id" => @source.id, "media_state" => "downloaded"}
) %> )}
</:tab> </:tab>
<:tab title="Other" id="other"> <:tab title="Other" id="other">
<%= live_render( {live_render(
@conn, @conn,
Pinchflat.Sources.MediaItemTableLive, PinchflatWeb.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "other"} session: %{"source_id" => @source.id, "media_state" => "other"}
) %> )}
</:tab> </:tab>
<:tab title="Tasks" id="tasks"> <:tab title="Tasks" id="tasks">
<%= if match?([_|_], @pending_tasks) do %> <%= if match?([_|_], @pending_tasks) do %>
<.table rows={@pending_tasks} table_class="text-black dark:text-white"> <.table rows={@pending_tasks} table_class="text-black dark:text-white">
<:col :let={task} label="Worker"> <:col :let={task} label="Worker">
<%= task.job.worker %> {task.job.worker}
</:col> </:col>
<:col :let={task} label="State"> <:col :let={task} label="State">
<%= task.job.state %> {task.job.state}
</:col> </:col>
<:col :let={task} label="Scheduled At"> <:col :let={task} label="Scheduled At">
<.datetime_in_zone datetime={task.job.scheduled_at} /> <.datetime_in_zone datetime={task.job.scheduled_at} />
@@ -111,6 +111,7 @@
/> />
</section> </section>
<section x-data={"{ cutoffDate: '#{f[:download_cutoff_date].value}' }"}>
<.input <.input
field={f[:download_cutoff_date]} field={f[:download_cutoff_date]}
type="text" type="text"
@@ -120,7 +121,21 @@
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])" pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
title="YYYY-MM-DD" 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. Old media may be deleted or downloaded if you change this date" help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format. Old media may be deleted or downloaded if you change this date"
x-model="cutoffDate"
>
<:input_append>
<.input
prompt="Select preset"
name="download_cutoff_date_preset"
value=""
options={cutoff_date_presets()}
type="select"
inputclass="w-30 lg:w-60 ml-2 md:ml-4"
x-on:change={"cutoffDate = $event.target.value || '#{f[:download_cutoff_date].value}'"}
/> />
</:input_append>
</.input>
</section>
<.input <.input
field={f[:retention_period_days]} field={f[:retention_period_days]}
@@ -0,0 +1,108 @@
defmodule PinchflatWeb.Sources.SourceLive.IndexTableLive do
use PinchflatWeb, :live_view
use Pinchflat.Media.MediaQuery
use Pinchflat.Sources.SourcesQuery
import PinchflatWeb.Helpers.SortingHelpers
import PinchflatWeb.Helpers.PaginationHelpers
alias Pinchflat.Repo
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
def mount(_params, session, socket) do
limit = session["results_per_page"]
initial_params =
Map.merge(
%{
sort_key: session["initial_sort_key"],
sort_direction: session["initial_sort_direction"]
},
get_pagination_attributes(sources_query(), 1, limit)
)
socket
|> assign(initial_params)
|> set_sources()
|> then(&{:ok, &1})
end
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
new_page = update_page_number(assigns.page, direction, assigns.total_pages)
socket
|> assign(get_pagination_attributes(sources_query(), new_page, assigns.limit))
|> set_sources()
|> then(&{:noreply, &1})
end
def handle_event("sort_update", %{"sort_key" => sort_key}, %{assigns: assigns} = socket) do
new_sort_key = String.to_existing_atom(sort_key)
new_params = %{
sort_key: new_sort_key,
sort_direction: get_sort_direction(assigns.sort_key, new_sort_key, assigns.sort_direction)
}
socket
|> assign(new_params)
|> set_sources()
|> then(&{:noreply, &1})
end
defp sort_attr(:pending_count), do: dynamic([s, mp, dl, pe], pe.pending_count)
defp sort_attr(:downloaded_count), do: dynamic([s, mp, dl], dl.downloaded_count)
defp sort_attr(:media_size_bytes), do: dynamic([s, mp, dl], dl.media_size_bytes)
defp sort_attr(:media_profile_name), do: dynamic([s, mp], mp.name)
defp sort_attr(:custom_name), do: dynamic([s], s.custom_name)
defp sort_attr(:enabled), do: dynamic([s], s.enabled)
defp set_sources(%{assigns: assigns} = socket) do
sources =
sources_query()
|> order_by(^[{assigns.sort_direction, sort_attr(assigns.sort_key)}, asc: :id])
|> limit(^assigns.limit)
|> offset(^assigns.offset)
|> Repo.all()
assign(socket, %{sources: sources})
end
defp sources_query do
downloaded_subquery =
from(
m in MediaItem,
select: %{downloaded_count: count(m.id), source_id: m.source_id, media_size_bytes: sum(m.media_size_bytes)},
where: ^MediaQuery.downloaded(),
group_by: m.source_id
)
pending_subquery =
from(
m in MediaItem,
inner_join: s in assoc(m, :source),
inner_join: mp in assoc(s, :media_profile),
select: %{pending_count: count(m.id), source_id: m.source_id},
where: ^MediaQuery.pending(),
group_by: m.source_id
)
from s in Source,
as: :source,
inner_join: mp in assoc(s, :media_profile),
left_join: d in subquery(downloaded_subquery),
on: d.source_id == s.id,
left_join: p in subquery(pending_subquery),
on: p.source_id == s.id,
on: d.source_id == s.id,
where: is_nil(s.marked_for_deletion_at) and is_nil(mp.marked_for_deletion_at),
preload: [media_profile: mp],
select: map(s, ^Source.__schema__(:fields)),
select_merge: %{
downloaded_count: coalesce(d.downloaded_count, 0),
pending_count: coalesce(p.pending_count, 0),
media_size_bytes: coalesce(d.media_size_bytes, 0)
}
end
end
@@ -0,0 +1,41 @@
<div class="flex flex-col min-w-max">
<.table rows={@sources} table_class="text-white" sort_key={@sort_key} sort_direction={@sort_direction}>
<:col :let={source} label="Name" sort_key="custom_name" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{source.id}"}>
{source.custom_name}
</.subtle_link>
</:col>
<:col :let={source} label="Pending" sort_key="pending_count">
<.subtle_link href={~p"/sources/#{source.id}/#tab-pending"}>
<.localized_number number={source.pending_count} />
</.subtle_link>
</:col>
<:col :let={source} label="Downloaded" sort_key="downloaded_count">
<.subtle_link href={~p"/sources/#{source.id}/#tab-downloaded"}>
<.localized_number number={source.downloaded_count} />
</.subtle_link>
</:col>
<:col :let={source} label="Size" sort_key="media_size_bytes">
<.readable_filesize byte_size={source.media_size_bytes} />
</:col>
<:col :let={source} label="Media Profile" sort_key="media_profile_name" class="truncate max-w-xs">
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
{source.media_profile.name}
</.subtle_link>
</:col>
<:col :let={source} label="Enabled?" sort_key="enabled">
<.live_component
module={PinchflatWeb.Sources.SourceLive.SourceEnableToggle}
source={source}
id={"source_#{source.id}_enabled"}
/>
</:col>
<:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
</:col>
</.table>
<section class="flex justify-center my-5">
<.live_pagination_controls page_number={@page} total_pages={@total_pages} />
</section>
</div>
@@ -0,0 +1,35 @@
defmodule PinchflatWeb.Sources.SourceLive.SourceEnableToggle do
use PinchflatWeb, :live_component
alias Pinchflat.Sources
alias Pinchflat.Sources.Source
def render(assigns) do
~H"""
<div>
<.form :let={f} for={@form} phx-change="update" phx-target={@myself} class="enabled_toggle_form">
<.input id={"source_#{@source_id}_enabled_input"} field={f[:enabled]} type="toggle" />
</.form>
</div>
"""
end
def update(assigns, socket) do
initial_data = %{
source_id: assigns.source.id,
form: Sources.change_source(%Source{}, assigns.source)
}
socket
|> assign(initial_data)
|> then(&{:ok, &1})
end
def handle_event("update", %{"source" => source_params}, %{assigns: assigns} = socket) do
assigns.source_id
|> Sources.get_source!()
|> Sources.update_source(source_params)
{:noreply, socket}
end
end
+7
View File
@@ -73,6 +73,13 @@ defmodule PinchflatWeb.Endpoint do
Phoenix.Controller.put_router_url(conn, new_base_url) Phoenix.Controller.put_router_url(conn, new_base_url)
end end
# Some podcast clients require file extensions, and others still will _add_
# file extensions to XML files if they don't have them. This plug removes
# the extension from the path so that the correct route is matched, regardless
# of the provided extension.
#
# This has the downside of in-app generated verified routes not working with
# extensions so this behaviour may change in the future.
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
defp strip_trailing_extension(conn, _opts) do defp strip_trailing_extension(conn, _opts) do
@@ -0,0 +1,45 @@
defmodule PinchflatWeb.Helpers.PaginationHelpers do
@moduledoc """
Methods for working with pagination, usually in the context of LiveViews or LiveComponents.
These methods are fairly simple, but they're commonly repeated across different Live entities
"""
alias Pinchflat.Repo
alias Pinchflat.Utils.NumberUtils
@doc """
Given a query, a page number, and a number of records per page, returns a map of pagination attributes.
Returns map()
"""
def get_pagination_attributes(query, page, records_per_page) do
total_record_count = Repo.aggregate(query, :count, :id)
total_pages = max(ceil(total_record_count / records_per_page), 1)
clamped_page = NumberUtils.clamp(page, 1, total_pages)
%{
page: clamped_page,
total_pages: total_pages,
total_record_count: total_record_count,
limit: records_per_page,
offset: (clamped_page - 1) * records_per_page
}
end
@doc """
Given a current page number, a direction to move in, and the total number of pages, returns the updated page number.
The updated page number is clamped to the range [1, total_pages].
Returns integer()
"""
def update_page_number(current_page, direction, total_pages) do
updated_page =
case to_string(direction) do
"inc" -> current_page + 1
"dec" -> current_page - 1
end
NumberUtils.clamp(updated_page, 1, total_pages)
end
end
@@ -0,0 +1,20 @@
defmodule PinchflatWeb.Helpers.SortingHelpers do
@moduledoc """
Methods for working with sorting, usually in the context of LiveViews or LiveComponents.
These methods are fairly simple, but they're commonly repeated across different Live entities
"""
@doc """
Given the old sort attribute, the new sort attribute, and the old sort direction, returns the new sort direction.
Returns :asc | :desc
"""
def get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) do
case {new_sort_attr, old_sort_direction} do
{^old_sort_attr, :desc} -> :asc
{^old_sort_attr, _} -> :desc
_ -> :asc
end
end
end
+66
View File
@@ -0,0 +1,66 @@
defmodule PinchflatWeb.Plugs do
@moduledoc """
Custom plugs for PinchflatWeb.
"""
use PinchflatWeb, :router
alias Pinchflat.Settings
@doc """
If the `expose_feed_endpoints` setting is true, this plug does nothing. Otherwise, it calls `basic_auth/2`.
"""
def maybe_basic_auth(conn, opts) do
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
conn
else
basic_auth(conn, opts)
end
end
@doc """
If the `basic_auth_username` and `basic_auth_password` settings are set, this plug calls `Plug.BasicAuth.basic_auth/3`.
"""
def basic_auth(conn, _opts) do
username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password)
if credential_set?(username) && credential_set?(password) do
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
else
conn
end
end
@doc """
Removes the `x-frame-options` header from the response to allow the page to be embedded in an iframe.
"""
def allow_iframe_embed(conn, _opts) do
delete_resp_header(conn, "x-frame-options")
end
@doc """
If the `route_token` query parameter matches the `route_token` setting, this plug does nothing.
Otherwise, it sends a 401 response.
"""
def token_protected_route(%{query_params: %{"route_token" => route_token}} = conn, _opts) do
if Settings.get!(:route_token) == route_token do
conn
else
send_unauthorized(conn)
end
end
def token_protected_route(conn, _opts) do
send_unauthorized(conn)
end
defp credential_set?(credential) do
credential && credential != ""
end
defp send_unauthorized(conn) do
conn
|> send_resp(:unauthorized, "Unauthorized")
|> halt()
end
end
+19 -41
View File
@@ -1,5 +1,6 @@
defmodule PinchflatWeb.Router do defmodule PinchflatWeb.Router do
use PinchflatWeb, :router use PinchflatWeb, :router
import PinchflatWeb.Plugs
import Phoenix.LiveDashboard.Router import Phoenix.LiveDashboard.Router
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes # IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
@@ -19,8 +20,24 @@ defmodule PinchflatWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
pipeline :feeds do scope "/", PinchflatWeb do
plug :maybe_basic_auth pipe_through [:maybe_basic_auth, :token_protected_route]
# has to match before /sources/:id
get "/sources/opml", Podcasts.PodcastController, :opml_feed
get "/sources/:foo/opml", Podcasts.PodcastController, :opml_feed
end
# Routes in here _may not be_ protected by basic auth. This is necessary for
# media streaming to work for RSS podcast feeds.
scope "/", PinchflatWeb do
pipe_through :maybe_basic_auth
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
get "/media/:uuid/episode_image", Podcasts.PodcastController, :episode_image
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
end end
scope "/", PinchflatWeb do scope "/", PinchflatWeb do
@@ -48,18 +65,6 @@ defmodule PinchflatWeb.Router do
end end
end end
# Routes in here _may not be_ protected by basic auth. This is necessary for
# media streaming to work for RSS podcast feeds.
scope "/", PinchflatWeb do
pipe_through :feeds
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
get "/media/:uuid/episode_image", Podcasts.PodcastController, :episode_image
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
end
# No auth or CSRF protection for the health check endpoint # No auth or CSRF protection for the health check endpoint
scope "/", PinchflatWeb do scope "/", PinchflatWeb do
pipe_through :api pipe_through :api
@@ -74,31 +79,4 @@ defmodule PinchflatWeb.Router do
metrics: PinchflatWeb.Telemetry, metrics: PinchflatWeb.Telemetry,
ecto_repos: [Pinchflat.Repo] ecto_repos: [Pinchflat.Repo]
end end
defp maybe_basic_auth(conn, opts) do
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
conn
else
basic_auth(conn, opts)
end
end
defp basic_auth(conn, _opts) do
username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password)
if credential_set?(username) && credential_set?(password) do
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
else
conn
end
end
defp credential_set?(credential) do
credential && credential != ""
end
defp allow_iframe_embed(conn, _opts) do
delete_resp_header(conn, "x-frame-options")
end
end end
+3 -3
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2024.10.30", version: "2024.12.31",
elixir: "~> 1.17", elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@@ -54,8 +54,8 @@ defmodule Pinchflat.MixProject do
{:ecto_sqlite3_extras, "~> 1.2.0"}, {:ecto_sqlite3_extras, "~> 1.2.0"},
{:phoenix_html, "~> 3.3"}, {:phoenix_html, "~> 3.3"},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.20.1"}, {:phoenix_live_view, "~> 1.0.0"},
{:floki, ">= 0.30.0", only: :test}, {:floki, ">= 0.36.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.2"}, {:phoenix_live_dashboard, "~> 0.8.2"},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
+6 -6
View File
@@ -1,6 +1,6 @@
%{ %{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
@@ -10,7 +10,7 @@
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"}, "credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"},
"dns_cluster": {:hex, :dns_cluster, "0.1.2", "3eb5be824c7888dadf9781018e1a5f1d3d1113b333c50bce90fb1b83df1015f2", [:mix], [], "hexpm", "7494272040f847637bbdb01bcdf4b871e82daf09b813e7d3cb3b84f112c6f2f8"}, "dns_cluster": {:hex, :dns_cluster, "0.1.2", "3eb5be824c7888dadf9781018e1a5f1d3d1113b333c50bce90fb1b83df1015f2", [:mix], [], "hexpm", "7494272040f847637bbdb01bcdf4b871e82daf09b813e7d3cb3b84f112c6f2f8"},
"ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, "ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"},
"ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
@@ -24,7 +24,7 @@
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"}, "finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"},
"floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, "floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"},
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
@@ -40,12 +40,12 @@
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
"oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"}, "oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"},
"phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.20.3", "8b6406bc0a451f295407d7acff7f234a6314be5bbe0b3f90ed82b07f50049878", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8e4385e05618b424779f894ed2df97d3c7518b7285fcd11979077ae6226466b"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
@@ -64,5 +64,5 @@
"tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"}, "tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 442 KiB

@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddPredictedMediaFilepathToMediaItems do
use Ecto.Migration
def change do
alter table(:media_items) do
add :predicted_media_filepath, :string
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddEnabledToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :enabled, :boolean, default: true, null: false
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddAudioLangToMediaProfiles do
use Ecto.Migration
def change do
alter table(:media_profiles) do
add :audio_track, :string
end
end
end
@@ -0,0 +1,11 @@
defmodule Pinchflat.Repo.Migrations.AddRouteTokenToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :route_token, :string, null: false, default: "tmp-token"
end
execute "UPDATE settings SET route_token = gen_random_uuid();", "SELECT 1;"
end
end
@@ -9,6 +9,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
setup do setup do
stub(YtDlpRunnerMock, :version, fn -> {:ok, "1"} end) stub(YtDlpRunnerMock, :version, fn -> {:ok, "1"} end)
stub(AppriseRunnerMock, :version, fn -> {:ok, "2"} end) stub(AppriseRunnerMock, :version, fn -> {:ok, "2"} end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "3", 0} end)
:ok :ok
end end
@@ -112,4 +113,16 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
assert Settings.get!(:apprise_version) assert Settings.get!(:apprise_version)
end end
end end
describe "run_app_init_script" do
test "calls the app_init user script runner" do
expect(UserScriptRunnerMock, :run, fn :app_init, data ->
assert data == %{}
{:ok, "", 0}
end)
PreJobStartupTasks.init(%{})
end
end
end end
@@ -6,7 +6,6 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Settings
alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadOptionBuilder alias Pinchflat.Downloading.DownloadOptionBuilder
@@ -253,21 +252,14 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end end
describe "build/1 when testing media quality and format options" do describe "build/1 when testing media quality and format options" do
test "includes quality options" do # There are more tests inside QualityOptionBuilderTest
resolutions = ["360", "480", "720", "1080", "2160", "4320"] # This is essenitally just testing that we implement that module correctly
Enum.each(resolutions, fn resolution ->
resolution_atom = String.to_existing_atom(resolution <> "p")
media_profile = media_profile_fixture(%{preferred_resolution: resolution_atom})
source = source_fixture(%{media_profile_id: media_profile.id})
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
test "includes video options for video profiles", %{media_item: media_item} 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:1080,+codec:avc:m4a"} in res
assert {:remux_video, "mp4"} in res assert {:remux_video, "mp4"} in res
end)
end end
test "includes quality options for audio only", %{media_item: media_item} do test "includes quality options for audio only", %{media_item: media_item} do
@@ -280,33 +272,6 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
refute {:remux_video, "mp4"} in res refute {:remux_video, "mp4"} in res
end end
test "includes custom quality options if specified", %{media_item: media_item} do
Settings.set(video_codec_preference: "av01")
Settings.set(audio_codec_preference: "aac")
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:format_sort, "res:1080,+codec:av01:aac"} in res
end
test "includes custom remux target for videos if specified", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{media_container: "mkv"})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:remux_video, "mkv"} in res
end
test "includes custom format target for audio if specified", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{media_container: "flac", preferred_resolution: :audio})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:audio_format, "flac"} in res
end
end end
describe "build/1 when testing sponsorblock options" do describe "build/1 when testing sponsorblock options" do
@@ -461,6 +426,22 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end end
end end
describe "build_quality_options_for/1" do
test "builds quality options for a media item", %{media_item: media_item} do
options = DownloadOptionBuilder.build_quality_options_for(media_item)
assert {:format_sort, "res:1080,+codec:avc:m4a"} in options
assert {:remux_video, "mp4"} in options
end
test "builds quality options for a source", %{media_item: media_item} do
options = DownloadOptionBuilder.build_quality_options_for(media_item.source)
assert {:format_sort, "res:1080,+codec:avc:m4a"} in options
assert {:remux_video, "mp4"} in options
end
end
defp update_media_profile_attribute(media_item_with_preloads, attrs) do defp update_media_profile_attribute(media_item_with_preloads, attrs) do
media_item_with_preloads.source.media_profile media_item_with_preloads.source.media_profile
|> Profiles.change_media_profile(attrs) |> Profiles.change_media_profile(attrs)
@@ -9,7 +9,12 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
_url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end) stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end) stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
@@ -52,13 +57,17 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
describe "perform/1" do describe "perform/1" do
test "it saves attributes to the media_item", %{media_item: media_item} do setup do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> stub(YtDlpRunnerMock, :run, fn
{:ok, render_metadata(:media_metadata)} _url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
end) end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end) :ok
end
test "it saves attributes to the media_item", %{media_item: media_item} do
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})
media_item = Repo.reload(media_item) media_item = Repo.reload(media_item)
@@ -67,12 +76,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
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, 1, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
assert media_item.metadata == nil assert media_item.metadata == nil
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
assert Repo.reload(media_item).metadata != nil assert Repo.reload(media_item).metadata != nil
@@ -86,7 +89,10 @@ 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, _addl -> {:error, "error"} end) expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, "error"}
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}))
@@ -96,8 +102,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "sets the job to retryable if the download failed and was retried", %{media_item: media_item} do 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 -> expect(YtDlpRunnerMock, :run, 2, fn
{:error, "Unable to communicate with SponsorBlock", 1} _url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, "Unable to communicate with SponsorBlock", 1}
end) end)
Oban.Testing.with_testing_mode(:inline, fn -> Oban.Testing.with_testing_mode(:inline, fn ->
@@ -108,8 +115,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "does not set the job to retryable if retrying wouldn't fix the issue", %{media_item: media_item} do test "does not set the job to retryable if retrying wouldn't fix the issue", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 2, fn
{:error, "Something something Video unavailable something something", 1} _url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, "Something something Video unavailable something something", 1}
end) end)
Oban.Testing.with_testing_mode(:inline, fn -> Oban.Testing.with_testing_mode(:inline, fn ->
@@ -120,13 +128,16 @@ 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, _addl -> {:error, "error", 1} end) expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _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, _addl -> :ok end) expect(YtDlpRunnerMock, :run, 0, fn _url, :download, _opts, _ot, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false}) Sources.update_source(media_item.source, %{download_media: false})
@@ -134,7 +145,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "does not download if the media item is set to not download", %{media_item: media_item} do 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) expect(YtDlpRunnerMock, :run, 0, fn _url, :download, _opts, _ot, _addl -> :ok end)
Media.update_media_item(media_item, %{prevent_download: true}) Media.update_media_item(media_item, %{prevent_download: true})
@@ -142,14 +153,19 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end 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, 1, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
FilesystemUtils.write_p!(metadata["filepath"], "test") FilesystemUtils.write_p!(metadata["filepath"], "test")
{:ok, Phoenix.json_library().encode!(metadata)} {:ok, Phoenix.json_library().encode!(metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end) _url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
media_item = Repo.reload(media_item) media_item = Repo.reload(media_item)
@@ -158,10 +174,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "does not set redownloaded_at by default", %{media_item: media_item} do test "does not set redownloaded_at by default", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
media_item = Repo.reload(media_item) media_item = Repo.reload(media_item)
@@ -173,23 +185,40 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "sets the no_force_overwrites runner option", %{media_item: media_item} do test "sets the no_force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, opts, _ot, _addl ->
assert :no_force_overwrites in opts assert :no_force_overwrites in opts
refute :force_overwrites in opts refute :force_overwrites in opts
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end) _url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
end end
end end
describe "perform/1 when testing non-downloadable media" do
test "does not retry the job if the media is currently not downloadable", %{media_item: media_item} do
stub(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
assert job.state == "completed"
end)
end
end
describe "perform/1 when testing forced downloads" do describe "perform/1 when testing forced downloads" do
test "ignores 'prevent_download' if forced", %{media_item: media_item} do test "ignores 'prevent_download' 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}) Sources.update_source(media_item.source, %{download_media: false})
Media.update_media_item(media_item, %{prevent_download: true}) Media.update_media_item(media_item, %{prevent_download: true})
@@ -197,14 +226,19 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "sets force_overwrites runner option", %{media_item: media_item} do test "sets force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, opts, _ot, _addl ->
assert :force_overwrites in opts assert :force_overwrites in opts
refute :no_force_overwrites in opts refute :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end) _url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true}) perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end end
@@ -212,12 +246,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
describe "perform/1 when testing re-downloads" do describe "perform/1 when testing re-downloads" do
test "sets redownloaded_at on the media_item", %{media_item: media_item} do test "sets redownloaded_at on the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
perform_job(MediaDownloadWorker, %{id: media_item.id, quality_upgrade?: true}) perform_job(MediaDownloadWorker, %{id: media_item.id, quality_upgrade?: true})
media_item = Repo.reload(media_item) media_item = Repo.reload(media_item)
@@ -225,28 +253,38 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "sets force_overwrites runner option", %{media_item: media_item} do test "sets force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, opts, _ot, _addl ->
assert :force_overwrites in opts assert :force_overwrites in opts
refute :no_force_overwrites in opts refute :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end) _url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true}) perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end end
test "deletes old files if the media item has been updated" do test "deletes old files if the media item has been updated" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
tmp_media_item = media_item_with_attachments() tmp_media_item = media_item_with_attachments()
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
metadata = Map.put(metadata, "filepath", tmp_media_item.media_filepath) metadata = Map.put(metadata, "filepath", tmp_media_item.media_filepath)
{:ok, Phoenix.json_library().encode!(metadata)} {:ok, Phoenix.json_library().encode!(metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end) _url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
old_media_item = media_item_with_attachments() old_media_item = media_item_with_attachments()
perform_job(MediaDownloadWorker, %{id: old_media_item.id, force: true}) perform_job(MediaDownloadWorker, %{id: old_media_item.id, force: true})
@@ -259,14 +297,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
describe "perform/1 when testing user script callbacks" do describe "perform/1 when testing user script callbacks" do
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
:ok
end
test "calls the media_pre_download user script runner", %{media_item: media_item} do test "calls the media_pre_download user script runner", %{media_item: media_item} do
expect(UserScriptRunnerMock, :run, fn :media_pre_download, data -> expect(UserScriptRunnerMock, :run, fn :media_pre_download, data ->
assert data.id == media_item.id assert data.id == media_item.id
@@ -16,14 +16,20 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
) )
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end) stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
{:ok, %{media_item: media_item}} {:ok, %{media_item: media_item}}
end end
describe "download_for_media_item/3" do describe "download_for_media_item/3" do
test "calls the backend runner", %{media_item: media_item} do test "calls the backend runner", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn url, _opts, ot, addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
url, :download, _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 [{:output_filepath, filepath} | _] = addl
@@ -32,14 +38,14 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item) assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end end
test "saves the metadata filepath to the database", %{media_item: media_item} do test "saves the metadata filepath to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
{:ok, render_metadata(:media_metadata)} _url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
_url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
end) end)
assert is_nil(media_item.metadata) assert is_nil(media_item.metadata)
@@ -49,17 +55,27 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/thumbnail.jpg" assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/thumbnail.jpg"
end end
test "errors for non-downloadable media are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
assert {:error, :unsuitable_for_download} = MediaDownloader.download_for_media_item(media_item)
end
test "non-recoverable 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, _addl -> expect(YtDlpRunnerMock, :run, 2, fn
{:error, :some_error, 1} _url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {: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 test "unknown errors are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 2, fn
{:error, :some_error} _url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, :some_error}
end) end)
assert {:error, message} = MediaDownloader.download_for_media_item(media_item) assert {:error, message} = MediaDownloader.download_for_media_item(media_item)
@@ -67,16 +83,55 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
end end
describe "download_for_media_item/3 when testing non-downloadable media" do
test "calls the download runner if the media is currently downloadable", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "was_live"})}
_url, :download, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end
test "does not call the download runner if the media is not downloadable", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
expect(YtDlpRunnerMock, :run, 0, fn _url, :download, _opts, _ot, _addl -> {:ok, ""} end)
assert {:error, :unsuitable_for_download} = MediaDownloader.download_for_media_item(media_item)
end
test "returns unexpected errors from the download status determination method", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl -> {:error, :what_tha} end)
assert {:error, "Unknown error: {:error, :what_tha}"} = MediaDownloader.download_for_media_item(media_item)
end
end
describe "download_for_media_item/3 when testing override options" do describe "download_for_media_item/3 when testing override options" do
test "includes override opts if specified", %{media_item: media_item} do test "includes override opts if specified", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, opts, _ot, _addl ->
refute :force_overwrites in opts refute :force_overwrites in opts
assert :no_force_overwrites in opts assert :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end) _url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
override_opts = [overwrite_behaviour: :no_force_overwrites] override_opts = [overwrite_behaviour: :no_force_overwrites]
@@ -86,12 +141,18 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing cookie usage" do describe "download_for_media_item/3 when testing cookie usage" do
test "sets use_cookies if the source uses cookies" do test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, "{}"}
_url, :download, _opts, _ot, addl ->
assert {:use_cookies, true} in addl assert {:use_cookies, true} in addl
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end) _url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
source = source_fixture(%{use_cookies: true}) source = source_fixture(%{use_cookies: true})
media_item = media_item_fixture(%{source_id: source.id}) media_item = media_item_fixture(%{source_id: source.id})
@@ -100,12 +161,17 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "does not set use_cookies if the source does not use cookies" do test "does not set use_cookies if the source does not use cookies" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, addl ->
assert {:use_cookies, false} in addl assert {:use_cookies, false} in addl
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end) _url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
source = source_fixture(%{use_cookies: false}) source = source_fixture(%{use_cookies: false})
media_item = media_item_fixture(%{source_id: source.id}) media_item = media_item_fixture(%{source_id: source.id})
@@ -118,7 +184,11 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock" message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
{:error, message, 1} {:error, message, 1}
end) end)
@@ -128,14 +198,19 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock" message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :download, _opts, _ot, addl ->
[{:output_filepath, filepath} | _] = addl [{:output_filepath, filepath} | _] = addl
File.write(filepath, render_metadata(:media_metadata)) File.write(filepath, render_metadata(:media_metadata))
{:error, message, 1} {:error, message, 1}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end) _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item) assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
media_item = Repo.reload(media_item) media_item = Repo.reload(media_item)
@@ -147,8 +222,10 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
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, _addl -> stub(YtDlpRunnerMock, :run, fn
{:ok, render_metadata(:media_metadata)} _url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
end) end)
:ok :ok
@@ -195,7 +272,11 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "extracts the thumbnail_filepath", %{media_item: media_item} do test "extracts the thumbnail_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
thumbnail_filepath = thumbnail_filepath =
@@ -209,6 +290,9 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
:ok = File.cp(thumbnail_filepath_fixture(), thumbnail_filepath) :ok = File.cp(thumbnail_filepath_fixture(), thumbnail_filepath)
{:ok, Phoenix.json_library().encode!(metadata)} {:ok, Phoenix.json_library().encode!(metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end) end)
assert media_item.thumbnail_filepath == nil assert media_item.thumbnail_filepath == nil
@@ -219,13 +303,19 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "extracts the metadata_filepath", %{media_item: media_item} do test "extracts the metadata_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 3, fn
metadata = render_parsed_metadata(:media_metadata) _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata)
infojson_filepath = metadata["infojson_filename"] infojson_filepath = metadata["infojson_filename"]
:ok = File.cp(infojson_filepath_fixture(), infojson_filepath) :ok = File.cp(infojson_filepath_fixture(), infojson_filepath)
{:ok, Phoenix.json_library().encode!(metadata)} {:ok, Phoenix.json_library().encode!(metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end) end)
assert media_item.metadata_filepath == nil assert media_item.metadata_filepath == nil
@@ -238,8 +328,10 @@ 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
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> stub(YtDlpRunnerMock, :run, fn
{:ok, render_metadata(:media_metadata)} _url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
end) end)
:ok :ok
@@ -0,0 +1,109 @@
defmodule Pinchflat.Downloading.QualityOptionBuilderTest do
use Pinchflat.DataCase
import Pinchflat.ProfilesFixtures
alias Pinchflat.Profiles
alias Pinchflat.Settings
alias Pinchflat.Downloading.QualityOptionBuilder
describe "build/1" do
test "includes format options if audio_track is set to original" do
media_profile = media_profile_fixture(%{audio_track: "original"})
assert res = QualityOptionBuilder.build(media_profile)
assert {:format, "bestvideo+bestaudio[format_note*=original]/bestvideo*+bestaudio/best"} in res
end
test "includes format options if audio_track is set to default" do
media_profile = media_profile_fixture(%{audio_track: "default"})
assert res = QualityOptionBuilder.build(media_profile)
assert {:format, "bestvideo+bestaudio[format_note*='(default)']/bestvideo*+bestaudio/best"} in res
end
test "includes format options if audio_track is set to a language code" do
media_profile = media_profile_fixture(%{audio_track: "en"})
assert res = QualityOptionBuilder.build(media_profile)
assert {:format, "bestvideo+bestaudio[language^=en]/bestvideo*+bestaudio/best"} in res
end
end
describe "build/1 when testing audio profiles" do
setup do
{:ok, media_profile: media_profile_fixture(%{preferred_resolution: :audio})}
end
test "includes quality options for audio only", %{media_profile: media_profile} do
assert res = QualityOptionBuilder.build(media_profile)
assert :extract_audio in res
assert {:format_sort, "+acodec:m4a"} in res
refute {:remux_video, "mp4"} in res
end
test "includes custom format target for audio if specified", %{media_profile: media_profile} do
{:ok, media_profile} =
Profiles.update_media_profile(media_profile, %{media_container: "flac", preferred_resolution: :audio})
assert res = QualityOptionBuilder.build(media_profile)
assert {:audio_format, "flac"} in res
end
test "includes custom format options", %{media_profile: media_profile} do
assert res = QualityOptionBuilder.build(media_profile)
assert {:format, "bestaudio/best"} in res
end
end
describe "build/1 when testing non-audio profiles" do
setup do
{:ok, media_profile: media_profile_fixture(%{preferred_resolution: :"480p"})}
end
test "includes quality options" do
resolutions = ["360", "480", "720", "1080", "1440", "2160", "4320"]
Enum.each(resolutions, fn resolution ->
resolution_atom = String.to_existing_atom(resolution <> "p")
media_profile = media_profile_fixture(%{preferred_resolution: resolution_atom})
assert res = QualityOptionBuilder.build(media_profile)
assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res
assert {:remux_video, "mp4"} in res
end)
end
test "includes custom quality options if specified", %{media_profile: media_profile} do
Settings.set(video_codec_preference: "av01")
Settings.set(audio_codec_preference: "aac")
{:ok, media_profile} = Profiles.update_media_profile(media_profile, %{preferred_resolution: :"1080p"})
assert res = QualityOptionBuilder.build(media_profile)
assert {:format_sort, "res:1080,+codec:av01:aac"} in res
end
test "includes custom remux target for videos if specified", %{media_profile: media_profile} do
{:ok, media_profile} = Profiles.update_media_profile(media_profile, %{media_container: "mkv"})
assert res = QualityOptionBuilder.build(media_profile)
assert {:remux_video, "mkv"} in res
end
test "includes custom format options", %{media_profile: media_profile} do
assert res = QualityOptionBuilder.build(media_profile)
assert {:format, "bestvideo*+bestaudio/best"} in res
end
end
end
@@ -1,6 +1,7 @@
defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Pinchflat.TasksFixtures
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
@@ -8,17 +9,35 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.FastIndexingHelpers alias Pinchflat.FastIndexing.FastIndexingHelpers
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, media_attributes_return_fixture()} {:ok, media_attributes_return_fixture()}
end) end)
{:ok, [source: source_fixture()]} {:ok, [source: source_fixture()]}
end end
describe "kickoff_indexing_task/1" do
test "deletes any existing fast indexing tasks", %{source: source} do
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id)
assert Repo.reload!(task)
assert {:ok, _} = FastIndexingHelpers.kickoff_indexing_task(source)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
test "kicks off a new fast indexing task", %{source: source} do
assert {:ok, _} = FastIndexingHelpers.kickoff_indexing_task(source)
assert [worker] = all_enqueued(worker: FastIndexingWorker)
assert worker.args["id"] == source.id
end
end
describe "kickoff_download_tasks_from_youtube_rss_feed/1" do describe "kickoff_download_tasks_from_youtube_rss_feed/1" do
test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do 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) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
@@ -61,10 +80,22 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker") assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker")
end end
test "passes the source's download options to the yt-dlp runner", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, opts, _ot, _addl_opts ->
assert {:output, "/tmp/test/media/%(title)S.%(ext)S"} in opts
assert {:remux_video, "mp4"} in opts
{:ok, media_attributes_return_fixture()}
end)
FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
end
test "sets use_cookies if the source uses cookies" do test "sets use_cookies if the source uses cookies" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, true} in addl assert {:use_cookies, true} in addl
{:ok, media_attributes_return_fixture()} {:ok, media_attributes_return_fixture()}
@@ -78,7 +109,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "does not set use_cookies if the source does not use cookies" do test "does not set use_cookies if the source does not use cookies" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, false} in addl assert {:use_cookies, false} in addl
{:ok, media_attributes_return_fixture()} {:ok, media_attributes_return_fixture()}
@@ -95,7 +126,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
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})
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
output = output =
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
id: "video2", id: "video2",
@@ -119,7 +150,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "does not blow up if a media item cannot be created", %{source: source} do test "does not blow up if a media item cannot be created", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
end) end)
@@ -129,7 +160,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "does not blow up if a media item causes a yt-dlp error", %{source: source} do test "does not blow up if a media item causes a yt-dlp error", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:error, "message", 1} {:error, "message", 1}
end) end)
@@ -84,7 +84,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
source = source_fixture(fast_index: true) source = source_fixture(fast_index: true)
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(AppriseRunnerMock, :run, fn servers, opts -> expect(AppriseRunnerMock, :run, fn servers, opts ->
assert "server_1" = servers assert "server_1" = servers
@@ -110,7 +113,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
source = source_fixture(fast_index: true, download_media: false) source = source_fixture(fast_index: true, download_media: false)
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end) expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
perform_job(FastIndexingWorker, %{id: source.id}) perform_job(FastIndexingWorker, %{id: source.id})
@@ -120,7 +127,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
source = source_fixture(fast_index: true, title_filter_regex: "foobar") source = source_fixture(fast_index: true, title_filter_regex: "foobar")
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end) expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
perform_job(FastIndexingWorker, %{id: source.id}) perform_job(FastIndexingWorker, %{id: source.id})
+3 -3
View File
@@ -757,7 +757,7 @@ defmodule Pinchflat.MediaTest do
end end
test "does delete the media item's metadata files" do test "does delete the media item's metadata files" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source]) media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
update_attrs = %{ update_attrs = %{
@@ -789,7 +789,7 @@ defmodule Pinchflat.MediaTest do
end end
test "deletes the media item's metadata files" do test "deletes the media item's metadata files" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source]) media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
update_attrs = %{ update_attrs = %{
@@ -875,7 +875,7 @@ defmodule Pinchflat.MediaTest do
end end
test "does not delete the media item's metadata files" do test "does not delete the media item's metadata files" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source]) media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
update_attrs = %{ update_attrs = %{
@@ -62,7 +62,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
describe "download_and_store_thumbnail_for/2" do describe "download_and_store_thumbnail_for/2" do
test "returns the filepath", %{media_item: media_item} do test "returns the filepath", %{media_item: media_item} do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""} end)
filepath = Helpers.download_and_store_thumbnail_for(media_item) filepath = Helpers.download_and_store_thumbnail_for(media_item)
@@ -70,7 +70,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end end
test "calls yt-dlp with the expected options", %{media_item: media_item} do test "calls yt-dlp with the expected options", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn url, opts, ot, _addl -> expect(YtDlpRunnerMock, :run, fn url, :download_thumbnail, 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"
@@ -89,7 +89,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end end
test "sets use_cookies if the source uses cookies" do test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl -> expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, addl ->
assert {:use_cookies, true} in addl assert {:use_cookies, true} in addl
{:ok, ""} {:ok, ""}
end) end)
@@ -101,7 +101,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end end
test "does not set use_cookies if the source does not use cookies" do test "does not set use_cookies if the source does not use cookies" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl -> expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, addl ->
assert {:use_cookies, false} in addl assert {:use_cookies, false} in addl
{:ok, ""} {:ok, ""}
end) end)
@@ -113,7 +113,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end end
test "returns nil if yt-dlp fails", %{media_item: media_item} do test "returns nil if yt-dlp fails", %{media_item: media_item} do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error"} end) stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:error, "error"} end)
filepath = Helpers.download_and_store_thumbnail_for(media_item) filepath = Helpers.download_and_store_thumbnail_for(media_item)
@@ -8,9 +8,6 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker alias Pinchflat.Metadata.SourceMetadataStorageWorker
@source_details_ot "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
@metadata_ot "playlist:%()j"
describe "kickoff_with_task/1" do describe "kickoff_with_task/1" do
test "enqueues a new worker for the source" do test "enqueues a new worker for the source" do
source = source_fixture() source = source_fixture()
@@ -32,8 +29,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1" do describe "perform/1" do
test "won't call itself in an infinite loop" do test "won't call itself in an infinite loop" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()} _url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, "{}"} _url, :get_source_metadata, _opts, _ot, _addl -> {:ok, "{}"}
end) end)
source = source_fixture() source = source_fixture()
@@ -51,8 +48,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when testing attribute updates" do describe "perform/1 when testing attribute updates" do
test "the source description is saved" do test "the source description is saved" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()} _url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)} _url, :get_source_metadata, _opts, _ot, _addl -> {:ok, render_metadata(:channel_source_metadata)}
end) end)
source = source_fixture(%{description: nil}) source = source_fixture(%{description: nil})
@@ -68,8 +65,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when testing metadata storage" do describe "perform/1 when testing metadata storage" do
test "sets metadata location for source" do test "sets metadata location for source" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()} _url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, "{}"} _url, :get_source_metadata, _opts, _ot, _addl -> {:ok, "{}"}
end) end)
source = Repo.preload(source_fixture(), :metadata) source = Repo.preload(source_fixture(), :metadata)
@@ -87,8 +84,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
file_contents = Phoenix.json_library().encode!(%{"title" => "test"}) file_contents = Phoenix.json_library().encode!(%{"title" => "test"})
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()} _url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, file_contents} _url, :get_source_metadata, _opts, _ot, _addl -> {:ok, file_contents}
end) end)
perform_job(SourceMetadataStorageWorker, %{id: source.id}) perform_job(SourceMetadataStorageWorker, %{id: source.id})
@@ -100,8 +97,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "sets metadata image location for source" do test "sets metadata image location for source" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()} _url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)} _url, :get_source_metadata, _opts, _ot, _addl -> {:ok, render_metadata(:channel_source_metadata)}
end) end)
source = source_fixture() source = source_fixture()
@@ -118,8 +115,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "stores metadata images for source" do test "stores metadata images for source" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()} _url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)} _url, :get_source_metadata, _opts, _ot, _addl -> {:ok, render_metadata(:channel_source_metadata)}
end) end)
source = source_fixture() source = source_fixture()
@@ -138,12 +135,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when testing source image downloading" do describe "perform/1 when testing source image downloading" do
test "downloads and stores source images" do test "downloads and stores source images" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"]) filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})} {:ok, source_details_return_fixture(%{filename: filename})}
_url, opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, opts, _ot, _addl ->
assert {:convert_thumbnails, "jpg"} in opts assert {:convert_thumbnails, "jpg"} in opts
{:ok, render_metadata(:channel_source_metadata)} {:ok, render_metadata(:channel_source_metadata)}
@@ -168,10 +165,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "calls one set of yt-dlp metadata opts for channels" do test "calls one set of yt-dlp metadata opts for channels" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()} {:ok, source_details_return_fixture()}
_url, opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, opts, _ot, _addl ->
assert {:playlist_items, 0} in opts assert {:playlist_items, 0} in opts
assert :write_all_thumbnails in opts assert :write_all_thumbnails in opts
@@ -186,10 +183,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "calls another set of yt-dlp metadata opts for playlists" do test "calls another set of yt-dlp metadata opts for playlists" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()} {:ok, source_details_return_fixture()}
_url, opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, opts, _ot, _addl ->
assert {:playlist_items, 1} in opts assert {:playlist_items, 1} in opts
assert :write_thumbnail in opts assert :write_thumbnail in opts
@@ -204,12 +201,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not store source images if the profile is not set to" do test "does not store source images if the profile is not set to" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"]) filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})} {:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, render_metadata(:channel_source_metadata)} {:ok, render_metadata(:channel_source_metadata)}
end) end)
@@ -226,12 +223,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not store source images if the series directory cannot be determined" do test "does not store source images if the series directory cannot be determined" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"]) filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})} {:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, render_metadata(:channel_source_metadata)} {:ok, render_metadata(:channel_source_metadata)}
end) end)
@@ -248,10 +245,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "sets use_cookies if the source uses cookies" do test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, 2, fn expect(YtDlpRunnerMock, :run, 2, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()} {:ok, source_details_return_fixture()}
_url, _opts, ot, addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, addl ->
assert {:use_cookies, true} in addl assert {:use_cookies, true} in addl
{:ok, render_metadata(:channel_source_metadata)} {:ok, render_metadata(:channel_source_metadata)}
end) end)
@@ -264,10 +261,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not set use_cookies if the source does not use cookies" do test "does not set use_cookies if the source does not use cookies" do
expect(YtDlpRunnerMock, :run, 2, fn expect(YtDlpRunnerMock, :run, 2, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()} {:ok, source_details_return_fixture()}
_url, _opts, ot, addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, addl ->
assert {:use_cookies, false} in addl assert {:use_cookies, false} in addl
{:ok, render_metadata(:channel_source_metadata)} {:ok, render_metadata(:channel_source_metadata)}
end) end)
@@ -282,12 +279,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when determining the series_directory" do describe "perform/1 when determining the series_directory" do
test "sets the series directory based on the returned media filepath" do test "sets the series directory based on the returned media filepath" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"]) filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})} {:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
end) end)
@@ -300,12 +297,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not set the series directory if it cannot be determined" do test "does not set the series directory if it cannot be determined" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"]) filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})} {:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
end) end)
@@ -318,11 +315,11 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "sets use_cookies if the source is set to use cookies" do test "sets use_cookies if the source is set to use cookies" do
expect(YtDlpRunnerMock, :run, 2, fn expect(YtDlpRunnerMock, :run, 2, fn
_url, _opts, ot, addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, addl ->
assert {:use_cookies, true} in addl assert {:use_cookies, true} in addl
{:ok, source_details_return_fixture()} {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
end) end)
@@ -332,11 +329,11 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not set use_cookies if the source is not set to use cookies" do test "does not set use_cookies if the source is not set to use cookies" do
expect(YtDlpRunnerMock, :run, 2, fn expect(YtDlpRunnerMock, :run, 2, fn
_url, _opts, ot, addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, addl ->
assert {:use_cookies, false} in addl assert {:use_cookies, false} in addl
{:ok, source_details_return_fixture()} {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
end) end)
@@ -348,12 +345,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when storing the series NFO" do describe "perform/1 when storing the series NFO" do
test "stores the NFO if specified" do test "stores the NFO if specified" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"]) filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})} {:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
end) end)
@@ -371,12 +368,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not store the NFO if not specified" do test "does not store the NFO if not specified" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"]) filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})} {:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
end) end)
@@ -390,12 +387,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not store the NFO if the series directory cannot be determined" do test "does not store the NFO if the series directory cannot be determined" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> _url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"]) filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})} {:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot -> _url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
end) end)
@@ -0,0 +1,34 @@
defmodule Pinchflat.Podcasts.OpmlFeedBuilderTest do
use Pinchflat.DataCase
import Pinchflat.SourcesFixtures
alias Pinchflat.Podcasts.OpmlFeedBuilder
setup do
source = source_fixture()
{:ok, source: source}
end
describe "build/2" do
test "returns an XML document", %{source: source} do
res = OpmlFeedBuilder.build("http://example.com", [source])
assert String.contains?(res, ~s(<?xml version="1.0" encoding="UTF-8"?>))
end
test "escapes illegal characters" do
source = source_fixture(%{custom_name: "A & B"})
res = OpmlFeedBuilder.build("http://example.com", [source])
assert String.contains?(res, ~s(A &amp; B))
end
test "build podcast link with URL base", %{source: source} do
res = OpmlFeedBuilder.build("http://example.com", [source])
assert String.contains?(res, ~s(http://example.com/sources/#{source.uuid}/feed.xml))
end
end
end
@@ -6,6 +6,16 @@ defmodule Pinchflat.Podcasts.PodcastHelpersTest do
alias Pinchflat.Podcasts.PodcastHelpers alias Pinchflat.Podcasts.PodcastHelpers
describe "opml_sources" do
test "returns sources not marked for deletion" do
source = source_fixture()
source_fixture(%{marked_for_deletion_at: DateTime.utc_now()})
assert [found_source] = PodcastHelpers.opml_sources()
assert found_source.custom_name == source.custom_name
assert found_source.uuid == source.uuid
end
end
describe "persisted_media_items_for/2" do describe "persisted_media_items_for/2" do
test "returns media items with files that exist on-disk" do test "returns media items with files that exist on-disk" do
source = source_fixture() source = source_fixture()
@@ -50,37 +50,35 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
describe "perform/1" do describe "perform/1" do
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> {:ok, ""} end)
stub(AppriseRunnerMock, :run, fn _, _ -> {:ok, ""} end) stub(AppriseRunnerMock, :run, fn _, _ -> {:ok, ""} end)
:ok :ok
end end
test "it indexes the source if it should be indexed" do test "it indexes the source if it should be indexed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end end
test "it indexes the source no matter what if the source has never been indexed before" do test "it indexes the source no matter what if the source has never been indexed before" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: nil) source = source_fixture(index_frequency_minutes: 0, last_indexed_at: nil)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end end
test "it indexes the source no matter what if the 'force' arg is passed" do test "it indexes the source no matter what if the 'force' arg is passed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now()) source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true}) perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
end end
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, 0, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, ""}
end)
source = source_fixture(index_frequency_minutes: -1, last_indexed_at: DateTime.utc_now()) source = source_fixture(index_frequency_minutes: -1, last_indexed_at: DateTime.utc_now())
@@ -88,7 +86,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it does not reschedule if the source shouldn't be indexed" do test "it does not reschedule if the source shouldn't be indexed" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: -1) source = source_fixture(index_frequency_minutes: -1)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -97,7 +95,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it kicks off a download job for each pending media item" do test "it kicks off a download job for each pending media item" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -108,7 +106,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it starts a job for any pending media item even if it's from another run" do test "it starts a job for any pending media item even if it's from another run" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -120,7 +118,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it does not kick off a job for media items that could not be saved" do test "it does not kick off a job for media items that could not be saved" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -133,8 +131,6 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it reschedules the job based on the index frequency" do test "it reschedules the job based on the index frequency" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -146,8 +142,6 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it creates a task for the rescheduled job" do test "it creates a task for the rescheduled job" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
task_count_fetcher = fn -> task_count_fetcher = fn ->
@@ -160,8 +154,6 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it creates a future task for fast indexing if appropriate" do test "it creates a future task for fast indexing if appropriate" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 10, fast_index: true) source = source_fixture(index_frequency_minutes: 10, fast_index: true)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -173,8 +165,6 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it deletes existing fast indexing tasks if a new one is created" do test "it deletes existing fast indexing tasks if a new one is created" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 10, fast_index: true) source = source_fixture(index_frequency_minutes: 10, fast_index: true)
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id})) {:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id) task = task_fixture(source_id: source.id, job_id: job.id)
@@ -185,8 +175,6 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it does not create a task for fast indexing otherwise" do test "it does not create a task for fast indexing otherwise" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 10, fast_index: false) source = source_fixture(index_frequency_minutes: 10, fast_index: false)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -194,7 +182,9 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
test "it creates the basic media_item records" do test "it creates the basic media_item records" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, source_attributes_return_fixture()} end) expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()}
end)
source = source_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
@@ -225,7 +215,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
test "sends a notification if new media was found" do test "sends a notification if new media was found" do
source = source_fixture() source = source_fixture()
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -23,6 +23,36 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id}) assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
end end
test "schedules a job for the future based on when the source was last indexed" do
source = source_fixture(index_frequency_minutes: 30, last_indexed_at: now_minus(5, :minutes))
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source)
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :minute), 25, 1
end
test "schedules a job immediately if the source was indexed far in the past" do
source = source_fixture(index_frequency_minutes: 30, last_indexed_at: now_minus(60, :minutes))
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source)
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :second), 0, 1
end
test "schedules a job immediately if the source has never been indexed" do
source = source_fixture(index_frequency_minutes: 30, last_indexed_at: nil)
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source)
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :second), 0, 1
end
test "creates and attaches a task" do test "creates and attaches a task" do
source = source_fixture(index_frequency_minutes: 1) source = source_fixture(index_frequency_minutes: 1)
@@ -92,9 +122,59 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end end
end end
describe "delete_indexing_tasks/2" do
setup do
source = source_fixture()
{:ok, %{source: source}}
end
test "deletes slow indexing tasks for the source", %{source: source} do
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
_task = task_fixture(source_id: source.id, job_id: job.id)
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
assert :ok = SlowIndexingHelpers.delete_indexing_tasks(source)
refute_enqueued(worker: MediaCollectionIndexingWorker)
end
test "deletes fast indexing tasks for the source", %{source: source} do
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
_task = task_fixture(source_id: source.id, job_id: job.id)
assert_enqueued(worker: FastIndexingWorker, args: %{"id" => source.id})
assert :ok = SlowIndexingHelpers.delete_indexing_tasks(source)
refute_enqueued(worker: FastIndexingWorker)
end
test "doesn't normally delete currently executing tasks", %{source: source} do
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id)
from(Oban.Job, where: [id: ^job.id], update: [set: [state: "executing"]])
|> Repo.update_all([])
assert Repo.reload!(task)
assert :ok = SlowIndexingHelpers.delete_indexing_tasks(source)
assert Repo.reload!(task)
end
test "can optionally delete currently executing tasks", %{source: source} do
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id)
from(Oban.Job, where: [id: ^job.id], update: [set: [state: "executing"]])
|> Repo.update_all([])
assert Repo.reload!(task)
assert :ok = SlowIndexingHelpers.delete_indexing_tasks(source, include_executing: true)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
end
describe "index_and_enqueue_download_for_media_items/1" do describe "index_and_enqueue_download_for_media_items/1" do
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -179,7 +259,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end end
test "doesn't blow up if a media item cannot be coerced into a struct", %{source: source} do test "doesn't blow up if a media item cannot be coerced into a struct", %{source: source} do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
response = response =
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
id: "video3", id: "video3",
@@ -202,8 +282,18 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert %Ecto.Changeset{} = changeset assert %Ecto.Changeset{} = changeset
end end
test "passes the source's download options to the yt-dlp runner", %{source: source} do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
assert {:output, "/tmp/test/media/%(title)S.%(ext)S"} in opts
assert {:remux_video, "mp4"} in opts
{:ok, source_attributes_return_fixture()}
end)
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
test "sets use_cookies if the source uses cookies" do test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
assert {:use_cookies, true} in addl_opts assert {:use_cookies, true} in addl_opts
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -214,7 +304,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end end
test "doesn't set use_cookies if the source doesn't use cookies" do test "doesn't set use_cookies if the source doesn't use cookies" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
assert {:use_cookies, false} in addl_opts assert {:use_cookies, false} in addl_opts
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -233,7 +323,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
test "creates a new media item for everything already in the file", %{source: source} do test "creates a new media item for everything already in the file", %{source: source} do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval) watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath) filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, source_attributes_return_fixture()) File.write(filepath, source_attributes_return_fixture())
@@ -252,7 +342,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
test "enqueues a download for everything already in the file", %{source: source} do test "enqueues a download for everything already in the file", %{source: source} do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval) watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath) filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, source_attributes_return_fixture()) File.write(filepath, source_attributes_return_fixture())
@@ -272,7 +362,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval) watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
source = source_fixture(download_media: false) source = source_fixture(download_media: false)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath) filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, source_attributes_return_fixture()) File.write(filepath, source_attributes_return_fixture())
@@ -292,7 +382,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
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})
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath) filepath = Keyword.get(addl_opts, :output_filepath)
contents = contents =
@@ -323,7 +413,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
test "does not enqueue multiple download jobs for the same media items", %{source: source} do test "does not enqueue multiple download jobs for the same media items", %{source: source} do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval) watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath) filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, source_attributes_return_fixture()) File.write(filepath, source_attributes_return_fixture())
@@ -342,7 +432,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
test "does not blow up if the file returns invalid json", %{source: source} do test "does not blow up if the file returns invalid json", %{source: source} do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval) watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath) filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, "INVALID") File.write(filepath, "INVALID")
+185 -60
View File
@@ -85,7 +85,7 @@ defmodule Pinchflat.SourcesTest do
describe "create_source/2" do describe "create_source/2" do
test "automatically sets the UUID" do test "automatically sets the UUID" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -97,7 +97,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "UUID is not writable by the user" do test "UUID is not writable by the user" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -110,7 +110,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "creates a source and adds name + ID from runner response for channels" do test "creates a source and adds name + ID from runner response for channels" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -123,7 +123,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "creates a source and adds name + ID for playlists" do test "creates a source and adds name + ID for playlists" do
expect(YtDlpRunnerMock, :run, &playlist_mock/4) expect(YtDlpRunnerMock, :run, &playlist_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -136,7 +136,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "adds an error if the runner fails" do test "adds an error if the runner fails" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "some error", 1} end) expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, _addl -> {:error, "some error", 1} end)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -148,7 +148,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "adds an error if the runner succeeds but the result was invalid JSON" do test "adds an error if the runner succeeds but the result was invalid JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, "Not JSON"} end) expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, _addl -> {:ok, "Not JSON"} end)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -160,7 +160,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "you can specify a custom custom_name" do test "you can specify a custom custom_name" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -174,7 +174,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "friendly name is pulled from collection_name if not specified" do test "friendly name is pulled from collection_name if not specified" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -187,7 +187,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "creation enforces uniqueness of collection_id scoped to the media_profile and title regex" do test "creation enforces uniqueness of collection_id scoped to the media_profile and title regex" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 2, fn _url, :get_source_details, _opts, _ot, _addl ->
{:ok, {:ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
channel: "some channel name", channel: "some channel name",
@@ -208,7 +208,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "creation lets you duplicate collection_ids and profiles as long as the regex is different" do test "creation lets you duplicate collection_ids and profiles as long as the regex is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 2, fn _url, :get_source_details, _opts, _ot, _addl ->
{:ok, {:ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
channel: "some channel name", channel: "some channel name",
@@ -232,7 +232,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "creation lets you duplicate collection_ids as long as the media profile is different" do test "creation lets you duplicate collection_ids as long as the media profile is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, 2, fn _url, :get_source_details, _opts, _ot, _addl ->
{:ok, {:ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
channel: "some channel name", channel: "some channel name",
@@ -256,8 +256,8 @@ defmodule Pinchflat.SourcesTest do
end end
test "collection_type is inferred from source details" do test "collection_type is inferred from source details" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
expect(YtDlpRunnerMock, :run, &playlist_mock/4) expect(YtDlpRunnerMock, :run, &playlist_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -276,13 +276,13 @@ defmodule Pinchflat.SourcesTest do
end end
test "creation with invalid data fails fast and does not call the runner" do test "creation with invalid data fails fast and does not call the runner" do
expect(YtDlpRunnerMock, :run, 0, &channel_mock/4) expect(YtDlpRunnerMock, :run, 0, &channel_mock/5)
assert {:error, %Ecto.Changeset{}} = Sources.create_source(@invalid_source_attrs) assert {:error, %Ecto.Changeset{}} = Sources.create_source(@invalid_source_attrs)
end end
test "creation will schedule the indexing task" do test "creation will schedule the indexing task" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -295,7 +295,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "creation schedules an index test even if the index frequency is 0" do test "creation schedules an index test even if the index frequency is 0" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -309,7 +309,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "fast_index forces the index frequency to be a default value" do test "fast_index forces the index frequency to be a default value" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -324,7 +324,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "disabling fast index will not change the index frequency" do test "disabling fast index will not change the index frequency" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -339,7 +339,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "creating will kickoff a metadata storage worker" do test "creating will kickoff a metadata storage worker" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -356,7 +356,7 @@ defmodule Pinchflat.SourcesTest do
describe "create_source/2 when testing options" do describe "create_source/2 when testing options" do
test "run_post_commit_tasks: false won't enqueue post-commit tasks" do test "run_post_commit_tasks: false won't enqueue post-commit tasks" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@@ -380,7 +380,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "updates with invalid data fails fast and does not call the runner" do test "updates with invalid data fails fast and does not call the runner" do
expect(YtDlpRunnerMock, :run, 0, &channel_mock/4) expect(YtDlpRunnerMock, :run, 0, &channel_mock/5)
source = source_fixture() source = source_fixture()
@@ -388,7 +388,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "updating the original_url will re-fetch the source details for channels" do test "updating the original_url will re-fetch the source details for channels" do
expect(YtDlpRunnerMock, :run, &channel_mock/4) expect(YtDlpRunnerMock, :run, &channel_mock/5)
source = source_fixture() source = source_fixture()
update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"} update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"}
@@ -399,7 +399,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "updating the original_url will re-fetch the source details for playlists" do test "updating the original_url will re-fetch the source details for playlists" do
expect(YtDlpRunnerMock, :run, &playlist_mock/4) expect(YtDlpRunnerMock, :run, &playlist_mock/5)
source = source_fixture() source = source_fixture()
update_attrs = %{original_url: "https://www.youtube.com/playlist?list=abc123"} update_attrs = %{original_url: "https://www.youtube.com/playlist?list=abc123"}
@@ -410,7 +410,7 @@ defmodule Pinchflat.SourcesTest do
end end
test "not updating the original_url will not re-fetch the source details" do test "not updating the original_url will not re-fetch the source details" do
expect(YtDlpRunnerMock, :run, 0, &channel_mock/4) expect(YtDlpRunnerMock, :run, 0, &channel_mock/5)
source = source_fixture() source = source_fixture()
update_attrs = %{name: "some updated name"} update_attrs = %{name: "some updated name"}
@@ -418,6 +418,100 @@ defmodule Pinchflat.SourcesTest do
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs) assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
end end
test "updates with invalid data returns error changeset" do
source = source_fixture()
assert {:error, %Ecto.Changeset{}} =
Sources.update_source(source, @invalid_source_attrs)
assert source == Sources.get_source!(source.id)
end
test "updating will kickoff a metadata storage worker if the original_url changes" do
expect(YtDlpRunnerMock, :run, &playlist_mock/5)
source = source_fixture()
update_attrs = %{original_url: "https://www.youtube.com/channel/cba321"}
assert {:ok, %Source{} = source} = Sources.update_source(source, update_attrs)
assert_enqueued(worker: SourceMetadataStorageWorker, args: %{"id" => source.id})
end
test "updating will not kickoff a metadata storage worker other attrs change" do
source = source_fixture()
update_attrs = %{name: "some new name"}
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: SourceMetadataStorageWorker)
end
end
describe "update_source/3 when testing media download tasks" do
test "enabling the download_media attribute will schedule a download task" do
source = source_fixture(download_media: false)
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
update_attrs = %{download_media: true}
refute_enqueued(worker: MediaDownloadWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
end
test "disabling the download_media attribute will cancel the download task" do
source = source_fixture(download_media: true, enabled: true)
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
update_attrs = %{download_media: false}
DownloadingHelpers.enqueue_pending_download_tasks(source)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: MediaDownloadWorker)
end
test "enabling download_media will not schedule a task if the source is disabled" do
source = source_fixture(download_media: false, enabled: false)
_media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
update_attrs = %{download_media: true}
refute_enqueued(worker: MediaDownloadWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: MediaDownloadWorker)
end
test "disabling a source will cancel any pending download tasks" do
source = source_fixture(download_media: true, enabled: true)
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
update_attrs = %{enabled: false}
DownloadingHelpers.enqueue_pending_download_tasks(source)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: MediaDownloadWorker)
end
test "enabling a source will schedule a download task if download_media is true" do
source = source_fixture(download_media: true, enabled: false)
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
update_attrs = %{enabled: true}
refute_enqueued(worker: MediaDownloadWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
end
test "enabling a source will not schedule a download task if download_media is false" do
source = source_fixture(download_media: false, enabled: false)
_media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
update_attrs = %{enabled: true}
refute_enqueued(worker: MediaDownloadWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: MediaDownloadWorker)
end
end
describe "update_source/3 when testing slow indexing" do
test "updating the index frequency to >0 will re-schedule the indexing task" do test "updating the index frequency to >0 will re-schedule the indexing task" do
source = source_fixture() source = source_fixture()
update_attrs = %{index_frequency_minutes: 123} update_attrs = %{index_frequency_minutes: 123}
@@ -462,27 +556,47 @@ defmodule Pinchflat.SourcesTest do
refute_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id}) refute_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
end end
test "enabling the download_media attribute will schedule a download task" do test "disabling a source will delete any pending tasks" do
source = source_fixture(download_media: false) source = source_fixture()
media_item = media_item_fixture(source_id: source.id, media_filepath: nil) update_attrs = %{enabled: false}
update_attrs = %{download_media: true}
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id)
refute_enqueued(worker: MediaDownloadWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs) assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
test "disabling the download_media attribute will cancel the download task" do test "updating the index frequency will not create a task if the source is disabled" do
source = source_fixture(download_media: true) source = source_fixture(enabled: false)
media_item = media_item_fixture(source_id: source.id, media_filepath: nil) update_attrs = %{index_frequency_minutes: 123}
update_attrs = %{download_media: false}
DownloadingHelpers.enqueue_pending_download_tasks(source)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id}) refute_enqueued(worker: MediaCollectionIndexingWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs) assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: MediaDownloadWorker) refute_enqueued(worker: MediaCollectionIndexingWorker)
end end
test "enabling a source will create a task if the index frequency is >0" do
source = source_fixture(enabled: false, index_frequency_minutes: 123)
update_attrs = %{enabled: true}
refute_enqueued(worker: MediaCollectionIndexingWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
end
test "enabling a source will not create a task if the index frequency is 0" do
source = source_fixture(enabled: false, index_frequency_minutes: 0)
update_attrs = %{enabled: true}
refute_enqueued(worker: MediaCollectionIndexingWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: MediaCollectionIndexingWorker)
end
end
describe "update_source/3 when testing fast indexing" do
test "enabling fast_index will schedule a fast indexing task" do test "enabling fast_index will schedule a fast indexing task" do
source = source_fixture(fast_index: false) source = source_fixture(fast_index: false)
update_attrs = %{fast_index: true} update_attrs = %{fast_index: true}
@@ -503,15 +617,6 @@ defmodule Pinchflat.SourcesTest do
refute_enqueued(worker: FastIndexingWorker) refute_enqueued(worker: FastIndexingWorker)
end end
test "updates with invalid data returns error changeset" do
source = source_fixture()
assert {:error, %Ecto.Changeset{}} =
Sources.update_source(source, @invalid_source_attrs)
assert source == Sources.get_source!(source.id)
end
test "fast_index forces the index frequency to be a default value" do test "fast_index forces the index frequency to be a default value" do
source = source_fixture(%{fast_index: true}) source = source_fixture(%{fast_index: true})
update_attrs = %{index_frequency_minutes: 0} update_attrs = %{index_frequency_minutes: 0}
@@ -530,23 +635,43 @@ defmodule Pinchflat.SourcesTest do
assert source.index_frequency_minutes == 0 assert source.index_frequency_minutes == 0
end end
test "updating will kickoff a metadata storage worker if the original_url changes" do test "disabling a source will delete any pending tasks" do
expect(YtDlpRunnerMock, :run, &playlist_mock/4)
source = source_fixture() source = source_fixture()
update_attrs = %{original_url: "https://www.youtube.com/channel/cba321"} update_attrs = %{enabled: false}
assert {:ok, %Source{} = source} = Sources.update_source(source, update_attrs) {:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id)
assert_enqueued(worker: SourceMetadataStorageWorker, args: %{"id" => source.id})
end
test "updating will not kickoff a metadata storage worker other attrs change" do
source = source_fixture()
update_attrs = %{name: "some new name"}
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs) assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: SourceMetadataStorageWorker) assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
test "updating fast indexing will not create a task if the source is disabled" do
source = source_fixture(enabled: false, fast_index: false)
update_attrs = %{fast_index: true}
refute_enqueued(worker: FastIndexingWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: FastIndexingWorker)
end
test "enabling a source will create a task if fast_index is true" do
source = source_fixture(enabled: false, fast_index: true)
update_attrs = %{enabled: true}
refute_enqueued(worker: FastIndexingWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
assert_enqueued(worker: FastIndexingWorker, args: %{"id" => source.id})
end
test "enabling a source will not create a task if fast_index is false" do
source = source_fixture(enabled: false, fast_index: false)
update_attrs = %{enabled: true}
refute_enqueued(worker: FastIndexingWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: FastIndexingWorker)
end end
end end
@@ -776,7 +901,7 @@ defmodule Pinchflat.SourcesTest do
end end
end end
defp playlist_mock(_url, _opts, _ot, _addl) do defp playlist_mock(_url, :get_source_details, _opts, _ot, _addl) do
{ {
:ok, :ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
@@ -788,7 +913,7 @@ defmodule Pinchflat.SourcesTest do
} }
end end
defp channel_mock(_url, _opts, _ot, _addl) do defp channel_mock(_url, :get_source_details, _opts, _ot, _addl) do
channel_id = "some_channel_id_#{:rand.uniform(1_000_000)}" channel_id = "some_channel_id_#{:rand.uniform(1_000_000)}"
{ {
+1 -1
View File
@@ -247,7 +247,7 @@ defmodule Pinchflat.TasksTest do
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
test "deletion can optionall include executing tasks" do test "deletion can optionally include executing tasks" do
source = source_fixture() source = source_fixture()
task = task_fixture(source_id: source.id) task = task_fixture(source_id: source.id)
+3 -11
View File
@@ -28,17 +28,9 @@ defmodule Pinchflat.Utils.StringUtilsTest do
end end
end end
describe "truncate/2" do describe "double_brace/1" do
test "truncates a string to the given length and adds ..." do test "wraps a string in double braces" do
assert StringUtils.truncate("hello world", 5) == "hello..." assert StringUtils.double_brace("hello") == "{{ hello }}"
end
test "breaks on a word boundary" do
assert StringUtils.truncate("hello world", 7) == "hello..."
end
test "does not truncate a string shorter than the given length" do
assert StringUtils.truncate("hello", 10) == "hello"
end end
end end
end end

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