Compare commits

..

30 Commits

Author SHA1 Message Date
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
Kieran Eglin 8c0dd0bb6b Bumped version 2024-10-30 14:13:08 -07:00
Kieran Eglin 7a374f1162 Updated README 2024-10-30 14:12:29 -07:00
Kieran a5afcf168b Went back to using original_url over webpage_url to improve short detection (#447) 2024-10-30 14:09:28 -07:00
Kieran Eglin 2f83fe03d2 Updated default tzdata location to prevent needless pulls every time I push a new version 2024-10-30 10:18:03 -07:00
Kieran Eglin 3c10d78e78 Updated timezone logic to prevent boot crash 2024-10-30 10:14:04 -07:00
Kieran 8c0df65c0c Took the easy way out and removed playlist index explicitly from the update method (#442) 2024-10-28 10:26:31 -07:00
Kieran 47bb1cb98d Updated timezone logic to not prevent booting (#441) 2024-10-28 09:15:45 -07:00
Kieran Eglin 20547e60e4 Bump version 2024-10-25 11:42:16 -07:00
Kieran 0fd3106cb8 Removed root pw workaround (#437) 2024-10-25 11:17:16 -07:00
Kieran 108a141c65 Improve error messages when adding a non-valid source URL (#436) 2024-10-25 11:01:46 -07:00
Kieran 6a1b7b0160 [Enhancement] Misc. UI updates (#434)
* Format 'Scheduled At' in task table according to user's TZ

* Changed video preview layout to 1 column on all displays

* Made links in descriptions clickable

* Updated Copy JSON button to properly escape newline characters
2024-10-24 12:47:48 -07:00
Kieran 3c8d99196a [Bugfix] Fix off-by-1 error for retention date logic (#432)
* Added a sanity check test to the media context

* Improves logic for handing media item culling dates
2024-10-24 10:26:30 -07:00
Jonas Bergler cae86953a0 document that BASE_ROUTE_PATH requires the prefix to be stripped (#413) 2024-10-22 12:24:34 -07:00
FibreTTP f661747a0c Flip settings submenu chevron (#405) 2024-10-10 14:19:03 -07:00
Kieran Eglin 85feb67299 Bumped version 2024-10-02 14:44:47 -07:00
Kieran 4843d7d6c2 Updated tzinfo package (#402) 2024-10-02 14:38:14 -07:00
Kieran a0b9e49486 [Enhancement] Added ability to detect when files have been deleted (#399)
* Added function for updating a media item's filepaths on-disk

* Added placeholder action to source page

* Turned the file sync into a job and properly hooked it up to the controller
2024-09-26 12:39:31 -07:00
Kieran 0163e85e76 [Bugfix] prevent duplicate videos from being downloaded if the video's name changes (#396)
* Added methods for deleting outdated files

* Hooked up outdated file deletion to media download worker
2024-09-23 15:59:48 -07:00
104 changed files with 2078 additions and 480 deletions
+5 -8
View File
@@ -53,7 +53,7 @@ If it doesn't work for your use case, please make a feature request! You can als
- Self-contained - just one Docker container with no external dependencies
- Powerful naming system so content is stored where and how you want it
- Easy-to-use web interface with presets to get you started right away
- First-class support for media center apps like Plex, Jellyfin, and Kodi
- First-class support for media center apps like Plex, Jellyfin, and Kodi ([docs](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#how-do-i-get-media-into-plexjellyfinkodi))
- Supports serving RSS feeds to your favourite podcast app ([docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds))
- Automatically downloads new content from channels and playlists
- Uses a novel approach to download new content more quickly than other apps
@@ -63,7 +63,7 @@ If it doesn't work for your use case, please make a feature request! You can als
- Allows automatically redownloading new media after a set period
- This can help improve the download quality of new content or improve SponsorBlock tags
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
- Advanced options like setting cutoff dates and filtering by title
- Advanced options like setting cutoff dates and filtering by title ([docs](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#i-only-want-certain-videos-from-a-source---how-can-i-only-download-those))
- Reliable hands-off operation
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
- Sponsorblock integration
@@ -131,9 +131,6 @@ You _must_ ensure the host directories you've mounted are writable by the user r
> [!IMPORTANT]
> It's not recommended to run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media.
> [!TIP]
> If you need to run any command as root, you can run `su` from the container's shell as there is no password set for the root user.
### ADVANCED: Storing Pinchflat config directory on a network share
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
@@ -155,7 +152,7 @@ If you change this setting and it works well for you, please leave a comment on
| `ENABLE_IPV6` | No | `false` | Setting to _any_ non-blank value will enable IPv6 |
| `JOURNAL_MODE` | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) |
| `TZ_DATA_DIR` | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
| `BASE_ROUTE_PATH` | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies |
| `BASE_ROUTE_PATH` | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies, but prefix must be stripped. |
| `YT_DLP_WORKER_CONCURRENCY` | No | `2` | The number of concurrent workers that use `yt-dlp` _per queue_. Set to 1 if you're getting IP limited, otherwise don't touch it |
## EFF donations
@@ -164,9 +161,9 @@ Prior to 2024-05-10, a portion of all donations were given to the [Electronic Fr
The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down.
## Pre-release disclaimer
## Stability disclaimer
This is pre-release software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges for now.
This software is in active development and anything can break at any time. I make no guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat).
## License
+7
View File
@@ -35,3 +35,10 @@ window.markVersionAsSeen = (versionString) => {
window.isVersionSeen = (versionString) => {
return localStorage.getItem('seenVersion') === versionString
}
window.dispatchFor = (elementOrId, eventName, detail = {}) => {
const element =
typeof elementOrId === 'string' ? document.getElementById(elementOrId) : elementOrId
element.dispatchEvent(new CustomEvent(eventName, { detail }))
}
+24 -1
View File
@@ -39,7 +39,7 @@ let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
}
},
hooks: {
supressEnterSubmission: {
'supress-enter-submission': {
mounted() {
this.el.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
@@ -47,6 +47,29 @@ let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
}
})
}
},
'formless-input': {
mounted() {
const subscribedEvents = this.el.dataset.subscribe.split(' ')
const eventName = this.el.dataset.eventName || ''
const identifier = this.el.dataset.identifier || ''
subscribedEvents.forEach((domEvent) => {
this.el.addEventListener(domEvent, () => {
// This ensures that the event is pushed to the server after the input value has been updated
// so that the server has the most up-to-date value
setTimeout(() => {
this.pushEvent('formless-input', {
value: this.el.value,
id: identifier,
event: eventName,
dom_id: this.el.id,
dom_event: domEvent
})
}, 0)
})
})
}
}
}
})
+11 -5
View File
@@ -55,11 +55,16 @@ config :pinchflat, Oban,
]
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"]))
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"]))
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
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
# For testing alternate journal modes (see issue #137)
@@ -73,17 +78,18 @@ if config_env() == :prod do
config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"),
apprise_executable: System.find_executable("apprise"),
media_directory: "/downloads",
media_directory: media_path,
metadata_directory: metadata_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"),
expose_feed_endpoints: expose_feed_endpoints,
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC",
# This is configured in application.ex
timezone: "UTC",
log_path: log_path,
base_route_path: base_route_path
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
config :tzdata, :data_dir, tz_data_path
config :pinchflat, Pinchflat.Repo,
database: db_path,
+3 -12
View File
@@ -88,6 +88,7 @@ RUN apt-get update -y && \
ca-certificates \
python3-mutagen \
curl \
zip \
openssh-client \
nano \
python3 \
@@ -116,7 +117,8 @@ ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
# 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
ENV MIX_ENV="prod"
@@ -127,17 +129,6 @@ EXPOSE ${PORT}
# Only copy the final release from the build stage
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
# NEVER do this if you're running in an environment where you don't trust the user
# (ie: most environments). This is only acceptable in a self-hosted environment.
# The user could just run the whole container as root and bypass this anyway so
# it's not a huge deal.
# This removes the root password to allow users to assume root if needed. This is
# preferrable to running the whole container as root so that the files/directories
# created by the app aren't owned by root and are therefore easier for other users
# and processes to interact with. If you want to just run the whole container as
# root, use --user 0:0 or something.
RUN passwd -d root
HEALTHCHECK --interval=120s --start-period=10s \
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
+19
View File
@@ -4,9 +4,12 @@ defmodule Pinchflat.Application do
@moduledoc false
use Application
require Logger
@impl true
def start(_type, _args) do
check_and_update_timezone()
children = [
PinchflatWeb.Telemetry,
Pinchflat.Repo,
@@ -47,4 +50,20 @@ defmodule Pinchflat.Application do
:ok = Oban.Telemetry.attach_default_logger()
:telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, [])
end
# This has to be here (rather than runtime.exs) since the `tzdata` application
# has to be started before we can check the timezone
defp check_and_update_timezone do
attempted_timezone = System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC"
valid_timezone =
if Tzdata.zone_exists?(attempted_timezone) do
attempted_timezone
else
Logger.warning("Invalid timezone #{attempted_timezone}, defaulting to UTC")
"UTC"
end
Application.put_env(:pinchflat, :timezone, valid_timezone)
end
end
@@ -16,6 +16,8 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
alias Pinchflat.Settings
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, opts)
end
@@ -36,6 +38,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
create_blank_yt_dlp_files()
create_blank_user_script_file()
apply_default_settings()
run_app_init_script()
{:ok, state}
end
@@ -95,6 +98,12 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
Settings.set(apprise_version: apprise_version)
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
Application.get_env(:pinchflat, :yt_dlp_runner)
end
@@ -4,10 +4,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
"""
alias Pinchflat.Sources
alias Pinchflat.Settings
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.OutputPathBuilder
alias Pinchflat.Downloading.QualityOptionBuilder
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
@@ -34,21 +34,38 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
@doc """
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
will use a default %MediaItem{} struct with the given source.
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
output_path_template = Sources.output_path_template(media_item_with_preloads.source)
build_output_path(output_path_template, media_item_with_preloads)
end
def build_output_path_for(%Source{} = source_with_preloads) do
build_output_path_for(%MediaItem{source: source_with_preloads})
@doc """
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
defp default_options(override_opts) do
@@ -125,27 +142,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end
defp quality_options(media_profile) do
vcodec = Settings.get!(:video_codec_preference)
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
QualityOptionBuilder.build(media_profile)
end
defp sponsorblock_options(media_profile) do
@@ -12,6 +12,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
alias Pinchflat.Tasks
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.FileSyncing
alias Pinchflat.Downloading.MediaDownloader
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
@@ -85,6 +86,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
})
:ok = FileSyncing.delete_outdated_files(media_item, updated_media_item)
run_user_script(:media_downloaded, updated_media_item)
:ok
@@ -92,6 +94,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
{:recovered, _} ->
{:error, :retry}
{:error, :unsuitable_for_download} ->
{:ok, :non_retry}
{:error, message} ->
action_on_error(message)
end
+12 -1
View File
@@ -37,6 +37,13 @@ defmodule Pinchflat.Downloading.MediaDownloader do
{:ok, 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} ->
Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
@@ -108,7 +115,11 @@ defmodule Pinchflat.Downloading.MediaDownloader do
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
runner_opts = [output_filepath: output_filepath, use_cookies: item_with_preloads.source.use_cookies]
YtDlpMedia.download(url, options, runner_opts)
case YtDlpMedia.get_downloadable_status(url) do
{:ok, :downloadable} -> YtDlpMedia.download(url, options, runner_opts)
{:ok, :ignorable} -> {:error, :unsuitable_for_download}
err -> err
end
end
defp recoverable_errors do
@@ -49,6 +49,8 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
end)
end
# NOTE: Since this is a date and not a datetime, we can't add logic to have to-the-minute
# comparison like we can with retention periods. We can only compare to the day.
defp delete_media_items_from_before_cutoff do
deletable_media =
MediaQuery.new()
@@ -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.Media
alias Pinchflat.Tasks
alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.FastIndexing.YoutubeApi
alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.Downloading.DownloadOptionBuilder
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 """
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
@@ -27,6 +41,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
downloaded_.
"""
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)
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)
@@ -68,7 +86,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
defp create_media_item_from_media_id(source, media_id) do
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} ->
Media.create_media_item_from_backend_attrs(source, media_attrs)
@@ -12,6 +12,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
@behaviour UserScriptCommandRunner
@event_types [
:app_init,
:media_pre_download,
:media_downloaded,
:media_deleted
+93
View File
@@ -0,0 +1,93 @@
defmodule Pinchflat.Media.FileSyncing do
@moduledoc """
Functions for ensuring file state is accurately reflected in the database.
"""
alias Pinchflat.Media
alias Pinchflat.Utils.MapUtils
alias Pinchflat.Media.MediaItem
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
@doc """
Deletes files that are no longer needed by a media item.
This means that if a media item has been updated, the old and new versions
can be passed and any files that are no longer needed will be deleted.
An example is a video that gets its quality upgraded and its name changes
between original download and re-download. The old file will exist on-disk
with the old name but the database entry will point to the new file. This
function can be used to delete the old file in this case.
Returns :ok
"""
def delete_outdated_files(old_media_item, new_media_item) do
non_subtitle_keys = MediaItem.filepath_attributes() -- [:subtitle_filepaths]
old_non_subtitles = Map.take(old_media_item, non_subtitle_keys)
old_subtitles = MapUtils.from_nested_list(old_media_item.subtitle_filepaths)
new_non_subtitles = Map.take(new_media_item, non_subtitle_keys)
new_subtitles = MapUtils.from_nested_list(new_media_item.subtitle_filepaths)
handle_file_deletion(old_non_subtitles, new_non_subtitles)
handle_file_deletion(old_subtitles, new_subtitles)
:ok
end
@doc """
Nillifies any media item filepaths that don't exist on disk for a list of media items
returns [%MediaItem{}]
"""
def sync_file_presence_on_disk(media_items) do
Enum.map(media_items, fn media_item ->
new_attributes = sync_media_item_files(media_item)
# Doing this one-by-one instead of batching since this process
# can take time and a batch could let MediaItem state get out of sync
{:ok, updated_media_item} = Media.update_media_item(media_item, new_attributes)
updated_media_item
end)
end
defp handle_file_deletion(old_attributes, new_attributes) do
# The logic:
# - A file should only be deleted if it exists and the new file is different
# - The new attributes are the ones we're interested in keeping
# - If the old attributes have a key that doesn't exist in the new attributes, don't touch it.
# This is good for archiving but may be unpopular for other users so this may change.
Enum.each(new_attributes, fn {key, new_filepath} ->
old_filepath = Map.get(old_attributes, key)
files_have_changed = old_filepath && new_filepath && old_filepath != new_filepath
files_exist_on_disk = files_have_changed && File.exists?(old_filepath) && File.exists?(new_filepath)
if files_exist_on_disk && !FSUtils.filepaths_reference_same_file?(old_filepath, new_filepath) do
FSUtils.delete_file_and_remove_empty_directories(old_filepath)
end
end)
end
defp sync_media_item_files(media_item) do
non_subtitle_keys = MediaItem.filepath_attributes() -- [:subtitle_filepaths]
subtitle_keys = MapUtils.from_nested_list(media_item.subtitle_filepaths)
non_subtitles = Map.take(media_item, non_subtitle_keys)
# This one is checking for the negative (ie: only update if the file doesn't exist)
new_non_subtitle_attrs =
Enum.reduce(non_subtitles, %{}, fn {key, filepath}, acc ->
if filepath && File.exists?(filepath), do: acc, else: Map.put(acc, key, nil)
end)
# This one is checking for the positive (ie: only update if the file exists)
# This is because subtitles, being an array type in the DB, are most easily updated
# by a full replacement rather than finding the actual diff
new_subtitle_attrs =
Enum.reduce(subtitle_keys, [], fn {key, filepath}, acc ->
if filepath && File.exists?(filepath), do: acc ++ [[key, filepath]], else: acc
end)
Map.put(new_non_subtitle_attrs, :subtitle_filepaths, new_subtitle_attrs)
end
end
@@ -0,0 +1,38 @@
defmodule Pinchflat.Media.FileSyncingWorker do
@moduledoc false
use Oban.Worker,
queue: :local_data,
tags: ["sources", "local_data"]
alias __MODULE__
alias Pinchflat.Repo
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.Media.FileSyncing
@doc """
Starts the source file syncing worker.
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
"""
def kickoff_with_task(source, opts \\ []) do
%{id: source.id}
|> FileSyncingWorker.new(opts)
|> Tasks.create_job_with_task(source)
end
@doc """
Deletes a profile and optionally deletes its files
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => source_id}}) do
source = Repo.preload(Sources.get_source!(source_id), :media_items)
FileSyncing.sync_file_presence_on_disk(source.media_items)
:ok
end
end
+7 -4
View File
@@ -15,6 +15,9 @@ defmodule Pinchflat.Media do
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
# Some fields should only be set on insert and not on update.
@fields_to_drop_on_update [:playlist_index]
@doc """
Returns the list of media_items.
@@ -131,8 +134,6 @@ defmodule Pinchflat.Media do
"""
def create_media_item_from_backend_attrs(source, media_attrs_struct) do
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
# Some fields should only be set on insert and not on update.
fields_to_drop_on_update = [:playlist_index]
%MediaItem{}
|> MediaItem.changeset(attrs)
@@ -140,7 +141,7 @@ defmodule Pinchflat.Media do
on_conflict: [
set:
attrs
|> Map.drop(fields_to_drop_on_update)
|> Map.drop(@fields_to_drop_on_update)
|> Map.to_list()
],
conflict_target: [:source_id, :media_id]
@@ -153,8 +154,10 @@ defmodule Pinchflat.Media do
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
"""
def update_media_item(%MediaItem{} = media_item, attrs) do
update_attrs = Map.drop(attrs, @fields_to_drop_on_update)
media_item
|> MediaItem.changeset(attrs)
|> MediaItem.changeset(update_attrs)
|> Repo.update()
end
+2
View File
@@ -31,6 +31,7 @@ defmodule Pinchflat.Media.MediaItem do
:uploaded_at,
:upload_date_index,
:duration_seconds,
:predicted_media_filepath,
# these fields are captured only on download
:media_downloaded_at,
:media_filepath,
@@ -76,6 +77,7 @@ defmodule Pinchflat.Media.MediaItem do
field :duration_seconds, :integer
field :playlist_index, :integer, default: 0
field :predicted_media_filepath, :string
field :media_filepath, :string
field :media_size_bytes, :integer
field :thumbnail_filepath, :string
+3 -3
View File
@@ -88,7 +88,7 @@ defmodule Pinchflat.Media.MediaQuery do
[mi, source],
fragment("""
IFNULL(retention_period_days, 0) > 0 AND
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
DATETIME(media_downloaded_at, '+' || retention_period_days || ' day') < DATETIME('now')
""")
)
end
@@ -100,8 +100,8 @@ defmodule Pinchflat.Media.MediaQuery do
# downloaded_at minus the redownload_delay_days is before the upload date
fragment("""
IFNULL(redownload_delay_days, 0) > 0 AND
DATETIME('now', '-' || redownload_delay_days || ' day') > uploaded_at AND
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < uploaded_at
DATE('now', '-' || redownload_delay_days || ' day') > DATE(uploaded_at) AND
DATE(media_downloaded_at, '-' || redownload_delay_days || ' day') < DATE(uploaded_at)
""")
)
end
+3 -1
View File
@@ -26,6 +26,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
sponsorblock_categories
shorts_behaviour
livestream_behaviour
audio_track
preferred_resolution
media_container
redownload_delay_days
@@ -65,7 +66,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
# See `build_format_clauses` in the Media context for more.
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 :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 :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",
"/downloads",
"/etc/yt-dlp",
"/etc/yt-dlp/plugins",
Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :extras_directory),
@@ -16,6 +16,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.FileFollowerServer
alias Pinchflat.Downloading.DownloadOptionBuilder
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
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
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
job_offset_seconds = calculate_job_offset_seconds(source)
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
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
@doc """
@@ -56,6 +72,9 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
Returns [%MediaItem{} | %Ecto.Changeset{}]
"""
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
# (important reading if you're not familiar with it)
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
@@ -94,8 +113,13 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
{:ok, pid} = FileFollowerServer.start_link()
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]
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)
@@ -132,4 +156,14 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
changeset
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
+2
View File
@@ -15,6 +15,7 @@ defmodule Pinchflat.Sources.Source do
alias Pinchflat.Metadata.SourceMetadata
@allowed_fields ~w(
enabled
collection_name
collection_id
collection_type
@@ -64,6 +65,7 @@ defmodule Pinchflat.Sources.Source do
)a
schema "sources" do
field :enabled, :boolean, default: true
# 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
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
+66 -18
View File
@@ -15,8 +15,8 @@ defmodule Pinchflat.Sources do
alias Pinchflat.Metadata.SourceMetadata
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.FastIndexing.FastIndexingHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker
@doc """
@@ -186,7 +186,13 @@ defmodule Pinchflat.Sources do
{:ok, source_details} ->
add_source_details_by_collection_type(source, changeset, source_details)
{:error, runner_error, _status_code} ->
err ->
runner_error =
case err do
{:error, error_msg, _status_code} -> error_msg
{:error, error_msg} -> error_msg
end
Ecto.Changeset.add_error(
changeset,
:original_url,
@@ -249,19 +255,40 @@ defmodule Pinchflat.Sources do
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.
defp maybe_handle_media_tasks(changeset, source) do
case {changeset.data, changeset.changes} do
{%{__meta__: %{state: :loaded}}, %{download_media: true}} ->
current_changes = changeset.changes
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)
{%{__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)
_ ->
:ok
nil
end
:ok
end
defp maybe_run_indexing_task(changeset, source) do
@@ -295,13 +322,22 @@ defmodule Pinchflat.Sources do
end
defp maybe_update_slow_indexing_task(changeset, source) do
case changeset.changes do
%{index_frequency_minutes: mins} when mins > 0 ->
# See comment in `maybe_handle_media_tasks` as to why we need these
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)
%{index_frequency_minutes: _} ->
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
{%{enabled: true}, %{index_frequency_minutes: mins}} when mins > 0 ->
SlowIndexingHelpers.kickoff_indexing_task(source)
{%{index_frequency_minutes: _}, _} ->
SlowIndexingHelpers.delete_indexing_tasks(source, include_executing: true)
{%{enabled: false}, _} ->
SlowIndexingHelpers.delete_indexing_tasks(source, include_executing: true)
_ ->
:ok
@@ -309,13 +345,25 @@ defmodule Pinchflat.Sources do
end
defp maybe_update_fast_indexing_task(changeset, source) do
case changeset.changes do
%{fast_index: true} ->
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
FastIndexingWorker.kickoff_with_task(source)
# See comment in `maybe_handle_media_tasks` as to why we need these
current_changes = changeset.changes
applied_changes = Ecto.Changeset.apply_changes(changeset)
%{fast_index: false} ->
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
# This technically could be simplified since `maybe_update_slow_indexing_task`
# 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
+18
View File
@@ -20,6 +20,24 @@ defmodule Pinchflat.Utils.FilesystemUtils do
end
end
@doc """
Checks if two filepaths reference the same file.
Useful if you have a relative and absolute filepath and want to be sure they're the same file.
Also works with symlinks.
Returns boolean()
"""
def filepaths_reference_same_file?(filepath_1, filepath_2) do
{:ok, stat_1} = File.stat(filepath_1)
{:ok, stat_2} = File.stat(filepath_2)
identifier_1 = "#{stat_1.major_device}:#{stat_1.minor_device}:#{stat_1.inode}"
identifier_2 = "#{stat_2.major_device}:#{stat_2.minor_device}:#{stat_2.inode}"
identifier_1 == identifier_2
end
@doc """
Generates a temporary file and returns its path. The file is empty and has the given type.
Generates all the directories in the path if they don't exist.
+17
View File
@@ -0,0 +1,17 @@
defmodule Pinchflat.Utils.MapUtils do
@moduledoc """
Utility methods for working with maps
"""
@doc """
Converts a nested list of 2-element tuples or lists into a map.
Returns map()
"""
def from_nested_list(list) do
Enum.reduce(list, %{}, fn
[key, value], acc -> Map.put(acc, key, value)
{key, value}, acc -> Map.put(acc, key, value)
end)
end
end
+11
View File
@@ -41,4 +41,15 @@ defmodule Pinchflat.Utils.StringUtils do
string
end
end
@doc """
Wraps a string in double braces. Useful as a UI helper now that
LiveView 1.0.0 allows `{}` for interpolation so now we can't use braces
directly in the view.
Returns binary()
"""
def double_brace(string) do
"{{ #{string} }}"
end
end
+45 -11
View File
@@ -11,7 +11,8 @@ defmodule Pinchflat.YtDlp.Media do
:livestream,
:short_form_content,
:uploaded_at,
:duration_seconds
:duration_seconds,
:predicted_media_filepath
]
defstruct [
@@ -23,7 +24,8 @@ defmodule Pinchflat.YtDlp.Media do
:short_form_content,
:uploaded_at,
:duration_seconds,
:playlist_index
:playlist_index,
:predicted_media_filepath
]
alias __MODULE__
@@ -47,6 +49,24 @@ defmodule Pinchflat.YtDlp.Media do
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) do
case backend_runner().run(url, [:simulate, :skip_download], "%(.{live_status})j") do
{:ok, output} ->
output
|> Phoenix.json_library().decode!()
|> parse_downloadable_status()
err ->
err
end
end
@doc """
Downloads a thumbnail for a single piece of media. Usually used for
downloading thumbnails for internal use
@@ -63,15 +83,16 @@ defmodule Pinchflat.YtDlp.Media do
@doc """
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, ...}.
"""
def get_media_attributes(url, addl_opts \\ []) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
command_opts = [:simulate, :skip_download]
def get_media_attributes(url, command_opts \\ [], addl_opts \\ []) do
all_command_opts = [:simulate, :skip_download] ++ command_opts
output_template = indexing_output_template()
case runner.run(url, command_opts, output_template, addl_opts) do
case backend_runner().run(url, all_command_opts, output_template, addl_opts) do
{:ok, output} ->
output
|> Phoenix.json_library().decode!()
@@ -87,9 +108,11 @@ defmodule Pinchflat.YtDlp.Media do
Returns the output template for yt-dlp's indexing command.
NOTE: playlist_index is really only useful for playlists that will never change their order.
NOTE: I've switched back to `original_url` (from `webpage_url`) since it's started indicating
if something is a short via the URL again
"""
def indexing_output_template do
"%(.{id,title,live_status,webpage_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
@doc """
@@ -103,17 +126,18 @@ defmodule Pinchflat.YtDlp.Media do
media_id: response["id"],
title: response["title"],
description: response["description"],
original_url: response["webpage_url"],
original_url: response["original_url"],
livestream: !!response["live_status"] && response["live_status"] != "not_live",
duration_seconds: response["duration"] && round(response["duration"]),
short_form_content: response["webpage_url"] && short_form_content?(response),
short_form_content: response["original_url"] && short_form_content?(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
defp short_form_content?(response) do
if String.contains?(response["webpage_url"], "/shorts/") do
if String.contains?(response["original_url"], "/shorts/") do
true
else
# Sometimes shorts are returned without /shorts/ in the URL,
@@ -140,6 +164,16 @@ defmodule Pinchflat.YtDlp.Media do
defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
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
# This approach lets us mock the command for testing
Application.get_env(:pinchflat, :yt_dlp_runner)
+8 -4
View File
@@ -11,20 +11,23 @@ defmodule Pinchflat.YtDlp.MediaCollection do
@doc """
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 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.
- :use_cookies - whether or not to use user-provided cookies when fetching the media details
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)
# `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
# 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)
output_template = YtDlpMedia.indexing_output_template()
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
@@ -35,7 +38,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
file_listener_handler.(output_filepath)
end
case runner.run(url, command_opts, output_template, runner_opts) do
case runner.run(url, all_command_opts, output_template, runner_opts) do
{:ok, output} ->
parsed_lines =
output
@@ -84,6 +87,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, format_source_details(parsed_json)}
else
{:error, %Jason.DecodeError{}} -> {:error, "Error decoding JSON response"}
err -> err
end
end
+1
View File
@@ -100,6 +100,7 @@ defmodule PinchflatWeb do
import PinchflatWeb.CustomComponents.TextComponents
import PinchflatWeb.CustomComponents.TableComponents
import PinchflatWeb.CustomComponents.ButtonComponents
import Pinchflat.Utils.StringUtils, only: [double_brace: 1]
alias Pinchflat.Settings
alias Pinchflat.Utils.StringUtils
+51 -50
View File
@@ -82,7 +82,7 @@ defmodule PinchflatWeb.CoreComponents do
</button>
</div>
<div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</div>
</div>
</div>
@@ -126,9 +126,9 @@ defmodule PinchflatWeb.CoreComponents do
]}>
<main>
<h5 :if={@title} class="mb-2 text-lg font-bold">
<%= @title %>
{@title}
</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>
<button
type="button"
@@ -208,9 +208,9 @@ defmodule PinchflatWeb.CoreComponents do
def simple_form(assigns) do
~H"""
<.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">
<%= render_slot(action, f) %>
{render_slot(action, f)}
</div>
</.form>
"""
@@ -297,11 +297,11 @@ defmodule PinchflatWeb.CoreComponents do
class={["rounded focus:ring-0", @inputclass]}
{@rest}
/>
<%= @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>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@@ -310,7 +310,7 @@ defmodule PinchflatWeb.CoreComponents do
~H"""
<div phx-feedback-for={@name}>
<.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>
<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">
@@ -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]}
/>
<label for={"#{@id}-#{option_value}"} class="ml-2 cursor-pointer select-none">
<%= option_name %>
{option_name}
</label>
</div>
</section>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@@ -340,14 +340,15 @@ defmodule PinchflatWeb.CoreComponents do
end)
~H"""
<div x-data={"{ enabled: #{@checked}}"}>
<.label for={@id}>
<%= @label %>
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
<div x-data={"{ enabled: #{@checked} }"} class="" phx-update="ignore" id={"#{@id}-wrapper"}>
<.label :if={@label} for={@id}>
{@label}
<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
</.label>
<div class="relative">
<div class="relative flex flex-col">
<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 && '!right-1 !translate-x-full'"
@@ -358,8 +359,8 @@ defmodule PinchflatWeb.CoreComponents do
>
</div>
</div>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
</div>
"""
@@ -369,7 +370,7 @@ defmodule PinchflatWeb.CoreComponents do
~H"""
<div phx-feedback-for={@name}>
<.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>
<div class="flex">
<select
@@ -384,13 +385,13 @@ defmodule PinchflatWeb.CoreComponents do
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</div>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@@ -399,7 +400,7 @@ defmodule PinchflatWeb.CoreComponents do
~H"""
<div phx-feedback-for={@name}>
<.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>
<textarea
id={@id}
@@ -413,8 +414,8 @@ defmodule PinchflatWeb.CoreComponents do
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@@ -424,7 +425,7 @@ defmodule PinchflatWeb.CoreComponents do
~H"""
<div phx-feedback-for={@name}>
<.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>
<div class="flex items-center">
<input
@@ -441,10 +442,10 @@ defmodule PinchflatWeb.CoreComponents do
]}
{@rest}
/>
<%= render_slot(@input_append) %>
{render_slot(@input_append)}
</div>
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@@ -457,7 +458,7 @@ defmodule PinchflatWeb.CoreComponents do
def help(assigns) do
~H"""
<p class="mt-1 text-sm leading-5">
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</p>
"""
end
@@ -471,7 +472,7 @@ defmodule PinchflatWeb.CoreComponents do
def label(assigns) do
~H"""
<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>
"""
end
@@ -485,7 +486,7 @@ defmodule PinchflatWeb.CoreComponents do
~H"""
<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" />
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</p>
"""
end
@@ -504,13 +505,13 @@ defmodule PinchflatWeb.CoreComponents do
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
<div>
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
<%= render_slot(@subtitle) %>
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none"><%= render_slot(@actions) %></div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@@ -550,9 +551,9 @@ defmodule PinchflatWeb.CoreComponents do
<table class="w-[40rem] mt-11 sm:w-full">
<thead class="text-sm text-left leading-6 text-zinc-500">
<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">
<span class="sr-only"><%= gettext("Actions") %></span>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
@@ -570,7 +571,7 @@ defmodule PinchflatWeb.CoreComponents do
<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={["relative", i == 0 && "font-semibold text-zinc-900"]}>
<%= render_slot(col, @row_item.(row)) %>
{render_slot(col, @row_item.(row))}
</span>
</div>
</td>
@@ -578,7 +579,7 @@ defmodule PinchflatWeb.CoreComponents do
<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 :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>
</div>
</td>
@@ -607,8 +608,8 @@ defmodule PinchflatWeb.CoreComponents do
<div class="mt-2 mb-14">
<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">
<dt class="w-1/4 flex-none dark:text-white"><%= item.title %></dt>
<dd class="dark:text-white"><%= render_slot(item) %></dd>
<dt class="w-1/4 flex-none dark:text-white">{item.title}</dt>
<dd class="dark:text-white">{render_slot(item)}</dd>
</div>
</dl>
</div>
@@ -643,12 +644,12 @@ defmodule PinchflatWeb.CoreComponents do
~H"""
<ul>
<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">
<%= 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 %>
<%= v %>
{v}
<% end %>
</code>
</li>
@@ -671,7 +672,7 @@ defmodule PinchflatWeb.CoreComponents do
<div class="mt-16">
<.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" />
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</.link>
</div>
"""
@@ -37,7 +37,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
disabled={@disabled}
{@rest}
>
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</button>
"""
end
@@ -66,7 +66,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
"font-medium text-white hover:bg-opacity-95"
]}
>
<%= @text %>
{@text}
<CoreComponents.icon
name="hero-chevron-down"
class="fill-current duration-200 ease-linear mt-1"
@@ -81,7 +81,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
<ul class="flex flex-col">
<li :for={option <- @option}>
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
<%= render_slot(option) %>
{render_slot(option)}
</span>
</li>
</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>
<span><%= @tooltip %></span>
<span>{@tooltip}</span>
</div>
</div>
"""
@@ -34,16 +34,16 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
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"
>
<span class="text-xl"><%= tab.title %></span>
<span class="text-xl">{tab.title}</span>
</a>
</div>
<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>
</header>
<div class="mt-4 min-h-60">
<div :for={tab <- @tab} x-show={"openTab === '#{tab.id}'"} class="font-medium leading-relaxed">
<%= render_slot(tab) %>
{render_slot(tab)}
</div>
</div>
</div>
@@ -32,7 +32,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<thead>
<tr class="text-left bg-meta-4">
<th :for={col <- @col} class="px-4 py-4 font-medium text-white xl:pl-11">
<%= col[:label] %>
{col[:label]}
</th>
</tr>
</thead>
@@ -45,7 +45,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
col[:class]
]}
>
<%= render_slot(col, @row_item.(row)) %>
{render_slot(col, @row_item.(row))}
</td>
</tr>
</tbody>
@@ -12,7 +12,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def inline_code(assigns) do
~H"""
<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>
"""
end
@@ -26,7 +26,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def inline_link(assigns) do
~H"""
<.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300">
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</.link>
"""
end
@@ -41,7 +41,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def subtle_link(assigns) do
~H"""
<.link href={@href} target={@target} class="underline decoration-bodydark decoration-1 hover:decoration-white">
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</.link>
"""
end
@@ -62,20 +62,27 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
end
@doc """
Renders a block of text with each line broken into a separate span.
Renders a block of text with each line broken into a separate span and links highlighted.
"""
attr :text, :string, required: true
def break_on_newline(assigns) do
broken_text =
assigns.text
def render_description(assigns) do
formatted_text =
Regex.split(~r{https?://\S+}, assigns.text, include_captures: true)
|> Enum.map(fn
"http" <> _ = url ->
Phoenix.HTML.Tag.content_tag(:a, url, class: "text-blue-500 hover:text-blue-300", href: url, target: "_blank")
text ->
text
|> String.split("\n", trim: false)
|> Enum.intersperse(Phoenix.HTML.Tag.tag(:span, class: "inline-block mt-2"))
end)
assigns = Map.put(assigns, :text, broken_text)
assigns = Map.put(assigns, :text, formatted_text)
~H"""
<span><%= @text %></span>
<span>{@text}</span>
"""
end
@@ -91,7 +98,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
assigns = Map.put(assigns, :timezone, timezone)
~H"""
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
<time>{Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format)}</time>
"""
end
@@ -102,7 +109,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def localized_number(assigns) do
~H"""
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}><%= @number %></span>
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}>{@number}</span>
"""
end
@@ -115,7 +122,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
def pluralize(assigns) do
~H"""
<%= @word %><%= if @count == 1, do: "", else: @suffix %>
{@word}{if @count == 1, do: "", else: @suffix}
"""
end
end
+3 -3
View File
@@ -61,10 +61,10 @@ defmodule PinchflatWeb.Layouts do
x-on:click="selected = !selected"
>
<span class="flex items-center gap-2.5">
<.icon name={@icon} /> <%= @text %>
<.icon name={@icon} /> {@text}
</span>
<span class="text-bodydark2">
<.icon name="hero-chevron-up" x-bind:class="{ 'rotate-180': selected }" />
<.icon name="hero-chevron-down" x-bind:class="{ 'rotate-180': selected }" />
</span>
</span>
@@ -103,7 +103,7 @@ defmodule PinchflatWeb.Layouts do
@class
]}
>
<.icon :if={@icon} name={@icon} /> <%= @text %>
<.icon :if={@icon} name={@icon} /> {@text}
</.link>
"""
end
@@ -6,7 +6,7 @@
<main>
<div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
<.flash_group flash={@flash} />
<%= @inner_content %>
{@inner_content}
</div>
</main>
</div>
@@ -11,7 +11,7 @@
<main>
<div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
<.flash_group flash={@flash} />
<%= @inner_content %>
{@inner_content}
</div>
</main>
</div>
@@ -67,7 +67,7 @@
class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm"
x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
>
<span>Pinchflat <%= Application.spec(:pinchflat)[:vsn] %></span>
<span>Pinchflat {Application.spec(:pinchflat)[:vsn]}</span>
<a
href="https://github.com/kieraneglin/pinchflat/releases"
target="_blank"
@@ -79,7 +79,7 @@
</a>
</span>
<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>
</li>
</ul>
@@ -3,7 +3,7 @@ defmodule Pinchflat.UpgradeButtonLive do
def render(assigns) do
~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="" />
</form>
@@ -25,6 +25,6 @@
into the text box and press the button.
</p>
<%= live_render(@conn, Pinchflat.UpgradeButtonLive) %>
{live_render(@conn, Pinchflat.UpgradeButtonLive)}
</section>
</.modal>
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title>
<%= assigns[:page_title] || "Pinchflat" %>
{assigns[:page_title] || "Pinchflat"}
</.live_title>
<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"} />
@@ -21,7 +21,7 @@
class="dark text-bodydark bg-boxdark-2"
data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")}
>
<%= @inner_content %>
{@inner_content}
<.donate_modal conn={@conn} />
<template x-if="!proEnabled && !onboarding">
@@ -2,7 +2,7 @@
<:option>
<span x-data="{ copied: false }" x-on:click={~s"
copyWithCallbacks(
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`,
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`,
() => copied = true,
() => copied = false
)
@@ -1,6 +1,6 @@
<div class="mb-6 flex gap-3 flex-row items-center">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= StringUtils.truncate(@media_item.title, 35) %>"
Editing "{StringUtils.truncate(@media_item.title, 35)}"
</h2>
</div>
@@ -1,13 +1,13 @@
<%= if media_type(@media_item) == :video do %>
<video controls class="max-h-96 w-full">
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" />
<video controls class="max-h-128 w-full">
<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.
</video>
<% end %>
<%= if media_type(@media_item) == :audio do %>
<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.
</audio>
<% end %>
@@ -4,7 +4,7 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
<%= StringUtils.truncate(@media_item.title, 35) %>
{StringUtils.truncate(@media_item.title, 35)}
</h2>
</div>
@@ -26,25 +26,29 @@
<:tab title="Media" id="media">
<div class="flex flex-col gap-10 dark:text-white">
<%= if media_file_exists?(@media_item) do %>
<section class="grid grid-cols-1 xl:grid-cols-2 xl:gap-6 mt-6">
<section class="grid grid-cols-1 xl:gap-6 mt-6">
<div>
<.media_preview media_item={@media_item} />
</div>
<aside class="mt-4 xl:mt-0">
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
<aside class="mt-4">
<h2 class="text-xl mb-2">{@media_item.title}</h2>
<div>Uploaded: {DateTime.to_date(@media_item.uploaded_at)}</div>
<div>
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
<span class="mx-2">or</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
</.subtle_link>
</span>
</div>
<div :if={@media_item.description} class="mt-4 text-bodydark">
<.break_on_newline text={@media_item.description} />
<.render_description text={@media_item.description} />
</div>
</aside>
</section>
@@ -54,7 +58,7 @@
<section>
<strong>Source:</strong>
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
<%= @media_item.source.custom_name %>
{@media_item.source.custom_name}
</.subtle_link>
<.list_items_from_map map={Map.from_struct(@media_item)} />
</section>
@@ -64,13 +68,13 @@
<%= if match?([_|_], @media_item.tasks) do %>
<.table rows={@media_item.tasks} table_class="text-black dark:text-white">
<:col :let={task} label="Worker">
<%= task.job.worker %>
{task.job.worker}
</:col>
<:col :let={task} label="State">
<%= task.job.state %>
{task.job.state}
</:col>
<:col :let={task} label="Scheduled At">
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
<.datetime_in_zone datetime={task.job.scheduled_at} />
</:col>
</.table>
<% else %>
@@ -1,26 +1,51 @@
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
use PinchflatWeb, :controller
use Pinchflat.Sources.SourcesQuery
use Pinchflat.Profiles.ProfilesQuery
alias Pinchflat.Repo
alias Pinchflat.Profiles
alias Pinchflat.Sources.Source
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Profiles.MediaProfileDeletionWorker
def index(conn, _params) do
media_profiles =
MediaProfile
|> where([mp], is_nil(mp.marked_for_deletion_at))
|> order_by(asc: :name)
|> Repo.all()
media_profiles_query =
from mp in MediaProfile,
as: :media_profile,
where: is_nil(mp.marked_for_deletion_at),
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
def new(conn, _params) do
changeset = Profiles.change_media_profile(%MediaProfile{})
def new(conn, params) do
# 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
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 :action, :string, required: true
attr :method, :string, required: true
def media_profile_form(assigns)
@@ -25,6 +26,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
[
{"8k", "4320p"},
{"4k", "2160p"},
{"1440p", "1440p"},
{"1080p", "1080p"},
{"720p", "720p"},
{"480p", "480p"},
@@ -2,7 +2,7 @@
<:option>
<span x-data="{ copied: false }" x-on:click={~s"
copyWithCallbacks(
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`,
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`,
() => copied = true,
() => copied = false
)
@@ -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>
</:option>
<:option>
<.link href={~p"/media_profiles/new?template_id=#{@media_profile}"} method="get">
Use as Template
</.link>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
@@ -3,14 +3,14 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= @media_profile.name %>"
Editing "{@media_profile.name}"
</h2>
</div>
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div class="max-w-full">
<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>
@@ -10,18 +10,22 @@
</.link>
</nav>
</div>
<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="flex flex-col gap-10 min-w-max">
<.table rows={@media_profiles} table_class="text-black dark:text-white">
<:col :let={media_profile} label="Name">
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
<%= media_profile.name %>
{media_profile.name}
</.subtle_link>
</:col>
<: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 :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" />
@@ -2,6 +2,7 @@
:let={f}
for={@changeset}
action={@action}
method={@method}
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
>
@@ -124,6 +125,16 @@
/>
</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">
Thumbnail Options
</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="max-w-full">
<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>
@@ -6,7 +6,9 @@
<ul class="list-disc list-inside ml-2 md:ml-5">
<li>
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>
<code class="text-sm">yt-dlp</code>-style
@@ -17,7 +19,7 @@
</li>
<li>
Any bare words:
<.inline_code>/videos/1080p/{{ id }}.{{ ext }}</.inline_code>
<.inline_code>{"/videos/1080p/#{double_brace("id")}.#{double_brace("ext")}"}</.inline_code>
</li>
</ul>
<p class="my-2">
@@ -25,7 +27,7 @@
<em>any</em>
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
<.inline_code>{{ duration }}</.inline_code>
<.inline_code>{double_brace("duration")}</.inline_code>
option is translated to
<.inline_code>%(duration)S</.inline_code>
</p>
@@ -33,12 +35,14 @@
<strong>Major 🔑:</strong>
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:
<.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 class="my-2">
<strong>NOTE:</strong>
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
<.inline_code>.%(ext)S</.inline_code>).
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>
<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">
<.inline_code>{{ <%= k %> }}</.inline_code>
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
<.inline_code>{double_brace(k)}</.inline_code>
<span :if={v}>- {html_escape({:safe, v})}</span>
</li>
</ul>
<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">
<li :for={{k, v} <- other_custom_output_template_options()} class="mt-1">
<.inline_code>{{ <%= k %> }}</.inline_code>
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
<.inline_code>{double_brace(k)}</.inline_code>
<span :if={v}>- {html_escape({:safe, v})}</span>
</li>
</ul>
<h3 class="text-lg font-bold mb-2">Common Options</h3>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li :for={opt <- common_output_template_options()} class="mt-1">
<.inline_code>{{ <%= opt %> }}</.inline_code>
<.inline_code>{double_brace(opt)}</.inline_code>
</li>
</ul>
</section>
@@ -4,7 +4,7 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
<%= @media_profile.name %>
{@media_profile.name}
</h2>
</div>
@@ -33,10 +33,10 @@
<.table rows={@sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name">
<.subtle_link href={~p"/sources/#{source.id}"}>
<%= source.custom_name || source.collection_name %>
{source.custom_name || source.collection_name}
</.subtle_link>
</:col>
<:col :let={source} label="Type"><%= source.collection_type %></:col>
<:col :let={source} label="Type">{source.collection_type}</:col>
<:col :let={source} label="Should Download?">
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
</:col>
@@ -17,7 +17,7 @@ defmodule PinchflatWeb.Pages.PageHTML do
})
~H"""
<.localized_number number={@num} /> <%= @suffix %>
<.localized_number number={@num} /> {@suffix}
"""
end
end
@@ -30,21 +30,21 @@ defmodule Pinchflat.Pages.HistoryTableLive do
<.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title">
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}>
<%= StringUtils.truncate(media_item.title, 35) %>
{StringUtils.truncate(media_item.title, 35)}
</.subtle_link>
</:col>
<:col :let={media_item} label="Upload Date">
<%= DateTime.to_date(media_item.uploaded_at) %>
{DateTime.to_date(media_item.uploaded_at)}
</:col>
<:col :let={media_item} label="Indexed At">
<%= format_datetime(media_item.inserted_at) %>
{format_datetime(media_item.inserted_at)}
</:col>
<:col :let={media_item} label="Downloaded At">
<%= format_datetime(media_item.media_downloaded_at) %>
{format_datetime(media_item.media_downloaded_at)}
</:col>
<:col :let={media_item} label="Source">
<.subtle_link href={~p"/sources/#{media_item.source_id}"}>
<%= StringUtils.truncate(media_item.source.custom_name, 35) %>
{StringUtils.truncate(media_item.source.custom_name, 35)}
</.subtle_link>
</:col>
</.table>
@@ -42,13 +42,13 @@
<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>
<section class="mt-6">
<%= live_render(@conn, Pinchflat.Pages.HistoryTableLive) %>
{live_render(@conn, Pinchflat.Pages.HistoryTableLive)}
</section>
</div>
<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>
<section class="mt-6 min-h-80">
<%= live_render(@conn, Pinchflat.Pages.JobTableLive) %>
{live_render(@conn, Pinchflat.Pages.JobTableLive)}
</section>
</div>
@@ -19,18 +19,18 @@ defmodule Pinchflat.Pages.JobTableLive do
<div class="max-w-full overflow-x-auto">
<.table rows={@tasks} table_class="text-white">
<:col :let={task} label="Task">
<%= worker_to_task_name(task.job.worker) %>
{worker_to_task_name(task.job.worker)}
</:col>
<:col :let={task} label="Subject">
<.subtle_link href={task_to_link(task)}>
<%= StringUtils.truncate(task_to_record_name(task), 35) %>
{StringUtils.truncate(task_to_record_name(task), 35)}
</.subtle_link>
</:col>
<:col :let={task} label="Attempt No.">
<%= task.job.attempt %>
{task.job.attempt}
</:col>
<:col :let={task} label="Started At">
<%= format_datetime(task.job.attempted_at) %>
{format_datetime(task.job.attempted_at)}
</:col>
</.table>
</div>
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.Searches.SearchHTML do
~H"""
<%= for fragment <- @split_string do %>
<%= render_fragment(fragment) %>
{render_fragment(fragment)}
<% end %>
"""
end
@@ -1,6 +1,6 @@
<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">
Results for "<%= StringUtils.truncate(@search_term, 50) %>"
Results for "{StringUtils.truncate(@search_term, 50)}"
</h2>
</div>
@@ -11,7 +11,7 @@
<.table rows={@search_results} table_class="text-black dark:text-white">
<:col :let={result} label="Title">
<.subtle_link href={~p"/sources/#{result.source_id}/media/#{result.id}"}>
<%= StringUtils.truncate(result.title, 35) %>
{StringUtils.truncate(result.title, 35)}
</.subtle_link>
</:col>
<:col :let={result} label="Excerpt">
@@ -19,11 +19,11 @@
</span>
</section>
<%= live_render(
{live_render(
@conn,
Pinchflat.Settings.AppriseServerLive,
session: %{"value" => f[:apprise_server].value}
) %>
)}
</section>
<section class="mt-8">
@@ -1,46 +1,20 @@
defmodule PinchflatWeb.Sources.SourceController do
use PinchflatWeb, :controller
use Pinchflat.Media.MediaQuery
use Pinchflat.Sources.SourcesQuery
alias Pinchflat.Repo
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Media.FileSyncingWorker
alias Pinchflat.Sources.SourceDeletionWorker
alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker
def index(conn, _params) do
source_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)
)
}
render(conn, :index, sources: Repo.all(source_query))
render(conn, :index)
end
def new(conn, params) do
@@ -66,7 +40,8 @@ defmodule PinchflatWeb.Sources.SourceController do
collection_name: nil,
collection_id: nil,
collection_type: nil,
original_url: nil
original_url: nil,
marked_for_deletion_at: nil
})
)
end
@@ -175,6 +150,15 @@ defmodule PinchflatWeb.Sources.SourceController do
)
end
def sync_files_on_disk(conn, %{"source_id" => id}) do
wrap_forced_action(
conn,
id,
"File sync enqueued.",
&FileSyncingWorker.kickoff_with_task/1
)
end
defp wrap_forced_action(conn, source_id, message, fun) do
source = Sources.get_source!(source_id)
fun.(source)
@@ -27,6 +27,18 @@ defmodule PinchflatWeb.Sources.SourceHTML do
]
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
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end
@@ -55,4 +67,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
"""
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
@@ -17,7 +17,7 @@
<:option>
<span x-data="{ copied: false }" x-on:click={~s"
copyWithCallbacks(
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`,
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`,
() => copied = true,
() => copied = false
)
@@ -38,7 +38,7 @@
<.link
href={~p"/sources/#{@source}/force_download_pending"}
method="post"
data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed."
data-confirm="Are you sure you want to force a download of all pending media items? This isn't normally needed."
>
Download Pending
</.link>
@@ -47,7 +47,7 @@
<.link
href={~p"/sources/#{@source}/force_redownload"}
method="post"
data-confirm="Are you sure you want to re-download all currently downloaded media items? This isn't normally needed and won't change anything if the files already exist."
data-confirm="Are you sure you want to re-download all currently downloaded media items? This doesn't upgrade your media, but will download any missing files if your settings have changed. This isn't normally needed."
>
Redownload Existing
</.link>
@@ -70,6 +70,15 @@
Refresh Metadata
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}/sync_files_on_disk"}
method="post"
data-confirm="Are you sure you want to sync files? This will update media items if their files have been deleted. File addition or moves are not detected. This isn't normally needed."
>
Sync Files on Disk
</.link>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
@@ -3,7 +3,7 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= @source.custom_name %>"
Editing "{@source.custom_name}"
</h2>
</div>
@@ -12,32 +12,7 @@
<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="flex flex-col gap-10 min-w-max">
<.table rows={@sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name">
<.subtle_link href={~p"/sources/#{source.id}"}>
<%= StringUtils.truncate(source.custom_name || source.collection_name, 35) %>
</.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>
{live_render(@conn, PinchflatWeb.Sources.IndexTableLive)}
</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">
<.subtle_link href={~p"/sources/#{source.id}"}>
{StringUtils.truncate(source.custom_name || source.collection_name, 35)}
</.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 Pinchflat.Media.MediaQuery
@@ -48,14 +48,14 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
<.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title">
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
<%= StringUtils.truncate(media_item.title, 50) %>
{StringUtils.truncate(media_item.title, 50)}
</.subtle_link>
</:col>
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
</:col>
<:col :let={media_item} label="Upload Date">
<%= DateTime.to_date(media_item.uploaded_at) %>
{DateTime.to_date(media_item.uploaded_at)}
</:col>
<: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" />
@@ -4,7 +4,7 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
<%= @source.custom_name %>
{@source.custom_name}
</h2>
</div>
@@ -29,7 +29,7 @@
<section>
<strong>Media Profile:</strong>
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
<%= @source.media_profile.name %>
{@source.media_profile.name}
</.subtle_link>
</section>
@@ -37,37 +37,37 @@
</div>
</:tab>
<:tab title="Pending" id="pending">
<%= live_render(
{live_render(
@conn,
Pinchflat.Sources.MediaItemTableLive,
PinchflatWeb.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "pending"}
) %>
)}
</:tab>
<:tab title="Downloaded" id="downloaded">
<%= live_render(
{live_render(
@conn,
Pinchflat.Sources.MediaItemTableLive,
PinchflatWeb.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "downloaded"}
) %>
)}
</:tab>
<:tab title="Other" id="other">
<%= live_render(
{live_render(
@conn,
Pinchflat.Sources.MediaItemTableLive,
PinchflatWeb.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "other"}
) %>
)}
</:tab>
<:tab title="Tasks" id="tasks">
<%= if match?([_|_], @pending_tasks) do %>
<.table rows={@pending_tasks} table_class="text-black dark:text-white">
<:col :let={task} label="Worker">
<%= task.job.worker %>
{task.job.worker}
</:col>
<:col :let={task} label="State">
<%= task.job.state %>
{task.job.state}
</:col>
<:col :let={task} label="Scheduled At">
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
<.datetime_in_zone datetime={task.job.scheduled_at} />
</:col>
</.table>
<% else %>
@@ -111,6 +111,7 @@
/>
</section>
<section x-data={"{ cutoffDate: '#{f[:download_cutoff_date].value}' }"}>
<.input
field={f[:download_cutoff_date]}
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])"
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"
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
field={f[:retention_period_days]}
+1
View File
@@ -40,6 +40,7 @@ defmodule PinchflatWeb.Router do
post "/force_redownload", Sources.SourceController, :force_redownload
post "/force_index", Sources.SourceController, :force_index
post "/force_metadata_refresh", Sources.SourceController, :force_metadata_refresh
post "/sync_files_on_disk", Sources.SourceController, :sync_files_on_disk
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] do
post "/force_download", MediaItems.MediaItemController, :force_download
+3 -3
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "2024.9.12",
version: "2024.12.10",
elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
@@ -54,8 +54,8 @@ defmodule Pinchflat.MixProject do
{:ecto_sqlite3_extras, "~> 1.2.0"},
{:phoenix_html, "~> 3.3"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.20.1"},
{:floki, ">= 0.30.0", only: :test},
{:phoenix_live_view, "~> 1.0.0"},
{:floki, ">= 0.36.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.2"},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
+7 -7
View File
@@ -1,6 +1,6 @@
%{
"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"},
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"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_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"},
"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"},
"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"},
@@ -24,7 +24,7 @@
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
"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"},
"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"},
"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"},
@@ -40,12 +40,12 @@
"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"},
"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_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_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_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"},
@@ -61,8 +61,8 @@
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"},
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"},
"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"},
"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: 438 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
@@ -9,6 +9,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
setup do
stub(YtDlpRunnerMock, :version, fn -> {:ok, "1"} end)
stub(AppriseRunnerMock, :version, fn -> {:ok, "2"} end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "3", 0} end)
:ok
end
@@ -112,4 +113,16 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
assert Settings.get!(:apprise_version)
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
@@ -6,7 +6,6 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
alias Pinchflat.Sources
alias Pinchflat.Profiles
alias Pinchflat.Settings
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadOptionBuilder
@@ -253,21 +252,14 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end
describe "build/1 when testing media quality and format options" do
test "includes quality options" do
resolutions = ["360", "480", "720", "1080", "2160", "4320"]
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)
# There are more tests inside QualityOptionBuilderTest
# This is essenitally just testing that we implement that module correctly
test "includes video options for video profiles", %{media_item: media_item} do
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
end)
end
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
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
describe "build/1 when testing sponsorblock options" do
@@ -461,6 +426,22 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
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
media_item_with_preloads.source.media_profile
|> Profiles.change_media_profile(attrs)
@@ -9,6 +9,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
alias Pinchflat.Downloading.MediaDownloadWorker
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
@@ -186,6 +187,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
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
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{: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
test "ignores 'prevent_download' if forced", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
@@ -236,6 +251,26 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
test "deletes old files if the media item has been updated" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
tmp_media_item = media_item_with_attachments()
metadata = render_parsed_metadata(:media_metadata)
metadata = Map.put(metadata, "filepath", tmp_media_item.media_filepath)
{:ok, Phoenix.json_library().encode!(metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
old_media_item = media_item_with_attachments()
perform_job(MediaDownloadWorker, %{id: old_media_item.id, force: true})
updated_media_item = Repo.reload(old_media_item)
assert updated_media_item.media_filepath != old_media_item.media_filepath
refute File.exists?(old_media_item.media_filepath)
assert File.exists?(updated_media_item.media_filepath)
end
end
describe "perform/1 when testing user script callbacks" do
@@ -16,6 +16,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
)
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
{:ok, %{media_item: media_item}}
@@ -49,6 +50,14 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/thumbnail.jpg"
end
test "errors for non-downloadable media are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{: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
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, :some_error, 1}
@@ -67,6 +76,36 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
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, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "was_live"})}
end)
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
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, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
expect(YtDlpRunnerMock, :run, 0, fn _url, _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, _opts, _ot -> {: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
test "includes override opts if specified", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
@@ -46,6 +46,23 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
refute Repo.reload!(old_media_item).media_filepath
end
test "deletes media files that are on their retention date per the 24-h clock" do
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date(2)
just_over_two_days_ago = now_minus(2, :days) |> DateTime.add(-1, :minute)
just_under_two_days_ago = now_minus(2, :days) |> DateTime.add(1, :minute)
Media.update_media_item(old_media_item, %{media_downloaded_at: just_over_two_days_ago})
Media.update_media_item(new_media_item, %{media_downloaded_at: just_under_two_days_ago})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
refute File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
refute Repo.reload!(old_media_item).media_filepath
end
test "sets culled_at and prevent_download" do
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
@@ -106,6 +123,25 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
refute Repo.reload!(old_media_item).media_filepath
end
# NOTE: Since this is a date and not a datetime, we can't add logic to have to-the-minute
# comparison like we can with retention periods. We can only compare to the day.
test "doesn't cull media from on or after the cutoff date" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(2)
Media.update_media_item(old_media_item, %{uploaded_at: now_minus(2, :days)})
Media.update_media_item(new_media_item, %{uploaded_at: now_minus(1, :day)})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
assert Repo.reload!(old_media_item).media_filepath
refute Repo.reload!(new_media_item).culled_at
refute Repo.reload!(old_media_item).culled_at
end
test "sets culled_at but not prevent_download" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date()
@@ -131,23 +167,6 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
refute Repo.reload!(old_media_item).culled_at
end
test "doesn't cull media from on or after the cutoff date" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(2)
Media.update_media_item(old_media_item, %{uploaded_at: now_minus(2, :days)})
Media.update_media_item(new_media_item, %{uploaded_at: now_minus(1, :day)})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
assert Repo.reload!(old_media_item).media_filepath
refute Repo.reload!(new_media_item).culled_at
refute Repo.reload!(old_media_item).culled_at
end
test "doesn't cull media items that have prevent_culling set" do
{_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date()
@@ -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
use Pinchflat.DataCase
import Pinchflat.TasksFixtures
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
@@ -8,6 +9,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
alias Pinchflat.Tasks
alias Pinchflat.Settings
alias Pinchflat.Media.MediaItem
alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.FastIndexingHelpers
@@ -19,6 +21,23 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
{:ok, [source: source_fixture()]}
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
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)
@@ -61,6 +80,18 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker")
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, 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
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
@@ -100,7 +131,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
Phoenix.json_library().encode!(%{
id: "video2",
title: "Video 2",
webpage_url: "https://example.com/shorts/video2",
original_url: "https://example.com/shorts/video2",
live_status: "is_live",
description: "desc2",
aspect_ratio: 1.67,
+122
View File
@@ -0,0 +1,122 @@
defmodule Pinchflat.Media.FileSyncingTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
alias Pinchflat.Media.FileSyncing
describe "delete_outdated_files/2" do
test "deletes outdated non-subtitle files" do
new_media_item = media_item_with_attachments()
old_media_item = media_item_with_attachments()
assert :ok = FileSyncing.delete_outdated_files(old_media_item, new_media_item)
assert File.exists?(new_media_item.media_filepath)
refute File.exists?(old_media_item.media_filepath)
end
test "doesn't delete non-subtitle files if the new file is the same" do
new_media_item = media_item_with_attachments()
old_media_item = media_item_fixture(%{media_filepath: new_media_item.media_filepath})
assert :ok = FileSyncing.delete_outdated_files(old_media_item, new_media_item)
assert File.exists?(new_media_item.media_filepath)
assert File.exists?(old_media_item.media_filepath)
end
test "doesn't delete the old file if the new file is missing that key" do
new_media_item = media_item_fixture(%{media_filepath: nil})
old_media_item = media_item_with_attachments()
assert :ok = FileSyncing.delete_outdated_files(old_media_item, new_media_item)
assert File.exists?(old_media_item.media_filepath)
end
test "deletes outdated subtitle files" do
new_media_item = media_item_with_attachments()
old_media_item = media_item_with_attachments()
assert :ok = FileSyncing.delete_outdated_files(old_media_item, new_media_item)
assert File.exists?(get_subtitle_filepath(new_media_item, "en"))
refute File.exists?(get_subtitle_filepath(old_media_item, "en"))
end
test "keeps old subtitle files if the new file is the same" do
new_media_item = media_item_with_attachments()
old_media_item = media_item_fixture(%{subtitle_filepaths: new_media_item.subtitle_filepaths})
assert :ok = FileSyncing.delete_outdated_files(old_media_item, new_media_item)
assert File.exists?(get_subtitle_filepath(new_media_item, "en"))
assert File.exists?(get_subtitle_filepath(old_media_item, "en"))
end
test "doesn't delete old subtitle files if the new file is missing that key" do
new_media_item = media_item_fixture(%{subtitle_filepaths: []})
old_media_item = media_item_with_attachments()
assert :ok = FileSyncing.delete_outdated_files(old_media_item, new_media_item)
assert File.exists?(get_subtitle_filepath(old_media_item, "en"))
end
end
describe "sync_file_presence_on_disk/1" do
test "removes attributes whose files are missing" do
media_item = media_item_fixture(%{media_filepath: "/tmp/missing_file.mp4"})
assert media_item.media_filepath
assert [updated_media_item] = FileSyncing.sync_file_presence_on_disk([media_item])
refute updated_media_item.media_filepath
end
test "doesn't remove attributes where the files still exist" do
media_item = media_item_with_attachments()
assert media_item.media_filepath
assert [updated_media_item] = FileSyncing.sync_file_presence_on_disk([media_item])
assert updated_media_item.media_filepath
end
test "doesn't touch other attributes if some are missing and some aren't" do
media_item = media_item_with_attachments()
File.rm(media_item.media_filepath)
assert media_item.thumbnail_filepath
assert media_item.media_filepath
assert [updated_media_item] = FileSyncing.sync_file_presence_on_disk([media_item])
assert updated_media_item.thumbnail_filepath
refute updated_media_item.media_filepath
end
test "removes subtitle files that are missing" do
media_item = media_item_fixture(%{subtitle_filepaths: [["en", "/tmp/missing_file.srt"]]})
assert get_subtitle_filepath(media_item, "en")
assert [updated_media_item] = FileSyncing.sync_file_presence_on_disk([media_item])
refute get_subtitle_filepath(updated_media_item, "en")
end
test "doesn't remove subtitle files that still exist" do
media_item = media_item_with_attachments()
assert get_subtitle_filepath(media_item, "en")
assert [updated_media_item] = FileSyncing.sync_file_presence_on_disk([media_item])
assert get_subtitle_filepath(updated_media_item, "en")
end
end
defp get_subtitle_filepath(media_item, language) do
Enum.reduce_while(media_item.subtitle_filepaths, nil, fn [lang, filepath], acc ->
if lang == language do
{:halt, filepath}
else
{:cont, acc}
end
end)
end
end
@@ -0,0 +1,37 @@
defmodule Pinchflat.Media.FileSyncingWorkerTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Media.FileSyncingWorker
describe "kickoff_with_task/3" do
test "starts the worker" do
source = source_fixture()
assert [] = all_enqueued(worker: FileSyncingWorker)
assert {:ok, _} = FileSyncingWorker.kickoff_with_task(source)
assert [_] = all_enqueued(worker: FileSyncingWorker)
end
test "attaches a task" do
source = source_fixture()
assert {:ok, task} = FileSyncingWorker.kickoff_with_task(source)
assert task.source_id == source.id
end
end
describe "perform/1" do
test "syncs file presence on disk" do
source = source_fixture()
media_item = media_item_fixture(%{media_filepath: "/tmp/missing.mp4", source_id: source.id})
perform_job(FileSyncingWorker, %{"id" => source.id})
updated_media_item = Repo.reload!(media_item)
refute updated_media_item.media_filepath
end
end
end
+22
View File
@@ -441,6 +441,13 @@ defmodule Pinchflat.MediaTest do
assert Media.pending_download?(media_item)
end
test "returns true if the cutoff date is equal to the upload date" do
source = source_fixture(%{download_cutoff_date: now_minus(2, :days)})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
assert Media.pending_download?(media_item)
end
test "returns false if there is a cutoff date after the media's upload date" do
source = source_fixture(%{download_cutoff_date: now_minus(1, :day)})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
@@ -705,6 +712,21 @@ defmodule Pinchflat.MediaTest do
assert media_item.media_filepath == update_attrs.media_filepath
end
test "updating strips playlist_index from the provided attrs" do
media_item = media_item_fixture(playlist_index: 5)
update_attrs = %{
media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id,
playlist_index: 1
}
assert {:ok, %MediaItem{} = media_item} = Media.update_media_item(media_item, update_attrs)
assert media_item.playlist_index == 5
end
test "updating with invalid data returns error changeset" do
media_item = media_item_fixture()
assert {:error, %Ecto.Changeset{}} = Media.update_media_item(media_item, @invalid_attrs)
@@ -23,6 +23,36 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
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
source = source_fixture(index_frequency_minutes: 1)
@@ -92,6 +122,56 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
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
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
@@ -188,7 +268,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
description: "desc3",
# Only focusing on these because these are passed to functions that
# could fail if they're not present
webpage_url: nil,
original_url: nil,
aspect_ratio: nil,
duration: nil,
upload_date: nil
@@ -202,6 +282,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert %Ecto.Changeset{} = changeset
end
test "passes the source's download options to the yt-dlp runner", %{source: source} do
expect(YtDlpRunnerMock, :run, fn _url, 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
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
assert {:use_cookies, true} in addl_opts
@@ -299,7 +389,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
Phoenix.json_library().encode!(%{
id: "video2",
title: "Video 2",
webpage_url: "https://example.com/shorts/video2",
original_url: "https://example.com/shorts/video2",
live_status: "is_live",
description: "desc2",
aspect_ratio: 1.67,
+171 -34
View File
@@ -147,6 +147,18 @@ defmodule Pinchflat.SourcesTest do
assert "could not fetch source details from URL" in errors_on(changeset).original_url
end
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)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:error, %Ecto.Changeset{} = changeset} = Sources.create_source(valid_attrs)
assert "could not fetch source details from URL" in errors_on(changeset).original_url
end
test "you can specify a custom custom_name" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
@@ -406,6 +418,100 @@ defmodule Pinchflat.SourcesTest do
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
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/4)
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
source = source_fixture()
update_attrs = %{index_frequency_minutes: 123}
@@ -450,27 +556,47 @@ defmodule Pinchflat.SourcesTest do
refute_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
end
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}
test "disabling a source will delete any pending tasks" do
source = source_fixture()
update_attrs = %{enabled: false}
{: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_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
test "disabling the download_media attribute will cancel the download task" do
source = source_fixture(download_media: true)
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
update_attrs = %{download_media: false}
DownloadingHelpers.enqueue_pending_download_tasks(source)
test "updating the index frequency will not create a task if the source is disabled" do
source = source_fixture(enabled: false)
update_attrs = %{index_frequency_minutes: 123}
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
refute_enqueued(worker: MediaCollectionIndexingWorker)
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs)
refute_enqueued(worker: MediaDownloadWorker)
refute_enqueued(worker: MediaCollectionIndexingWorker)
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
source = source_fixture(fast_index: false)
update_attrs = %{fast_index: true}
@@ -491,15 +617,6 @@ defmodule Pinchflat.SourcesTest do
refute_enqueued(worker: FastIndexingWorker)
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
source = source_fixture(%{fast_index: true})
update_attrs = %{index_frequency_minutes: 0}
@@ -518,23 +635,43 @@ defmodule Pinchflat.SourcesTest do
assert source.index_frequency_minutes == 0
end
test "updating will kickoff a metadata storage worker if the original_url changes" do
expect(YtDlpRunnerMock, :run, &playlist_mock/4)
test "disabling a source will delete any pending tasks" do
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)
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"}
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id)
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
+1 -1
View File
@@ -247,7 +247,7 @@ defmodule Pinchflat.TasksTest do
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
test "deletion can optionall include executing tasks" do
test "deletion can optionally include executing tasks" do
source = source_fixture()
task = task_fixture(source_id: source.id)
@@ -37,6 +37,46 @@ defmodule Pinchflat.Utils.FilesystemUtilsTest do
end
end
describe "filepaths_reference_same_file?/2" do
setup do
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
on_exit(fn -> File.rm!(filepath) end)
{:ok, %{filepath: filepath}}
end
test "returns true if the files are the same", %{filepath: filepath} do
assert FilesystemUtils.filepaths_reference_same_file?(filepath, filepath)
end
test "returns true if different filepaths point to the same file", %{filepath: filepath} do
short_path = Path.expand(filepath)
long_path = Path.join(["/tmp", "..", filepath])
assert short_path != long_path
assert FilesystemUtils.filepaths_reference_same_file?(short_path, long_path)
end
test "returns true if the files are symlinked", %{filepath: filepath} do
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
other_filepath = Path.join([tmpfile_directory, "symlink.json"])
:ok = File.ln_s!(filepath, other_filepath)
assert FilesystemUtils.filepaths_reference_same_file?(filepath, other_filepath)
File.rm!(other_filepath)
end
test "returns false if the files are different", %{filepath: filepath} do
other_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
refute FilesystemUtils.filepaths_reference_same_file?(filepath, other_filepath)
File.rm!(other_filepath)
end
end
describe "generate_metadata_tmpfile/1" do
test "creates a tmpfile and returns its path" do
res = FilesystemUtils.generate_metadata_tmpfile(:json)
+31
View File
@@ -0,0 +1,31 @@
defmodule Pinchflat.Utils.MapUtilsTest do
use Pinchflat.DataCase
alias Pinchflat.Utils.MapUtils
describe "from_nested_list/1" do
test "creates a map from a nested 2-element tuple list" do
list = [
{"key1", "value1"},
{"key2", "value2"}
]
assert MapUtils.from_nested_list(list) == %{
"key1" => "value1",
"key2" => "value2"
}
end
test "creates a map from a nested 2-element list of lists" do
list = [
["key1", "value1"],
["key2", "value2"]
]
assert MapUtils.from_nested_list(list) == %{
"key1" => "value1",
"key2" => "value2"
}
end
end
end
@@ -41,4 +41,10 @@ defmodule Pinchflat.Utils.StringUtilsTest do
assert StringUtils.truncate("hello", 10) == "hello"
end
end
describe "double_brace/1" do
test "wraps a string in double braces" do
assert StringUtils.double_brace("hello") == "{{ hello }}"
end
end
end
@@ -35,6 +35,16 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
assert {:error, "Big issue", 1} = MediaCollection.get_media_attributes_for_collection(@channel_url)
end
test "passes long additional command options" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl_opts ->
assert :foo in opts
{:ok, ""}
end)
assert {:ok, _} = MediaCollection.get_media_attributes_for_collection(@channel_url, [:foo])
end
test "passes additional args to runner" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
assert [{:output_filepath, filepath} | _] = addl_opts
@@ -56,7 +66,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
assert {:ok, _} =
MediaCollection.get_media_attributes_for_collection(@channel_url, file_listener_handler: handler)
MediaCollection.get_media_attributes_for_collection(@channel_url, [], file_listener_handler: handler)
assert_receive {:handler, filename}
assert String.ends_with?(filename, ".json")
@@ -133,7 +143,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
test "returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_details(@channel_url)
assert {:error, "Error decoding JSON response"} = MediaCollection.get_source_details(@channel_url)
end
end
+86 -17
View File
@@ -58,6 +58,64 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
end
describe "get_downloadable_status/1" do
test "returns :downloadable if the media was never live" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "not_live"})}
end)
assert {:ok, :downloadable} = Media.get_downloadable_status(@media_url)
end
test "returns :downloadable if the media was live and has been processed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "was_live"})}
end)
assert {:ok, :downloadable} = Media.get_downloadable_status(@media_url)
end
test "returns :downloadable if the media's live_status is nil" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => nil})}
end)
assert {:ok, :downloadable} = Media.get_downloadable_status(@media_url)
end
test "returns :ignorable if the media is currently live" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
assert {:ok, :ignorable} = Media.get_downloadable_status(@media_url)
end
test "returns :ignorable if the media is scheduled to be live" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_upcoming"})}
end)
assert {:ok, :ignorable} = Media.get_downloadable_status(@media_url)
end
test "returns :ignorable if the media was live but hasn't been processed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "post_live"})}
end)
assert {:ok, :ignorable} = Media.get_downloadable_status(@media_url)
end
test "returns an error if the downloadable status can't be determined" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "what_tha"})}
end)
assert {:error, "Unknown live status: what_tha"} = Media.get_downloadable_status(@media_url)
end
end
describe "download_thumbnail/2" do
test "calls the backend runner with the expected arguments" do
expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot, _addl ->
@@ -120,13 +178,22 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert {:ok, _} = Media.get_media_attributes(@media_url)
end
test "passes along additional command options" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
assert [:simulate, :skip_download, :custom_arg] = opts
{:ok, media_attributes_return_fixture()}
end)
assert {:ok, _} = Media.get_media_attributes(@media_url, [:custom_arg])
end
test "passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
assert [addl_arg: true] = addl
{:ok, media_attributes_return_fixture()}
end)
assert {:ok, _} = Media.get_media_attributes(@media_url, addl_arg: true)
assert {:ok, _} = Media.get_media_attributes(@media_url, [], addl_arg: true)
end
test "returns the error straight through when the command fails" do
@@ -139,7 +206,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "indexing_output_template/0" do
test "contains all the greatest hits" do
attrs =
~w(id title live_status webpage_url description aspect_ratio duration upload_date timestamp playlist_index)a
~w(id title live_status original_url description aspect_ratio duration upload_date timestamp playlist_index filename)a
formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j"
@@ -153,13 +220,14 @@ defmodule Pinchflat.YtDlp.MediaTest do
"id" => "TiZPUDkDYbk",
"title" => "Trying to Wheelie Without the Rear Brake",
"description" => "I'm not sure what I expected.",
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"live_status" => "not_live",
"aspect_ratio" => 1.0,
"duration" => 60,
"upload_date" => "20210101",
"timestamp" => 1_600_000_000,
"playlist_index" => 1
"playlist_index" => 1,
"filename" => "TiZPUDkDYbk.mp4"
}
assert %Media{
@@ -171,13 +239,14 @@ defmodule Pinchflat.YtDlp.MediaTest do
short_form_content: false,
uploaded_at: ~U[2020-09-13 12:26:40Z],
duration_seconds: 60,
playlist_index: 1
playlist_index: 1,
predicted_media_filepath: "TiZPUDkDYbk.mp4"
} == Media.response_to_struct(response)
end
test "sets short_form_content to true if the URL contains /shorts/" do
response = %{
"webpage_url" => "https://www.youtube.com/shorts/TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/shorts/TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101"
@@ -188,7 +257,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "sets short_form_content to true if the aspect ratio are duration are right" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 0.5,
"duration" => 59,
"upload_date" => "20210101"
@@ -199,7 +268,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "sets short_form_content to false otherwise" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101"
@@ -210,7 +279,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "doesn't blow up if short form content-related fields are missing" do
response = %{
"webpage_url" => nil,
"original_url" => nil,
"aspect_ratio" => nil,
"duration" => nil,
"upload_date" => "20210101"
@@ -221,7 +290,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "parses the duration" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 60.4,
"upload_date" => "20210101"
@@ -232,7 +301,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "doesn't blow up if duration is missing" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => nil,
"upload_date" => "20210101"
@@ -243,7 +312,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "sets livestream to false if the live_status field isn't present" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 60,
"upload_date" => "20210101"
@@ -254,7 +323,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "doesn't blow up if playlist_index is missing" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => nil,
"upload_date" => "20210101"
@@ -267,7 +336,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "response_to_struct/1 when testing uploaded_at" do
test "parses the upload date from the timestamp if present" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101",
@@ -281,7 +350,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "parses the upload date from the uploaded_at if timestamp is present but nil" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101",
@@ -295,7 +364,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "parses the upload date from the uploaded_at if timestamp absent" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101"
@@ -308,7 +377,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "doesn't blow up if upload date is missing" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => nil
@@ -79,6 +79,15 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
refute html_response(conn, 200) =~ "MENU"
end
test "preloads some attributes when using a template", %{conn: conn} do
profile = media_profile_fixture(name: "My first profile", download_subs: true, sub_langs: "de")
conn = get(conn, ~p"/media_profiles/new", %{"template_id" => profile.id})
assert html_response(conn, 200) =~ "New Media Profile"
assert html_response(conn, 200) =~ profile.sub_langs
refute html_response(conn, 200) =~ profile.name
end
end
describe "edit media_profile" do
@@ -7,6 +7,7 @@ defmodule PinchflatWeb.SourceControllerTest do
alias Pinchflat.Repo
alias Pinchflat.Settings
alias Pinchflat.Media.FileSyncingWorker
alias Pinchflat.Sources.SourceDeletionWorker
alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.Metadata.SourceMetadataStorageWorker
@@ -33,27 +34,10 @@ defmodule PinchflatWeb.SourceControllerTest do
end
describe "index" do
test "lists all sources", %{conn: conn} do
source = source_fixture()
# Most of the tests are in `index_table_list_test.exs`
test "returns 200", %{conn: conn} do
conn = get(conn, ~p"/sources")
assert html_response(conn, 200) =~ "Sources"
assert html_response(conn, 200) =~ source.custom_name
end
test "omits sources that have marked_for_deletion_at set", %{conn: conn} do
source = source_fixture(marked_for_deletion_at: DateTime.utc_now())
conn = get(conn, ~p"/sources")
refute html_response(conn, 200) =~ source.custom_name
end
test "omits sources who's media profile has marked_for_deletion_at set", %{conn: conn} do
media_profile = media_profile_fixture(marked_for_deletion_at: DateTime.utc_now())
source = source_fixture(media_profile_id: media_profile.id)
conn = get(conn, ~p"/sources")
refute html_response(conn, 200) =~ source.custom_name
end
end
@@ -268,6 +252,23 @@ defmodule PinchflatWeb.SourceControllerTest do
end
end
describe "sync_files_on_disk" do
test "forces a file sync", %{conn: conn} do
source = source_fixture()
assert [] = all_enqueued(worker: FileSyncingWorker)
post(conn, ~p"/sources/#{source.id}/sync_files_on_disk")
assert [_] = all_enqueued(worker: FileSyncingWorker)
end
test "redirects to the source page", %{conn: conn} do
source = source_fixture()
conn = post(conn, ~p"/sources/#{source.id}/sync_files_on_disk")
assert redirected_to(conn) == ~p"/sources/#{source.id}"
end
end
defp create_source(_) do
source = source_fixture()
media_item = media_item_with_attachments(%{source_id: source.id})
@@ -0,0 +1,55 @@
defmodule PinchflatWeb.Sources.IndexTableLiveTest do
use PinchflatWeb.ConnCase
import Phoenix.LiveViewTest
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Sources.Source
alias PinchflatWeb.Sources.IndexTableLive
describe "initial rendering" do
test "lists all sources", %{conn: conn} do
source = source_fixture()
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
assert html =~ source.custom_name
end
test "omits sources that have marked_for_deletion_at set", %{conn: conn} do
source = source_fixture(marked_for_deletion_at: DateTime.utc_now())
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
refute html =~ source.custom_name
end
test "omits sources who's media profile has marked_for_deletion_at set", %{conn: conn} do
media_profile = media_profile_fixture(marked_for_deletion_at: DateTime.utc_now())
source = source_fixture(media_profile_id: media_profile.id)
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
refute html =~ source.custom_name
end
end
describe "when a source is enabled or disabled" do
test "updates the source's enabled status", %{conn: conn} do
source = source_fixture(enabled: true)
{:ok, view, _html} = live_isolated(conn, IndexTableLive)
params = %{
"event" => "toggle_enabled",
"id" => source.id,
"value" => "false"
}
# Send an event to the server directly
render_change(view, "formless-input", params)
assert %{enabled: false} = Repo.get!(Source, source.id)
end
end
end

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