Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c0dd0bb6b | |||
| 7a374f1162 | |||
| a5afcf168b | |||
| 2f83fe03d2 | |||
| 3c10d78e78 | |||
| 8c0df65c0c | |||
| 47bb1cb98d | |||
| 20547e60e4 | |||
| 0fd3106cb8 | |||
| 108a141c65 | |||
| 6a1b7b0160 | |||
| 3c8d99196a | |||
| cae86953a0 | |||
| f661747a0c | |||
| 85feb67299 | |||
| 4843d7d6c2 | |||
| a0b9e49486 | |||
| 0163e85e76 |
@@ -53,7 +53,7 @@ If it doesn't work for your use case, please make a feature request! You can als
|
|||||||
- Self-contained - just one Docker container with no external dependencies
|
- Self-contained - just one Docker container with no external dependencies
|
||||||
- Powerful naming system so content is stored where and how you want it
|
- Powerful naming system so content is stored where and how you want it
|
||||||
- Easy-to-use web interface with presets to get you started right away
|
- Easy-to-use web interface with presets to get you started right away
|
||||||
- First-class support for media center apps like Plex, Jellyfin, and Kodi
|
- First-class support for media center apps like Plex, Jellyfin, and Kodi ([docs](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#how-do-i-get-media-into-plexjellyfinkodi))
|
||||||
- Supports serving RSS feeds to your favourite podcast app ([docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds))
|
- Supports serving RSS feeds to your favourite podcast app ([docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds))
|
||||||
- Automatically downloads new content from channels and playlists
|
- Automatically downloads new content from channels and playlists
|
||||||
- Uses a novel approach to download new content more quickly than other apps
|
- Uses a novel approach to download new content more quickly than other apps
|
||||||
@@ -63,7 +63,7 @@ If it doesn't work for your use case, please make a feature request! You can als
|
|||||||
- Allows automatically redownloading new media after a set period
|
- Allows automatically redownloading new media after a set period
|
||||||
- This can help improve the download quality of new content or improve SponsorBlock tags
|
- This can help improve the download quality of new content or improve SponsorBlock tags
|
||||||
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
|
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
|
||||||
- Advanced options like setting cutoff dates and filtering by title
|
- Advanced options like setting cutoff dates and filtering by title ([docs](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#i-only-want-certain-videos-from-a-source---how-can-i-only-download-those))
|
||||||
- Reliable hands-off operation
|
- Reliable hands-off operation
|
||||||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||||
- Sponsorblock integration
|
- Sponsorblock integration
|
||||||
@@ -131,9 +131,6 @@ You _must_ ensure the host directories you've mounted are writable by the user r
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> It's not recommended to run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media.
|
> It's not recommended to run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media.
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> If you need to run any command as root, you can run `su` from the container's shell as there is no password set for the root user.
|
|
||||||
|
|
||||||
### ADVANCED: Storing Pinchflat config directory on a network share
|
### ADVANCED: Storing Pinchflat config directory on a network share
|
||||||
|
|
||||||
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
|
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
|
||||||
@@ -155,7 +152,7 @@ If you change this setting and it works well for you, please leave a comment on
|
|||||||
| `ENABLE_IPV6` | No | `false` | Setting to _any_ non-blank value will enable IPv6 |
|
| `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, but prefix must be stripped. |
|
||||||
| `YT_DLP_WORKER_CONCURRENCY` | No | `2` | The number of concurrent workers that use `yt-dlp` _per queue_. Set to 1 if you're getting IP limited, otherwise don't touch it |
|
| `YT_DLP_WORKER_CONCURRENCY` | No | `2` | The number of concurrent workers that use `yt-dlp` _per queue_. Set to 1 if you're getting IP limited, otherwise don't touch it |
|
||||||
|
|
||||||
## EFF donations
|
## EFF donations
|
||||||
@@ -164,9 +161,9 @@ Prior to 2024-05-10, a portion of all donations were given to the [Electronic Fr
|
|||||||
|
|
||||||
The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down.
|
The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down.
|
||||||
|
|
||||||
## Pre-release disclaimer
|
## Stability disclaimer
|
||||||
|
|
||||||
This is pre-release software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges for now.
|
This software is in active development and anything can break at any time. I make no guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -79,11 +79,12 @@ if config_env() == :prod do
|
|||||||
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
||||||
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
|
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
|
||||||
expose_feed_endpoints: expose_feed_endpoints,
|
expose_feed_endpoints: expose_feed_endpoints,
|
||||||
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC",
|
# This is configured in application.ex
|
||||||
|
timezone: "UTC",
|
||||||
log_path: log_path,
|
log_path: log_path,
|
||||||
base_route_path: base_route_path
|
base_route_path: base_route_path
|
||||||
|
|
||||||
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
|
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/config/extras/elixir_tz_data")
|
||||||
|
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
database: db_path,
|
database: db_path,
|
||||||
|
|||||||
@@ -127,17 +127,6 @@ EXPOSE ${PORT}
|
|||||||
# Only copy the final release from the build stage
|
# Only copy the final release from the build stage
|
||||||
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
||||||
|
|
||||||
# NEVER do this if you're running in an environment where you don't trust the user
|
|
||||||
# (ie: most environments). This is only acceptable in a self-hosted environment.
|
|
||||||
# The user could just run the whole container as root and bypass this anyway so
|
|
||||||
# it's not a huge deal.
|
|
||||||
# This removes the root password to allow users to assume root if needed. This is
|
|
||||||
# preferrable to running the whole container as root so that the files/directories
|
|
||||||
# created by the app aren't owned by root and are therefore easier for other users
|
|
||||||
# and processes to interact with. If you want to just run the whole container as
|
|
||||||
# root, use --user 0:0 or something.
|
|
||||||
RUN passwd -d root
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=120s --start-period=10s \
|
HEALTHCHECK --interval=120s --start-period=10s \
|
||||||
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
|
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ defmodule Pinchflat.Application do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Application
|
use Application
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
|
check_and_update_timezone()
|
||||||
|
|
||||||
children = [
|
children = [
|
||||||
PinchflatWeb.Telemetry,
|
PinchflatWeb.Telemetry,
|
||||||
Pinchflat.Repo,
|
Pinchflat.Repo,
|
||||||
@@ -47,4 +50,20 @@ defmodule Pinchflat.Application do
|
|||||||
:ok = Oban.Telemetry.attach_default_logger()
|
:ok = Oban.Telemetry.attach_default_logger()
|
||||||
:telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, [])
|
:telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, [])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# This has to be here (rather than runtime.exs) since the `tzdata` application
|
||||||
|
# has to be started before we can check the timezone
|
||||||
|
defp check_and_update_timezone do
|
||||||
|
attempted_timezone = System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC"
|
||||||
|
|
||||||
|
valid_timezone =
|
||||||
|
if Tzdata.zone_exists?(attempted_timezone) do
|
||||||
|
attempted_timezone
|
||||||
|
else
|
||||||
|
Logger.warning("Invalid timezone #{attempted_timezone}, defaulting to UTC")
|
||||||
|
"UTC"
|
||||||
|
end
|
||||||
|
|
||||||
|
Application.put_env(:pinchflat, :timezone, valid_timezone)
|
||||||
|
end
|
||||||
end
|
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
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# NOTE: Since this is a date and not a datetime, we can't add logic to have to-the-minute
|
||||||
|
# comparison like we can with retention periods. We can only compare to the day.
|
||||||
defp delete_media_items_from_before_cutoff do
|
defp delete_media_items_from_before_cutoff do
|
||||||
deletable_media =
|
deletable_media =
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,6 +15,9 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||||
|
|
||||||
|
# Some fields should only be set on insert and not on update.
|
||||||
|
@fields_to_drop_on_update [:playlist_index]
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of media_items.
|
Returns the list of media_items.
|
||||||
|
|
||||||
@@ -131,8 +134,6 @@ defmodule Pinchflat.Media do
|
|||||||
"""
|
"""
|
||||||
def create_media_item_from_backend_attrs(source, media_attrs_struct) do
|
def create_media_item_from_backend_attrs(source, media_attrs_struct) do
|
||||||
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
|
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
|
||||||
# Some fields should only be set on insert and not on update.
|
|
||||||
fields_to_drop_on_update = [:playlist_index]
|
|
||||||
|
|
||||||
%MediaItem{}
|
%MediaItem{}
|
||||||
|> MediaItem.changeset(attrs)
|
|> MediaItem.changeset(attrs)
|
||||||
@@ -140,7 +141,7 @@ defmodule Pinchflat.Media do
|
|||||||
on_conflict: [
|
on_conflict: [
|
||||||
set:
|
set:
|
||||||
attrs
|
attrs
|
||||||
|> Map.drop(fields_to_drop_on_update)
|
|> Map.drop(@fields_to_drop_on_update)
|
||||||
|> Map.to_list()
|
|> Map.to_list()
|
||||||
],
|
],
|
||||||
conflict_target: [:source_id, :media_id]
|
conflict_target: [:source_id, :media_id]
|
||||||
@@ -153,8 +154,10 @@ defmodule Pinchflat.Media do
|
|||||||
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def update_media_item(%MediaItem{} = media_item, attrs) do
|
def update_media_item(%MediaItem{} = media_item, attrs) do
|
||||||
|
update_attrs = Map.drop(attrs, @fields_to_drop_on_update)
|
||||||
|
|
||||||
media_item
|
media_item
|
||||||
|> MediaItem.changeset(attrs)
|
|> MediaItem.changeset(update_attrs)
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
[mi, source],
|
[mi, source],
|
||||||
fragment("""
|
fragment("""
|
||||||
IFNULL(retention_period_days, 0) > 0 AND
|
IFNULL(retention_period_days, 0) > 0 AND
|
||||||
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
DATETIME(media_downloaded_at, '+' || retention_period_days || ' day') < DATETIME('now')
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -100,8 +100,8 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
# downloaded_at minus the redownload_delay_days is before the upload date
|
# downloaded_at minus the redownload_delay_days is before the upload date
|
||||||
fragment("""
|
fragment("""
|
||||||
IFNULL(redownload_delay_days, 0) > 0 AND
|
IFNULL(redownload_delay_days, 0) > 0 AND
|
||||||
DATETIME('now', '-' || redownload_delay_days || ' day') > uploaded_at AND
|
DATE('now', '-' || redownload_delay_days || ' day') > DATE(uploaded_at) AND
|
||||||
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < uploaded_at
|
DATE(media_downloaded_at, '-' || redownload_delay_days || ' day') < DATE(uploaded_at)
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -186,7 +186,13 @@ defmodule Pinchflat.Sources do
|
|||||||
{:ok, source_details} ->
|
{:ok, source_details} ->
|
||||||
add_source_details_by_collection_type(source, changeset, source_details)
|
add_source_details_by_collection_type(source, changeset, source_details)
|
||||||
|
|
||||||
{:error, runner_error, _status_code} ->
|
err ->
|
||||||
|
runner_error =
|
||||||
|
case err do
|
||||||
|
{:error, error_msg, _status_code} -> error_msg
|
||||||
|
{:error, error_msg} -> error_msg
|
||||||
|
end
|
||||||
|
|
||||||
Ecto.Changeset.add_error(
|
Ecto.Changeset.add_error(
|
||||||
changeset,
|
changeset,
|
||||||
:original_url,
|
:original_url,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -87,9 +87,11 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
Returns the output template for yt-dlp's indexing command.
|
Returns the output template for yt-dlp's indexing command.
|
||||||
|
|
||||||
NOTE: playlist_index is really only useful for playlists that will never change their order.
|
NOTE: playlist_index is really only useful for playlists that will never change their order.
|
||||||
|
NOTE: I've switched back to `original_url` (from `webpage_url`) since it's started indicating
|
||||||
|
if something is a short via the URL again
|
||||||
"""
|
"""
|
||||||
def indexing_output_template do
|
def indexing_output_template do
|
||||||
"%(.{id,title,live_status,webpage_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j"
|
"%(.{id,title,live_status,original_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j"
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -103,17 +105,17 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
media_id: response["id"],
|
media_id: response["id"],
|
||||||
title: response["title"],
|
title: response["title"],
|
||||||
description: response["description"],
|
description: response["description"],
|
||||||
original_url: response["webpage_url"],
|
original_url: response["original_url"],
|
||||||
livestream: !!response["live_status"] && response["live_status"] != "not_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["original_url"] && short_form_content?(response),
|
||||||
uploaded_at: response["upload_date"] && parse_uploaded_at(response),
|
uploaded_at: response["upload_date"] && parse_uploaded_at(response),
|
||||||
playlist_index: response["playlist_index"] || 0
|
playlist_index: response["playlist_index"] || 0
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp short_form_content?(response) do
|
defp short_form_content?(response) do
|
||||||
if String.contains?(response["webpage_url"], "/shorts/") do
|
if String.contains?(response["original_url"], "/shorts/") do
|
||||||
true
|
true
|
||||||
else
|
else
|
||||||
# Sometimes shorts are returned without /shorts/ in the URL,
|
# Sometimes shorts are returned without /shorts/ in the URL,
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
{:ok, format_source_details(parsed_json)}
|
{:ok, format_source_details(parsed_json)}
|
||||||
else
|
else
|
||||||
|
{:error, %Jason.DecodeError{}} -> {:error, "Error decoding JSON response"}
|
||||||
err -> err
|
err -> err
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,17 +62,24 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a block of text with each line broken into a separate span.
|
Renders a block of text with each line broken into a separate span and links highlighted.
|
||||||
"""
|
"""
|
||||||
attr :text, :string, required: true
|
attr :text, :string, required: true
|
||||||
|
|
||||||
def break_on_newline(assigns) do
|
def render_description(assigns) do
|
||||||
broken_text =
|
formatted_text =
|
||||||
assigns.text
|
Regex.split(~r{https?://\S+}, assigns.text, include_captures: true)
|
||||||
|
|> Enum.map(fn
|
||||||
|
"http" <> _ = url ->
|
||||||
|
Phoenix.HTML.Tag.content_tag(:a, url, class: "text-blue-500 hover:text-blue-300", href: url, target: "_blank")
|
||||||
|
|
||||||
|
text ->
|
||||||
|
text
|
||||||
|> String.split("\n", trim: false)
|
|> String.split("\n", trim: false)
|
||||||
|> Enum.intersperse(Phoenix.HTML.Tag.tag(:span, class: "inline-block mt-2"))
|
|> Enum.intersperse(Phoenix.HTML.Tag.tag(:span, class: "inline-block mt-2"))
|
||||||
|
end)
|
||||||
|
|
||||||
assigns = Map.put(assigns, :text, broken_text)
|
assigns = Map.put(assigns, :text, formatted_text)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<span><%= @text %></span>
|
<span><%= @text %></span>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ defmodule PinchflatWeb.Layouts do
|
|||||||
<.icon name={@icon} /> <%= @text %>
|
<.icon name={@icon} /> <%= @text %>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-bodydark2">
|
<span class="text-bodydark2">
|
||||||
<.icon name="hero-chevron-up" x-bind:class="{ 'rotate-180': selected }" />
|
<.icon name="hero-chevron-down" x-bind:class="{ 'rotate-180': selected }" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<:option>
|
<:option>
|
||||||
<span x-data="{ copied: false }" x-on:click={~s"
|
<span x-data="{ copied: false }" x-on:click={~s"
|
||||||
copyWithCallbacks(
|
copyWithCallbacks(
|
||||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`,
|
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`,
|
||||||
() => copied = true,
|
() => copied = true,
|
||||||
() => copied = false
|
() => copied = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<%= if media_type(@media_item) == :video do %>
|
<%= if media_type(@media_item) == :video do %>
|
||||||
<video controls class="max-h-96 w-full">
|
<video controls class="max-h-128 w-full">
|
||||||
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" />
|
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" />
|
||||||
Your browser does not support the video element.
|
Your browser does not support the video element.
|
||||||
</video>
|
</video>
|
||||||
|
|||||||
@@ -26,11 +26,12 @@
|
|||||||
<:tab title="Media" id="media">
|
<:tab title="Media" id="media">
|
||||||
<div class="flex flex-col gap-10 dark:text-white">
|
<div class="flex flex-col gap-10 dark:text-white">
|
||||||
<%= if media_file_exists?(@media_item) do %>
|
<%= if media_file_exists?(@media_item) do %>
|
||||||
<section class="grid grid-cols-1 xl:grid-cols-2 xl:gap-6 mt-6">
|
<section class="grid grid-cols-1 xl:gap-6 mt-6">
|
||||||
<div>
|
<div>
|
||||||
<.media_preview media_item={@media_item} />
|
<.media_preview media_item={@media_item} />
|
||||||
</div>
|
</div>
|
||||||
<aside class="mt-4 xl:mt-0">
|
<aside class="mt-4">
|
||||||
|
<h2 class="text-xl mb-2"><%= @media_item.title %></h2>
|
||||||
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
|
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
|
||||||
<div>
|
<div>
|
||||||
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div :if={@media_item.description} class="mt-4 text-bodydark">
|
<div :if={@media_item.description} class="mt-4 text-bodydark">
|
||||||
<.break_on_newline text={@media_item.description} />
|
<.render_description text={@media_item.description} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
<%= task.job.state %>
|
<%= task.job.state %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="Scheduled At">
|
<:col :let={task} label="Scheduled At">
|
||||||
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
|
<.datetime_in_zone datetime={task.job.scheduled_at} />
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<:option>
|
<:option>
|
||||||
<span x-data="{ copied: false }" x-on:click={~s"
|
<span x-data="{ copied: false }" x-on:click={~s"
|
||||||
copyWithCallbacks(
|
copyWithCallbacks(
|
||||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`,
|
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`,
|
||||||
() => copied = true,
|
() => copied = true,
|
||||||
() => copied = false
|
() => copied = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<:option>
|
<:option>
|
||||||
<span x-data="{ copied: false }" x-on:click={~s"
|
<span x-data="{ copied: false }" x-on:click={~s"
|
||||||
copyWithCallbacks(
|
copyWithCallbacks(
|
||||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`,
|
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`,
|
||||||
() => copied = true,
|
() => copied = true,
|
||||||
() => copied = false
|
() => copied = false
|
||||||
)
|
)
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
<%= task.job.state %>
|
<%= task.job.state %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="Scheduled At">
|
<:col :let={task} label="Scheduled At">
|
||||||
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
|
<.datetime_in_zone datetime={task.job.scheduled_at} />
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "2024.9.12",
|
version: "2024.10.30",
|
||||||
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,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
"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.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.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"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -46,6 +46,23 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
refute Repo.reload!(old_media_item).media_filepath
|
refute Repo.reload!(old_media_item).media_filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "deletes media files that are on their retention date per the 24-h clock" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date(2)
|
||||||
|
|
||||||
|
just_over_two_days_ago = now_minus(2, :days) |> DateTime.add(-1, :minute)
|
||||||
|
just_under_two_days_ago = now_minus(2, :days) |> DateTime.add(1, :minute)
|
||||||
|
|
||||||
|
Media.update_media_item(old_media_item, %{media_downloaded_at: just_over_two_days_ago})
|
||||||
|
Media.update_media_item(new_media_item, %{media_downloaded_at: just_under_two_days_ago})
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
assert File.exists?(new_media_item.media_filepath)
|
||||||
|
refute File.exists?(old_media_item.media_filepath)
|
||||||
|
assert Repo.reload!(new_media_item).media_filepath
|
||||||
|
refute Repo.reload!(old_media_item).media_filepath
|
||||||
|
end
|
||||||
|
|
||||||
test "sets culled_at and prevent_download" do
|
test "sets culled_at and prevent_download" do
|
||||||
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
||||||
|
|
||||||
@@ -106,6 +123,25 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
refute Repo.reload!(old_media_item).media_filepath
|
refute Repo.reload!(old_media_item).media_filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# NOTE: Since this is a date and not a datetime, we can't add logic to have to-the-minute
|
||||||
|
# comparison like we can with retention periods. We can only compare to the day.
|
||||||
|
test "doesn't cull media from on or after the cutoff date" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(2)
|
||||||
|
|
||||||
|
Media.update_media_item(old_media_item, %{uploaded_at: now_minus(2, :days)})
|
||||||
|
Media.update_media_item(new_media_item, %{uploaded_at: now_minus(1, :day)})
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
assert File.exists?(new_media_item.media_filepath)
|
||||||
|
assert File.exists?(old_media_item.media_filepath)
|
||||||
|
assert Repo.reload!(new_media_item).media_filepath
|
||||||
|
assert Repo.reload!(old_media_item).media_filepath
|
||||||
|
|
||||||
|
refute Repo.reload!(new_media_item).culled_at
|
||||||
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
|
end
|
||||||
|
|
||||||
test "sets culled_at but not prevent_download" do
|
test "sets culled_at but not prevent_download" do
|
||||||
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date()
|
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date()
|
||||||
|
|
||||||
@@ -131,23 +167,6 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
refute Repo.reload!(old_media_item).culled_at
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
end
|
end
|
||||||
|
|
||||||
test "doesn't cull media from on or after the cutoff date" do
|
|
||||||
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(2)
|
|
||||||
|
|
||||||
Media.update_media_item(old_media_item, %{uploaded_at: now_minus(2, :days)})
|
|
||||||
Media.update_media_item(new_media_item, %{uploaded_at: now_minus(1, :day)})
|
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
|
||||||
|
|
||||||
assert File.exists?(new_media_item.media_filepath)
|
|
||||||
assert File.exists?(old_media_item.media_filepath)
|
|
||||||
assert Repo.reload!(new_media_item).media_filepath
|
|
||||||
assert Repo.reload!(old_media_item).media_filepath
|
|
||||||
|
|
||||||
refute Repo.reload!(new_media_item).culled_at
|
|
||||||
refute Repo.reload!(old_media_item).culled_at
|
|
||||||
end
|
|
||||||
|
|
||||||
test "doesn't cull media items that have prevent_culling set" do
|
test "doesn't cull media items that have prevent_culling set" do
|
||||||
{_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date()
|
{_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date()
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
Phoenix.json_library().encode!(%{
|
Phoenix.json_library().encode!(%{
|
||||||
id: "video2",
|
id: "video2",
|
||||||
title: "Video 2",
|
title: "Video 2",
|
||||||
webpage_url: "https://example.com/shorts/video2",
|
original_url: "https://example.com/shorts/video2",
|
||||||
live_status: "is_live",
|
live_status: "is_live",
|
||||||
description: "desc2",
|
description: "desc2",
|
||||||
aspect_ratio: 1.67,
|
aspect_ratio: 1.67,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -441,6 +441,13 @@ defmodule Pinchflat.MediaTest do
|
|||||||
assert Media.pending_download?(media_item)
|
assert Media.pending_download?(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns true if the cutoff date is equal to the upload date" do
|
||||||
|
source = source_fixture(%{download_cutoff_date: now_minus(2, :days)})
|
||||||
|
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
|
||||||
|
|
||||||
|
assert Media.pending_download?(media_item)
|
||||||
|
end
|
||||||
|
|
||||||
test "returns false if there is a cutoff date after the media's upload date" do
|
test "returns false if there is a cutoff date after the media's upload date" do
|
||||||
source = source_fixture(%{download_cutoff_date: now_minus(1, :day)})
|
source = source_fixture(%{download_cutoff_date: now_minus(1, :day)})
|
||||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
|
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
|
||||||
@@ -705,6 +712,21 @@ defmodule Pinchflat.MediaTest do
|
|||||||
assert media_item.media_filepath == update_attrs.media_filepath
|
assert media_item.media_filepath == update_attrs.media_filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "updating strips playlist_index from the provided attrs" do
|
||||||
|
media_item = media_item_fixture(playlist_index: 5)
|
||||||
|
|
||||||
|
update_attrs = %{
|
||||||
|
media_id: Faker.String.base64(12),
|
||||||
|
title: Faker.Commerce.product_name(),
|
||||||
|
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
||||||
|
source_id: source_fixture().id,
|
||||||
|
playlist_index: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, %MediaItem{} = media_item} = Media.update_media_item(media_item, update_attrs)
|
||||||
|
assert media_item.playlist_index == 5
|
||||||
|
end
|
||||||
|
|
||||||
test "updating with invalid data returns error changeset" do
|
test "updating with invalid data returns error changeset" do
|
||||||
media_item = media_item_fixture()
|
media_item = media_item_fixture()
|
||||||
assert {:error, %Ecto.Changeset{}} = Media.update_media_item(media_item, @invalid_attrs)
|
assert {:error, %Ecto.Changeset{}} = Media.update_media_item(media_item, @invalid_attrs)
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
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
|
||||||
webpage_url: nil,
|
original_url: nil,
|
||||||
aspect_ratio: nil,
|
aspect_ratio: nil,
|
||||||
duration: nil,
|
duration: nil,
|
||||||
upload_date: nil
|
upload_date: nil
|
||||||
@@ -299,7 +299,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
Phoenix.json_library().encode!(%{
|
Phoenix.json_library().encode!(%{
|
||||||
id: "video2",
|
id: "video2",
|
||||||
title: "Video 2",
|
title: "Video 2",
|
||||||
webpage_url: "https://example.com/shorts/video2",
|
original_url: "https://example.com/shorts/video2",
|
||||||
live_status: "is_live",
|
live_status: "is_live",
|
||||||
description: "desc2",
|
description: "desc2",
|
||||||
aspect_ratio: 1.67,
|
aspect_ratio: 1.67,
|
||||||
|
|||||||
@@ -147,6 +147,18 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
assert "could not fetch source details from URL" in errors_on(changeset).original_url
|
assert "could not fetch source details from URL" in errors_on(changeset).original_url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "adds an error if the runner succeeds but the result was invalid JSON" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, "Not JSON"} end)
|
||||||
|
|
||||||
|
valid_attrs = %{
|
||||||
|
media_profile_id: media_profile_fixture().id,
|
||||||
|
original_url: "https://www.youtube.com/channel/abc123"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, %Ecto.Changeset{} = changeset} = Sources.create_source(valid_attrs)
|
||||||
|
assert "could not fetch source details from URL" in errors_on(changeset).original_url
|
||||||
|
end
|
||||||
|
|
||||||
test "you can specify a custom custom_name" do
|
test "you can specify a custom custom_name" do
|
||||||
expect(YtDlpRunnerMock, :run, &channel_mock/4)
|
expect(YtDlpRunnerMock, :run, &channel_mock/4)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -133,7 +133,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
|
|||||||
test "returns an error if the output is not JSON" do
|
test "returns an error if the output is not JSON" do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end)
|
||||||
|
|
||||||
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_details(@channel_url)
|
assert {:error, "Error decoding JSON response"} = MediaCollection.get_source_details(@channel_url)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ 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 =
|
attrs =
|
||||||
~w(id title live_status webpage_url description aspect_ratio duration upload_date timestamp playlist_index)a
|
~w(id title live_status original_url description aspect_ratio duration upload_date timestamp playlist_index)a
|
||||||
|
|
||||||
formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j"
|
formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j"
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
"id" => "TiZPUDkDYbk",
|
"id" => "TiZPUDkDYbk",
|
||||||
"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",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"live_status" => "not_live",
|
"live_status" => "not_live",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 60,
|
"duration" => 60,
|
||||||
@@ -177,7 +177,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "sets short_form_content to true if the URL contains /shorts/" do
|
test "sets short_form_content to true if the URL contains /shorts/" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/shorts/TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/shorts/TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 61,
|
"duration" => 61,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -188,7 +188,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "sets short_form_content to true if the aspect ratio are duration are right" do
|
test "sets short_form_content to true if the aspect ratio are duration are right" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 0.5,
|
"aspect_ratio" => 0.5,
|
||||||
"duration" => 59,
|
"duration" => 59,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -199,7 +199,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "sets short_form_content to false otherwise" do
|
test "sets short_form_content to false otherwise" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 61,
|
"duration" => 61,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -210,7 +210,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "doesn't blow up if short form content-related fields are missing" do
|
test "doesn't blow up if short form content-related fields are missing" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => nil,
|
"original_url" => nil,
|
||||||
"aspect_ratio" => nil,
|
"aspect_ratio" => nil,
|
||||||
"duration" => nil,
|
"duration" => nil,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -221,7 +221,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "parses the duration" do
|
test "parses the duration" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 60.4,
|
"duration" => 60.4,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -232,7 +232,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "doesn't blow up if duration is missing" do
|
test "doesn't blow up if duration is missing" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => nil,
|
"duration" => nil,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -243,7 +243,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "sets livestream to false if the live_status 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",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 60,
|
"duration" => 60,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -254,7 +254,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "doesn't blow up if playlist_index is missing" do
|
test "doesn't blow up if playlist_index is missing" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => nil,
|
"duration" => nil,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -267,7 +267,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
describe "response_to_struct/1 when testing uploaded_at" do
|
describe "response_to_struct/1 when testing uploaded_at" do
|
||||||
test "parses the upload date from the timestamp if present" do
|
test "parses the upload date from the timestamp if present" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 61,
|
"duration" => 61,
|
||||||
"upload_date" => "20210101",
|
"upload_date" => "20210101",
|
||||||
@@ -281,7 +281,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "parses the upload date from the uploaded_at if timestamp is present but nil" do
|
test "parses the upload date from the uploaded_at if timestamp is present but nil" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 61,
|
"duration" => 61,
|
||||||
"upload_date" => "20210101",
|
"upload_date" => "20210101",
|
||||||
@@ -295,7 +295,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "parses the upload date from the uploaded_at if timestamp absent" do
|
test "parses the upload date from the uploaded_at if timestamp absent" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 61,
|
"duration" => 61,
|
||||||
"upload_date" => "20210101"
|
"upload_date" => "20210101"
|
||||||
@@ -308,7 +308,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
|||||||
|
|
||||||
test "doesn't blow up if upload date is missing" do
|
test "doesn't blow up if upload date is missing" do
|
||||||
response = %{
|
response = %{
|
||||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||||
"aspect_ratio" => 1.0,
|
"aspect_ratio" => 1.0,
|
||||||
"duration" => 61,
|
"duration" => 61,
|
||||||
"upload_date" => nil
|
"upload_date" => nil
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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!
|
||||||
@@ -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)
|
||||||
@@ -92,7 +95,7 @@ defmodule Pinchflat.MediaFixtures do
|
|||||||
media_attributes = %{
|
media_attributes = %{
|
||||||
id: "video1",
|
id: "video1",
|
||||||
title: "Video 1",
|
title: "Video 1",
|
||||||
webpage_url: "https://example.com/video1",
|
original_url: "https://example.com/video1",
|
||||||
live_status: "not_live",
|
live_status: "not_live",
|
||||||
description: "desc1",
|
description: "desc1",
|
||||||
aspect_ratio: 1.67,
|
aspect_ratio: 1.67,
|
||||||
@@ -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!(),
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ defmodule Pinchflat.SourcesFixtures do
|
|||||||
%{
|
%{
|
||||||
id: "video1",
|
id: "video1",
|
||||||
title: "Video 1",
|
title: "Video 1",
|
||||||
webpage_url: "https://example.com/video1",
|
original_url: "https://example.com/video1",
|
||||||
live_status: "not_live",
|
live_status: "not_live",
|
||||||
description: "desc1",
|
description: "desc1",
|
||||||
aspect_ratio: 1.67,
|
aspect_ratio: 1.67,
|
||||||
@@ -90,7 +90,7 @@ defmodule Pinchflat.SourcesFixtures do
|
|||||||
%{
|
%{
|
||||||
id: "video2",
|
id: "video2",
|
||||||
title: "Video 2",
|
title: "Video 2",
|
||||||
webpage_url: "https://example.com/video2",
|
original_url: "https://example.com/video2",
|
||||||
live_status: "is_live",
|
live_status: "is_live",
|
||||||
description: "desc2",
|
description: "desc2",
|
||||||
aspect_ratio: 1.67,
|
aspect_ratio: 1.67,
|
||||||
@@ -100,7 +100,7 @@ defmodule Pinchflat.SourcesFixtures do
|
|||||||
%{
|
%{
|
||||||
id: "video3",
|
id: "video3",
|
||||||
title: "Video 3",
|
title: "Video 3",
|
||||||
webpage_url: "https://example.com/video3",
|
original_url: "https://example.com/video3",
|
||||||
live_status: "not_live",
|
live_status: "not_live",
|
||||||
description: "desc3",
|
description: "desc3",
|
||||||
aspect_ratio: 1.0,
|
aspect_ratio: 1.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user