Compare commits

...

20 Commits

Author SHA1 Message Date
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
Kieran Eglin 7fb6fa3af4 Bumped version 2024-09-12 14:39:07 -07:00
Kieran bb8788f03d [Dev] Version updates 2024-09-12 (#388)
* Updated some deps

* Updated phoenix
2024-09-12 13:04:49 -07:00
Kieran ed4434512b [Dev] Speed up CI (#385)
* Moved around copy steps

* no-op change

* Reverted the no-op change

* Testing if docker-compose volume is needed

* Nope, it's needed

* Testing alternate driver

* Add containerd

* Bump version

* Revert
2024-09-12 12:38:12 -07:00
Kieran f6344d43d2 [Bugfix] Disallow concurrent slow-indexing runs for the same source (#384)
* Updated deps in case that does anything

* Kills running slow-indexes when a new slow-index is enqueued

* Revert deps upgrade (saving that for its own PR)
2024-09-10 14:16:10 -07:00
Kieran e0745bdfbe [Enhancement] Allow configuration of media container format (#383)
* Adds media container to media_profiles and updates option builder

* Added media container to media profile form
2024-09-10 11:44:57 -07:00
Kieran Eglin a0a02cb4be Updated docker-build-push version in docker release file as well 2024-09-10 11:42:10 -07:00
Kieran Eglin 7866f7258a Updated docker-build-push version 2024-09-10 11:36:54 -07:00
Kieran 5cd82f9dd5 Updated the aspect ratio threshold for short detection (#382) 2024-09-10 08:49:30 -07:00
Kieran Eglin c85e9cb5f1 Retry with a non-yanked version of ffmpeg I guess? 2024-08-28 09:58:53 -07:00
Kieran Eglin 4837dd44c9 Bumped version 2024-08-28 08:47:13 -07:00
Kieran Eglin e3f42c28d0 Opps - reversed the IPv6 logic 2024-08-27 20:25:53 -07:00
Kieran 4a11d47692 Locked ffmpeg to last known-good version (#373) 2024-08-27 20:20:53 -07:00
Kieran 612c760755 [Enhancement] Allow IPv6 (#372)
* Added option to enable IPv6 in runtime config

* Updated README
2024-08-27 20:08:53 -07:00
Kieran 14b8ecbe44 [Enhancement] Show error messages in-app for easier triage (#365)
* Added an improved error message screen with actionable details

* Added a basic 404 page

* fixed tests
2024-08-23 13:33:27 -07:00
Kieran a6c61ccd0d [Bugfix] Improve livestream detection (#362)
* Livestream status is now based off live_status instead of was_live

* Updated tests
2024-08-20 09:37:42 -07:00
Kieran Eglin 80209240d2 Updated logger to add dates in logfiles rather than just console output 2024-08-20 08:58:30 -07:00
46 changed files with 726 additions and 141 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push - name: Build and Push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./docker/selfhosted.Dockerfile file: ./docker/selfhosted.Dockerfile
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build docker image - name: Build docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./docker/dev.Dockerfile file: ./docker/dev.Dockerfile
+2 -1
View File
@@ -151,7 +151,8 @@ If you change this setting and it works well for you, please leave a comment on
| `LOG_LEVEL` | No | `debug` | Can be set to `info` but `debug` is strongly recommended | | `LOG_LEVEL` | No | `debug` | Can be set to `info` but `debug` is strongly recommended |
| `BASIC_AUTH_USERNAME` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) | | `BASIC_AUTH_USERNAME` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
| `BASIC_AUTH_PASSWORD` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) | | `BASIC_AUTH_PASSWORD` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
| `EXPOSE_FEED_ENDPOINTS` | No | | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) | | `EXPOSE_FEED_ENDPOINTS` | No | `false` | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) |
| `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) | | `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 | | `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 |
+3 -2
View File
@@ -41,7 +41,8 @@ config :pinchflat, PinchflatWeb.Endpoint,
adapter: Phoenix.Endpoint.Cowboy2Adapter, adapter: Phoenix.Endpoint.Cowboy2Adapter,
render_errors: [ render_errors: [
formats: [html: PinchflatWeb.ErrorHTML, json: PinchflatWeb.ErrorJSON], formats: [html: PinchflatWeb.ErrorHTML, json: PinchflatWeb.ErrorJSON],
layout: false root_layout: {PinchflatWeb.Layouts, :root},
layout: {PinchflatWeb.Layouts, :app}
], ],
pubsub_server: Pinchflat.PubSub, pubsub_server: Pinchflat.PubSub,
live_view: [signing_salt: "/t5878kO"] live_view: [signing_salt: "/t5878kO"]
@@ -91,7 +92,7 @@ config :tailwind,
] ]
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :console, config :logger, :default_formatter,
format: "$date $time $metadata[$level] | $message\n", format: "$date $time $metadata[$level] | $message\n",
metadata: [:request_id] metadata: [:request_id]
+1 -1
View File
@@ -67,7 +67,7 @@ config :pinchflat, PinchflatWeb.Endpoint,
config :pinchflat, dev_routes: true config :pinchflat, dev_routes: true
# Do not include metadata nor timestamps in development logs # Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n" config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such # Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive. # in production as building large stacktraces may be expensive.
+2 -1
View File
@@ -66,6 +66,7 @@ if config_env() == :prod do
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal")) journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
# For running PF in a subdirectory via a reverse proxy # For running PF in a subdirectory via a reverse proxy
base_route_path = System.get_env("BASE_ROUTE_PATH", "/") base_route_path = System.get_env("BASE_ROUTE_PATH", "/")
enable_ipv6 = String.length(System.get_env("ENABLE_IPV6", "")) > 0
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug")) config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
@@ -120,7 +121,7 @@ if config_env() == :prod do
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses. # for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0}, ip: if(enable_ipv6, do: {0, 0, 0, 0, 0, 0, 0, 0}, else: {0, 0, 0, 0}),
port: String.to_integer(System.get_env("PORT") || "4000") port: String.to_integer(System.get_env("PORT") || "4000")
], ],
url: [path: base_route_path], url: [path: base_route_path],
+5 -3
View File
@@ -49,14 +49,16 @@ ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8
# Create app directory and copy the Elixir projects into it.
WORKDIR /app WORKDIR /app
COPY . ./
COPY mix.exs mix.lock ./
# Install Elixir deps # Install Elixir deps
# RUN mix archive.install github hexpm/hex branch latest # NOTE: this has to be before the bulk copy to ensure that deps are cached
RUN MIX_ENV=dev mix deps.get && MIX_ENV=dev mix deps.compile RUN MIX_ENV=dev mix deps.get && MIX_ENV=dev mix deps.compile
RUN MIX_ENV=test mix deps.get && MIX_ENV=test mix deps.compile RUN MIX_ENV=test mix deps.get && MIX_ENV=test mix deps.compile
COPY . ./
# Gives us iex shell history # Gives us iex shell history
ENV ERL_AFLAGS="-kernel shell_history enabled" ENV ERL_AFLAGS="-kernel shell_history enabled"
+3 -3
View File
@@ -27,10 +27,10 @@ RUN apt-get update -y && \
# Hex and Rebar # Hex and Rebar
mix local.hex --force && \ mix local.hex --force && \
mix local.rebar --force && \ mix local.rebar --force && \
# FFmpeg # FFmpeg (latest build that doesn't cause an illegal instruction error for some users - see #347)
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \ export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" ;; \ "linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/autobuild-2024-07-30-14-10/ffmpeg-N-116468-g0e09f6d690-linux64-gpl.tar.xz" ;; \
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz" ;; \ "linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/autobuild-2024-07-30-14-10/ffmpeg-N-116468-g0e09f6d690-linuxarm64-gpl.tar.xz" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \ curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \ tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \
@@ -127,11 +127,12 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
defp quality_options(media_profile) do defp quality_options(media_profile) do
vcodec = Settings.get!(:video_codec_preference) vcodec = Settings.get!(:video_codec_preference)
acodec = Settings.get!(:audio_codec_preference) acodec = Settings.get!(:audio_codec_preference)
container = media_profile.media_container
case media_profile.preferred_resolution do case media_profile.preferred_resolution do
# Also be aware that :audio disabled all embedding options for subtitles # Also be aware that :audio disabled all embedding options for subtitles
:audio -> :audio ->
[:extract_audio, format_sort: "+acodec:#{acodec}"] [:extract_audio, format_sort: "+acodec:#{acodec}", audio_format: container || "best"]
resolution_atom -> resolution_atom ->
{resolution_string, _} = {resolution_string, _} =
@@ -141,7 +142,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
[ [
# Since Plex doesn't support reading metadata from MKV # Since Plex doesn't support reading metadata from MKV
remux_video: "mp4", remux_video: container || "mp4",
format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}" format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}"
] ]
end end
@@ -12,6 +12,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Media.FileSyncing
alias Pinchflat.Downloading.MediaDownloader alias Pinchflat.Downloading.MediaDownloader
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner 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) 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) run_user_script(:media_downloaded, updated_media_item)
:ok :ok
+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
+2
View File
@@ -27,6 +27,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
shorts_behaviour shorts_behaviour
livestream_behaviour livestream_behaviour
preferred_resolution preferred_resolution
media_container
redownload_delay_days redownload_delay_days
marked_for_deletion_at marked_for_deletion_at
)a )a
@@ -65,6 +66,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
field :preferred_resolution, Ecto.Enum, values: ~w(4320p 2160p 1080p 720p 480p 360p audio)a, default: :"1080p" field :preferred_resolution, Ecto.Enum, values: ~w(4320p 2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
field :media_container, :string, default: nil
field :marked_for_deletion_at, :utc_datetime field :marked_for_deletion_at, :utc_datetime
@@ -28,7 +28,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
""" """
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker") 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)
end end
+9 -21
View File
@@ -53,20 +53,6 @@ defmodule Pinchflat.Tasks do
) )
end end
@doc """
Returns the list of pending tasks for a given record type and ID. Optionally allows you to specify
which worker to include.
Returns [%Task{}, ...]
"""
def list_pending_tasks_for(record, worker_name \\ nil) do
list_tasks_for(
record,
worker_name,
[:available, :scheduled, :retryable]
)
end
@doc """ @doc """
Gets a single task. Gets a single task.
@@ -127,13 +113,13 @@ defmodule Pinchflat.Tasks do
@doc """ @doc """
Deletes all tasks attached to a given record, cancelling any attached jobs. Deletes all tasks attached to a given record, cancelling any attached jobs.
Optionally allows you to specify which worker to include. Optionally allows you to specify which worker and job states to include.
Returns :ok Returns :ok
""" """
def delete_tasks_for(record, worker_name \\ nil) do def delete_tasks_for(record, worker_name \\ nil, job_states \\ Oban.Job.states()) do
record record
|> list_tasks_for(worker_name) |> list_tasks_for(worker_name, job_states)
|> Enum.each(&delete_task/1) |> Enum.each(&delete_task/1)
end end
@@ -143,10 +129,12 @@ defmodule Pinchflat.Tasks do
Returns :ok Returns :ok
""" """
def delete_pending_tasks_for(record, worker_name \\ nil) do def delete_pending_tasks_for(record, worker_name \\ nil, opts \\ []) do
record include_executing = Keyword.get(opts, :include_executing, false)
|> list_pending_tasks_for(worker_name) base_job_states = [:available, :scheduled, :retryable]
|> Enum.each(&delete_task/1) job_states = if include_executing, do: base_job_states ++ [:executing], else: base_job_states
delete_tasks_for(record, worker_name, job_states)
end end
@doc """ @doc """
+18
View File
@@ -20,6 +20,24 @@ defmodule Pinchflat.Utils.FilesystemUtils do
end end
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 """ @doc """
Generates a temporary file and returns its path. The file is empty and has the given type. 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. 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
+3 -3
View File
@@ -89,7 +89,7 @@ defmodule Pinchflat.YtDlp.Media do
NOTE: playlist_index is really only useful for playlists that will never change their order. NOTE: playlist_index is really only useful for playlists that will never change their order.
""" """
def indexing_output_template do def indexing_output_template do
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j" "%(.{id,title,live_status,webpage_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j"
end end
@doc """ @doc """
@@ -104,7 +104,7 @@ defmodule Pinchflat.YtDlp.Media do
title: response["title"], title: response["title"],
description: response["description"], description: response["description"],
original_url: response["webpage_url"], original_url: response["webpage_url"],
livestream: !!response["was_live"], livestream: !!response["live_status"] && response["live_status"] != "not_live",
duration_seconds: response["duration"] && round(response["duration"]), duration_seconds: response["duration"] && round(response["duration"]),
short_form_content: response["webpage_url"] && short_form_content?(response), short_form_content: response["webpage_url"] && short_form_content?(response),
uploaded_at: response["upload_date"] && parse_uploaded_at(response), uploaded_at: response["upload_date"] && parse_uploaded_at(response),
@@ -124,7 +124,7 @@ defmodule Pinchflat.YtDlp.Media do
# #
# These don't fail if duration or aspect_ratio are missing # These don't fail if duration or aspect_ratio are missing
# due to Elixir's comparison semantics # due to Elixir's comparison semantics
response["duration"] <= 60 && response["aspect_ratio"] < 0.8 response["duration"] <= 60 && response["aspect_ratio"] <= 0.85
end end
end end
+1 -8
View File
@@ -1,14 +1,7 @@
defmodule PinchflatWeb.ErrorHTML do defmodule PinchflatWeb.ErrorHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
# If you want to customize your error pages, embed_templates "error_html/*"
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/pinchflat_web/controllers/error_html/404.html.heex
# * lib/pinchflat_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on # The default is to render a plain text page based on
# the template name. For example, "404.html" becomes # the template name. For example, "404.html" becomes
@@ -0,0 +1,3 @@
<section>
<h2 class="text-title-md2 font-bold text-white">404 (not found)</h2>
</section>
@@ -0,0 +1,29 @@
<section>
<h2 class="text-title-md2 font-bold text-white">Internal Server Error</h2>
<p class="text-body-md text-white mt-2">
This shouldn't happen! Please make a
<.inline_link href="https://github.com/kieraneglin/pinchflat/issues/new/choose">GitHub issue</.inline_link>
with the following information:
</p>
<ul class="list-disc ml-8 mb-8">
<li>What you were doing when you saw this page</li>
<li>
Your system details and logs from
<.inline_link href={~p"/app_info"}>app info</.inline_link>
</li>
<li>All the information in the textarea below (use select all + copy)</li>
</ul>
<textarea class="w-full min-h-96 font-mono inline-block rounded-lg" readonly>
**Status**:
`<%= if Map.has_key?(assigns, :status), do: @status, else: "" %>`
**Reason**:
`<%= if Map.has_key?(assigns, :reason), do: inspect(@reason), else: "" %>`
**Stacktrace**:
```
<%= if Map.has_key?(assigns, :stack), do: Exception.format_stacktrace(@stack), else: "" %>
```
</textarea>
</section>
@@ -1,4 +1,10 @@
<.simple_form :let={f} for={@changeset} action={@action}> <.simple_form
:let={f}
for={@changeset}
action={@action}
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
>
<.error :if={@changeset.action}> <.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below. Oops, something went wrong! Please check the errors below.
</.error> </.error>
@@ -30,10 +36,14 @@
</.input> </.input>
</section> </section>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <section class="flex justify-between items-center mt-8">
General Options <h3 class="text-2xl text-white">
</h3> General Options
</h3>
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
</span>
</section>
<section x-data="{ <section x-data="{
presets: { presets: {
default: 'Default', default: 'Default',
@@ -203,6 +213,16 @@
/> />
</section> </section>
<section x-show="advancedMode">
<.input
field={f[:media_container]}
type="text"
label="Media Container"
placeholder="mp4"
help="Don't change this if you're going to consume media via Plex. Leave blank for default"
/>
</section>
<section x-data="{ presets: { default: null, media_center: 1, audio: null, archiving: 1 } }"> <section x-data="{ presets: { default: null, media_center: 1, audio: null, archiving: 1 } }">
<.input <.input
field={f[:redownload_delay_days]} field={f[:redownload_delay_days]}
@@ -8,6 +8,7 @@ defmodule PinchflatWeb.Sources.SourceController do
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Media.FileSyncingWorker
alias Pinchflat.Sources.SourceDeletionWorker alias Pinchflat.Sources.SourceDeletionWorker
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
@@ -175,6 +176,15 @@ defmodule PinchflatWeb.Sources.SourceController do
) )
end 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 defp wrap_forced_action(conn, source_id, message, fun) do
source = Sources.get_source!(source_id) source = Sources.get_source!(source_id)
fun.(source) fun.(source)
@@ -38,7 +38,7 @@
<.link <.link
href={~p"/sources/#{@source}/force_download_pending"} href={~p"/sources/#{@source}/force_download_pending"}
method="post" 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 Download Pending
</.link> </.link>
@@ -47,7 +47,7 @@
<.link <.link
href={~p"/sources/#{@source}/force_redownload"} href={~p"/sources/#{@source}/force_redownload"}
method="post" 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 Redownload Existing
</.link> </.link>
@@ -70,6 +70,15 @@
Refresh Metadata Refresh Metadata
</.link> </.link>
</:option> </: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> <:option>
<div class="h-px w-full bg-bodydark2"></div> <div class="h-px w-full bg-bodydark2"></div>
</:option> </:option>
@@ -12,7 +12,7 @@
<section x-data="{ mediaProfileId: null }"> <section x-data="{ mediaProfileId: null }">
<section class="flex justify-between items-center mt-4"> <section class="flex justify-between items-center mt-4">
<h3 class=" text-2xl text-black dark:text-white"> <h3 class="text-2xl text-white">
General Options General Options
</h3> </h3>
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode"> <span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
+1
View File
@@ -40,6 +40,7 @@ defmodule PinchflatWeb.Router do
post "/force_redownload", Sources.SourceController, :force_redownload post "/force_redownload", Sources.SourceController, :force_redownload
post "/force_index", Sources.SourceController, :force_index post "/force_index", Sources.SourceController, :force_index
post "/force_metadata_refresh", Sources.SourceController, :force_metadata_refresh 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 resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] do
post "/force_download", MediaItems.MediaItemController, :force_download post "/force_download", MediaItems.MediaItemController, :force_download
+5 -5
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2024.8.14", version: "2024.10.2",
elixir: "~> 1.17", elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@@ -46,10 +46,10 @@ defmodule Pinchflat.MixProject do
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
defp deps do defp deps do
[ [
{:phoenix, "~> 1.7.10"}, {:phoenix, "~> 1.7.14"},
{:phoenix_ecto, "~> 4.4"}, {:phoenix_ecto, "~> 4.4"},
{:ecto, "~> 3.11.2"}, {:ecto, "~> 3.12.3"},
{:ecto_sql, "~> 3.10"}, {:ecto_sql, "~> 3.12"},
{:ecto_sqlite3, ">= 0.0.0"}, {:ecto_sqlite3, ">= 0.0.0"},
{:ecto_sqlite3_extras, "~> 1.2.0"}, {:ecto_sqlite3_extras, "~> 1.2.0"},
{:phoenix_html, "~> 3.3"}, {:phoenix_html, "~> 3.3"},
@@ -67,7 +67,7 @@ defmodule Pinchflat.MixProject do
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"}, {:dns_cluster, "~> 0.1.1"},
{:plug_cowboy, "~> 2.5"}, {:plug_cowboy, "~> 2.5"},
{:oban, "~> 2.16"}, {:oban, "~> 2.17"},
{:nimble_parsec, "~> 1.4"}, {:nimble_parsec, "~> 1.4"},
{:timex, "~> 3.0"}, {:timex, "~> 3.0"},
{:mox, "~> 1.0", only: :test}, {:mox, "~> 1.0", only: :test},
+21 -21
View File
@@ -1,26 +1,26 @@
%{ %{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.9", "e8d3364f310da6ce6463c3dd20cf90ae7bbecbf6c5203b98bf9b48035592649b", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9dcab3d0f3038621f1601f13539e7a9ee99843862e66ad62827b0c42b2f58a54"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"}, "credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "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.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"dns_cluster": {:hex, :dns_cluster, "0.1.2", "3eb5be824c7888dadf9781018e1a5f1d3d1113b333c50bce90fb1b83df1015f2", [:mix], [], "hexpm", "7494272040f847637bbdb01bcdf4b871e82daf09b813e7d3cb3b84f112c6f2f8"}, "dns_cluster": {:hex, :dns_cluster, "0.1.2", "3eb5be824c7888dadf9781018e1a5f1d3d1113b333c50bce90fb1b83df1015f2", [:mix], [], "hexpm", "7494272040f847637bbdb01bcdf4b871e82daf09b813e7d3cb3b84f112c6f2f8"},
"ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [: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", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, "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.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.17.2", "200226e057f76c40be55fbac77771eb1a233260ab8ec7283f5da6d9402bde8de", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "a3838919c5a34c268c28cafab87b910bcda354a9a4e778658da46c149bb2c1da"},
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"}, "ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
"elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"}, "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"},
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"}, "ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
"expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"},
"exqlite": {:hex, :exqlite, "0.19.0", "0f3ee29e35bed38552dd0ed59600aa81c78f867f5b5ff0e17d330148e0465483", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "55a8fbb0443f03d4a256e3458bd1203eff5037a6624b76460eaaa9080f462b06"}, "exqlite": {:hex, :exqlite, "0.23.0", "6e851c937a033299d0784994c66da24845415072adbc455a337e20087bce9033", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "404341cceec5e6466aaed160cf0b58be2019b60af82588c215e1224ebd3ec831"},
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"}, "finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"},
@@ -29,40 +29,40 @@
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
"mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"},
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"},
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
"oban": {:hex, :oban, "2.17.3", "ddfd5710aadcd550d2e174c8d73ce5f1865601418cf54a91775f20443fb832b7", [:mix], [{:ecto_sql, "~> 3.6", [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", "452eada8bfe0d0fefd0740ab5fa8cf3ef6c375df0b4a3c3805d179022a04738a"}, "oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [: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.6", [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", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, "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_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"},
"phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.20.3", "8b6406bc0a451f295407d7acff7f234a6314be5bbe0b3f90ed82b07f50049878", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8e4385e05618b424779f894ed2df97d3c7518b7285fcd11979077ae6226466b"}, "phoenix_live_view": {:hex, :phoenix_live_view, "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_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [: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", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"},
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"}, "swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"}, "table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"},
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, "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"}, "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"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [: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", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, "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"},
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 433 KiB

@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddMediaContainerToMediaProfiles do
use Ecto.Migration
def change do
alter table(:media_profiles) do
add :media_container, :string
end
end
end
@@ -252,7 +252,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end end
end end
describe "build/1 when testing quality options" do describe "build/1 when testing media quality and format options" do
test "includes quality options" do test "includes quality options" do
resolutions = ["360", "480", "720", "1080", "2160", "4320"] resolutions = ["360", "480", "720", "1080", "2160", "4320"]
@@ -291,6 +291,22 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:format_sort, "res:1080,+codec:av01:aac"} in res assert {:format_sort, "res:1080,+codec:av01:aac"} in res
end end
test "includes custom remux target for videos if specified", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{media_container: "mkv"})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:remux_video, "mkv"} in res
end
test "includes custom format target for audio if specified", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{media_container: "flac", preferred_resolution: :audio})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:audio_format, "flac"} in res
end
end end
describe "build/1 when testing sponsorblock options" do describe "build/1 when testing sponsorblock options" do
@@ -236,6 +236,26 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true}) perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end end
test "deletes old files if the media item has been updated" do
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 end
describe "perform/1 when testing user script callbacks" do describe "perform/1 when testing user script callbacks" do
@@ -101,7 +101,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
id: "video2", id: "video2",
title: "Video 2", title: "Video 2",
webpage_url: "https://example.com/shorts/video2", webpage_url: "https://example.com/shorts/video2",
was_live: true, live_status: "is_live",
description: "desc2", description: "desc2",
aspect_ratio: 1.67, aspect_ratio: 1.67,
duration: 345.67, duration: 345.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
@@ -43,11 +43,12 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
test "it extracts the livestream flag", %{metadata: metadata} do test "it extracts the livestream flag", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata) result = Parser.parse_for_media_item(metadata)
assert result.livestream == metadata["was_live"] assert metadata["live_status"] == "not_live"
refute result.livestream
end end
test "the livestream flag defaults to false", %{metadata: metadata} do test "the livestream flag defaults to false", %{metadata: metadata} do
metadata = Map.put(metadata, "was_live", nil) metadata = Map.put(metadata, "live_status", nil)
result = Parser.parse_for_media_item(metadata) result = Parser.parse_for_media_item(metadata)
@@ -41,6 +41,17 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
test "deletes any executing media collection tasks for the source" do
source = source_fixture()
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id)
Repo.update_all(from(Oban.Job, where: [id: ^task.job_id], update: [set: [state: "executing"]]), [])
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
test "deletes any pending media tasks for the source" do test "deletes any pending media tasks for the source" do
source = source_fixture() source = source_fixture()
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id})) {:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
@@ -173,7 +184,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
id: "video3", id: "video3",
title: "Video 3", title: "Video 3",
was_live: false, live_status: "not_live",
description: "desc3", description: "desc3",
# Only focusing on these because these are passed to functions that # Only focusing on these because these are passed to functions that
# could fail if they're not present # could fail if they're not present
@@ -289,7 +300,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
id: "video2", id: "video2",
title: "Video 2", title: "Video 2",
webpage_url: "https://example.com/shorts/video2", webpage_url: "https://example.com/shorts/video2",
was_live: true, live_status: "is_live",
description: "desc2", description: "desc2",
aspect_ratio: 1.67, aspect_ratio: 1.67,
duration: 345.67, duration: 345.67,
+42 -42
View File
@@ -12,7 +12,7 @@ defmodule Pinchflat.TasksTest do
@invalid_attrs %{job_id: nil} @invalid_attrs %{job_id: nil}
describe "schema" do describe "schema" do
test "it deletes a task when the job gets deleted" do test "deletes a task when the job gets deleted" do
task = Repo.preload(task_fixture(), [:job]) task = Repo.preload(task_fixture(), [:job])
{:ok, _} = Repo.delete(task.job) {:ok, _} = Repo.delete(task.job)
@@ -20,7 +20,7 @@ defmodule Pinchflat.TasksTest do
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
test "it does not delete the other record when a job gets deleted" do test "does not delete the other record when a job gets deleted" do
task = Repo.preload(task_fixture(), [:source, :job]) task = Repo.preload(task_fixture(), [:source, :job])
{:ok, _} = Repo.delete(task.job) {:ok, _} = Repo.delete(task.job)
@@ -30,21 +30,21 @@ defmodule Pinchflat.TasksTest do
end end
describe "list_tasks/0" do describe "list_tasks/0" do
test "it returns all tasks" do test "returns all tasks" do
task = task_fixture() task = task_fixture()
assert Tasks.list_tasks() == [task] assert Tasks.list_tasks() == [task]
end end
end end
describe "list_tasks_for/3" do describe "list_tasks_for/3" do
test "it lets you specify which record type/ID to join on" do test "lets you specify which record type/ID to join on" do
source = source_fixture() source = source_fixture()
task = task_fixture(source_id: source.id) task = task_fixture(source_id: source.id)
assert Tasks.list_tasks_for(source, nil, [:available]) == [task] assert Tasks.list_tasks_for(source, nil, [:available]) == [task]
end end
test "it lets you specify which job states to include" do test "lets you specify which job states to include" do
source = source_fixture() source = source_fixture()
task = task_fixture(source_id: source.id) task = task_fixture(source_id: source.id)
@@ -52,7 +52,7 @@ defmodule Pinchflat.TasksTest do
assert Tasks.list_tasks_for(source, nil, [:cancelled]) == [] assert Tasks.list_tasks_for(source, nil, [:cancelled]) == []
end end
test "it lets you specify which worker to include" do test "lets you specify which worker to include" do
source = source_fixture() source = source_fixture()
task = task_fixture(source_id: source.id) task = task_fixture(source_id: source.id)
@@ -60,7 +60,7 @@ defmodule Pinchflat.TasksTest do
assert Tasks.list_tasks_for(source, "FooBarWorker") == [] assert Tasks.list_tasks_for(source, "FooBarWorker") == []
end end
test "it includes all workers if no worker is specified" do test "includes all workers if no worker is specified" do
source = source_fixture() source = source_fixture()
task = task_fixture(source_id: source.id) task = task_fixture(source_id: source.id)
@@ -68,32 +68,8 @@ defmodule Pinchflat.TasksTest do
end end
end end
describe "list_pending_tasks_for/3" do
test "it lists pending tasks" do
source = source_fixture()
task = task_fixture(source_id: source.id)
assert Tasks.list_pending_tasks_for(source) == [task]
end
test "it does not list non-pending tasks" do
task = Repo.preload(task_fixture(), [:job, :source])
:ok = Oban.cancel_job(task.job)
assert Tasks.list_pending_tasks_for(task.source) == []
end
test "it lets you specify which worker to include" do
source = source_fixture()
task = task_fixture(source_id: source.id)
assert Tasks.list_pending_tasks_for(source, "TestJobWorker") == [task]
assert Tasks.list_pending_tasks_for(source, "FooBarWorker") == []
end
end
describe "get_task!/1" do describe "get_task!/1" do
test "it returns the task with given id" do test "returns the task with given id" do
task = task_fixture() task = task_fixture()
assert Tasks.get_task!(task.id) == task assert Tasks.get_task!(task.id) == task
end end
@@ -132,7 +108,7 @@ defmodule Pinchflat.TasksTest do
end end
describe "create_job_with_task/2" do describe "create_job_with_task/2" do
test "it enqueues the given job" do test "enqueues the given job" do
media_item = media_item_fixture() media_item = media_item_fixture()
refute_enqueued(worker: TestJobWorker) refute_enqueued(worker: TestJobWorker)
@@ -140,7 +116,7 @@ defmodule Pinchflat.TasksTest do
assert_enqueued(worker: TestJobWorker) assert_enqueued(worker: TestJobWorker)
end end
test "it creates a task record if successful" do test "creates a task record if successful" do
source = source_fixture() source = source_fixture()
assert {:ok, %Task{} = task} = Tasks.create_job_with_task(TestJobWorker.new(%{}), source) assert {:ok, %Task{} = task} = Tasks.create_job_with_task(TestJobWorker.new(%{}), source)
@@ -148,7 +124,7 @@ defmodule Pinchflat.TasksTest do
assert task.source_id == source.id assert task.source_id == source.id
end end
test "it returns an error if the job already exists" do test "returns an error if the job already exists" do
source = source_fixture() source = source_fixture()
job = TestJobWorker.new(%{foo: "bar"}, unique: [period: :infinity]) job = TestJobWorker.new(%{foo: "bar"}, unique: [period: :infinity])
@@ -156,7 +132,7 @@ defmodule Pinchflat.TasksTest do
assert {:error, :duplicate_job} = Tasks.create_job_with_task(job, source) assert {:error, :duplicate_job} = Tasks.create_job_with_task(job, source)
end end
test "it returns an error if the job fails to enqueue" do test "returns an error if the job fails to enqueue" do
source = source_fixture() source = source_fixture()
assert {:error, %Ecto.Changeset{}} = Tasks.create_job_with_task(%Ecto.Changeset{}, source) assert {:error, %Ecto.Changeset{}} = Tasks.create_job_with_task(%Ecto.Changeset{}, source)
@@ -181,7 +157,7 @@ defmodule Pinchflat.TasksTest do
end end
describe "delete_tasks_for/2" do describe "delete_tasks_for/2" do
test "it deletes tasks attached to a source" do test "deletes tasks attached to a source" do
source = source_fixture() source = source_fixture()
task = task_fixture(source_id: source.id) task = task_fixture(source_id: source.id)
@@ -189,7 +165,7 @@ defmodule Pinchflat.TasksTest do
assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end
end end
test "it deletes the tasks attached to a media_item" do test "deletes the tasks attached to a media_item" do
media_item = media_item_fixture() media_item = media_item_fixture()
task = task_fixture(media_item_id: media_item.id) task = task_fixture(media_item_id: media_item.id)
@@ -208,6 +184,17 @@ defmodule Pinchflat.TasksTest do
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
test "deletion can specify which states to include" do
source = source_fixture()
task = task_fixture(source_id: source.id)
assert :ok = Tasks.delete_tasks_for(source, nil, [:executing])
assert Repo.reload!(task)
assert :ok = Tasks.delete_tasks_for(source, nil, [:available])
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
test "deletion does not impact unintended records" do test "deletion does not impact unintended records" do
source = source_fixture() source = source_fixture()
task = task_fixture(source_id: source.id) task = task_fixture(source_id: source.id)
@@ -221,7 +208,7 @@ defmodule Pinchflat.TasksTest do
end end
describe "delete_pending_tasks_for/1" do describe "delete_pending_tasks_for/1" do
test "it deletes pending tasks attached to a source" do test "deletes pending tasks attached to a source" do
source = source_fixture() source = source_fixture()
task = task_fixture(source_id: source.id) task = task_fixture(source_id: source.id)
@@ -229,7 +216,7 @@ defmodule Pinchflat.TasksTest do
assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end
end end
test "it does not delete non-pending tasks" do test "does not delete non-pending tasks" do
source = source_fixture() source = source_fixture()
task = Repo.preload(task_fixture(source_id: source.id), :job) task = Repo.preload(task_fixture(source_id: source.id), :job)
:ok = Oban.cancel_job(task.job) :ok = Oban.cancel_job(task.job)
@@ -238,7 +225,7 @@ defmodule Pinchflat.TasksTest do
assert Tasks.get_task!(task.id) assert Tasks.get_task!(task.id)
end end
test "it works on media_items" do test "works on media_items" do
media_item = media_item_fixture() media_item = media_item_fixture()
pending_task = task_fixture(media_item_id: media_item.id) pending_task = task_fixture(media_item_id: media_item.id)
cancelled_task = Repo.preload(task_fixture(media_item_id: media_item.id), :job) cancelled_task = Repo.preload(task_fixture(media_item_id: media_item.id), :job)
@@ -259,10 +246,23 @@ defmodule Pinchflat.TasksTest do
assert :ok = Tasks.delete_pending_tasks_for(media_item, "TestJobWorker") assert :ok = Tasks.delete_pending_tasks_for(media_item, "TestJobWorker")
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
test "deletion can optionall include executing tasks" do
source = source_fixture()
task = task_fixture(source_id: source.id)
from(Oban.Job, where: [id: ^task.job_id], update: [set: [state: "executing"]])
|> Repo.update_all([])
assert :ok = Tasks.delete_pending_tasks_for(source, nil, include_executing: false)
assert Repo.reload!(task)
assert :ok = Tasks.delete_pending_tasks_for(source, nil, include_executing: true)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
end end
describe "change_task/1" do describe "change_task/1" do
test "it returns a task changeset" do test "returns a task changeset" do
task = task_fixture() task = task_fixture()
assert %Ecto.Changeset{} = Tasks.change_task(task) assert %Ecto.Changeset{} = Tasks.change_task(task)
end end
@@ -37,6 +37,46 @@ defmodule Pinchflat.Utils.FilesystemUtilsTest do
end end
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 describe "generate_metadata_tmpfile/1" do
test "creates a tmpfile and returns its path" do test "creates a tmpfile and returns its path" do
res = FilesystemUtils.generate_metadata_tmpfile(:json) 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
+5 -3
View File
@@ -138,7 +138,9 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "indexing_output_template/0" do describe "indexing_output_template/0" do
test "contains all the greatest hits" do test "contains all the greatest hits" do
attrs = ~w(id title was_live webpage_url description aspect_ratio duration upload_date timestamp playlist_index)a attrs =
~w(id title live_status webpage_url description aspect_ratio duration upload_date timestamp playlist_index)a
formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j" formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j"
assert formatted_attrs == Media.indexing_output_template() assert formatted_attrs == Media.indexing_output_template()
@@ -152,7 +154,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
"title" => "Trying to Wheelie Without the Rear Brake", "title" => "Trying to Wheelie Without the Rear Brake",
"description" => "I'm not sure what I expected.", "description" => "I'm not sure what I expected.",
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"was_live" => false, "live_status" => "not_live",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 60, "duration" => 60,
"upload_date" => "20210101", "upload_date" => "20210101",
@@ -239,7 +241,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert %Media{duration_seconds: nil} = Media.response_to_struct(response) assert %Media{duration_seconds: nil} = Media.response_to_struct(response)
end end
test "sets livestream to false if the was_live field isn't present" do test "sets livestream to false if the live_status field isn't present" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
@@ -5,10 +5,10 @@ defmodule PinchflatWeb.ErrorHTMLTest do
import Phoenix.Template import Phoenix.Template
test "renders 404.html" do test "renders 404.html" do
assert render_to_string(PinchflatWeb.ErrorHTML, "404", "html", []) == "Not Found" assert render_to_string(PinchflatWeb.ErrorHTML, "404", "html", []) =~ "404 (not found)"
end end
test "renders 500.html" do test "renders 500.html" do
assert render_to_string(PinchflatWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" assert render_to_string(PinchflatWeb.ErrorHTML, "500", "html", []) =~ "Internal Server Error"
end end
end end
@@ -7,6 +7,7 @@ defmodule PinchflatWeb.SourceControllerTest do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Media.FileSyncingWorker
alias Pinchflat.Sources.SourceDeletionWorker alias Pinchflat.Sources.SourceDeletionWorker
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.Metadata.SourceMetadataStorageWorker alias Pinchflat.Metadata.SourceMetadataStorageWorker
@@ -268,6 +269,23 @@ defmodule PinchflatWeb.SourceControllerTest do
end end
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 defp create_source(_) do
source = source_fixture() source = source_fixture()
media_item = media_item_with_attachments(%{source_id: source.id}) media_item = media_item_with_attachments(%{source_id: source.id})
+36
View File
@@ -0,0 +1,36 @@
1
00:00:00,000 --> 00:00:02,500
Welcome to the Example Subtitle File!
2
00:00:03,000 --> 00:00:06,000
This is a demonstration of SRT subtitles.
3
00:00:07,000 --> 00:00:10,500
You can use SRT files to add subtitles to your videos.
4
00:00:12,000 --> 00:00:15,000
Each subtitle entry consists of a number, a timecode,
and the subtitle text.
5
00:00:16,000 --> 00:00:20,000
The timecode format is hours:minutes:seconds,milliseconds.
6
00:00:21,000 --> 00:00:25,000
You can adjust the timing to match your video.
7
00:00:26,000 --> 00:00:30,000
Make sure the subtitle text is clear and readable.
8
00:00:31,000 --> 00:00:35,000
And that's how you create an SRT subtitle file!
9
00:00:36,000 --> 00:00:40,000
Enjoy adding subtitles to your videos!
+16 -3
View File
@@ -73,16 +73,19 @@ defmodule Pinchflat.MediaFixtures do
"#{:rand.uniform(1_000_000)}" "#{:rand.uniform(1_000_000)}"
]) ])
stored_media_filepath = Path.join(base_dir, "#media.mp4") stored_media_filepath = Path.join(base_dir, "media.mp4")
thumbnail_filepath = Path.join(base_dir, "thumbnail.jpg") thumbnail_filepath = Path.join(base_dir, "thumbnail.jpg")
subtitle_filepath = Path.join(base_dir, "subtitle.en.srt")
FilesystemUtils.cp_p!(media_filepath_fixture(), stored_media_filepath) FilesystemUtils.cp_p!(media_filepath_fixture(), stored_media_filepath)
FilesystemUtils.cp_p!(thumbnail_filepath_fixture(), thumbnail_filepath) FilesystemUtils.cp_p!(thumbnail_filepath_fixture(), thumbnail_filepath)
FilesystemUtils.cp_p!(subtitle_filepath_fixture(), subtitle_filepath)
merged_attrs = merged_attrs =
Map.merge(attrs, %{ Map.merge(attrs, %{
media_filepath: stored_media_filepath, media_filepath: stored_media_filepath,
thumbnail_filepath: thumbnail_filepath thumbnail_filepath: thumbnail_filepath,
subtitle_filepaths: [["en", subtitle_filepath]]
}) })
media_item_fixture(merged_attrs) media_item_fixture(merged_attrs)
@@ -93,7 +96,7 @@ defmodule Pinchflat.MediaFixtures do
id: "video1", id: "video1",
title: "Video 1", title: "Video 1",
webpage_url: "https://example.com/video1", webpage_url: "https://example.com/video1",
was_live: false, live_status: "not_live",
description: "desc1", description: "desc1",
aspect_ratio: 1.67, aspect_ratio: 1.67,
duration: 123.45, duration: 123.45,
@@ -124,6 +127,16 @@ defmodule Pinchflat.MediaFixtures do
]) ])
end end
def subtitle_filepath_fixture do
Path.join([
File.cwd!(),
"test",
"support",
"files",
"subtitle.srt"
])
end
def infojson_filepath_fixture do def infojson_filepath_fixture do
Path.join([ Path.join([
File.cwd!(), File.cwd!(),
+3 -3
View File
@@ -81,7 +81,7 @@ defmodule Pinchflat.SourcesFixtures do
id: "video1", id: "video1",
title: "Video 1", title: "Video 1",
webpage_url: "https://example.com/video1", webpage_url: "https://example.com/video1",
was_live: false, live_status: "not_live",
description: "desc1", description: "desc1",
aspect_ratio: 1.67, aspect_ratio: 1.67,
duration: 12.34, duration: 12.34,
@@ -91,7 +91,7 @@ defmodule Pinchflat.SourcesFixtures do
id: "video2", id: "video2",
title: "Video 2", title: "Video 2",
webpage_url: "https://example.com/video2", webpage_url: "https://example.com/video2",
was_live: true, live_status: "is_live",
description: "desc2", description: "desc2",
aspect_ratio: 1.67, aspect_ratio: 1.67,
duration: 345.67, duration: 345.67,
@@ -101,7 +101,7 @@ defmodule Pinchflat.SourcesFixtures do
id: "video3", id: "video3",
title: "Video 3", title: "Video 3",
webpage_url: "https://example.com/video3", webpage_url: "https://example.com/video3",
was_live: false, live_status: "not_live",
description: "desc3", description: "desc3",
aspect_ratio: 1.0, aspect_ratio: 1.0,
duration: 678.90, duration: 678.90,