Compare commits

..

28 Commits

Author SHA1 Message Date
Kieran Eglin e0a84a521b Bumped version 2024-07-30 08:23:03 -07:00
Kieran Eglin d217ad5744 [Dev] Added ZSH to dev dockerfile 2024-07-30 08:20:55 -07:00
Kieran d5ae41cdab [Performance] Asyncronously delete sources and media profiles (#277)
* [WIP] started on source deletion

* Removed unneeded test blocks

* Added marked_for_deletion_at to sources and media profiles

* Hooked up async deletion to media profiles as well
2024-07-22 12:26:22 -07:00
Kieran 7a01db05dd [Enhancement] Ignore media based on user scripts (#330)
* Integrated pre-download callback for media items

* Refactored existing tests

* Docs, etc
2024-07-22 10:47:49 -07:00
Kieran d392fc3818 [Enhancement] Media from before a source's cutoff date is now automatically deleted (#329)
* Changed the purpose of the 'culled_at' flag. renamed some methods

* implemented new culling behaviour for source cutoff dates

* Removed culled_at flag if a media item gets redownloaded

* Updated source form

* Removed unused method
2024-07-19 16:39:30 -07:00
Kieran Eglin 47596d5f72 Added a script to help with version bumping 2024-07-18 15:59:31 -07:00
Kieran Eglin dbc25e5730 changed version number because I'm a coward 2024-07-18 11:13:03 -07:00
Kieran Eglin a3ebf59b29 Bumped version 2024-07-18 11:09:28 -07:00
Kieran 8dd8e49051 [Dev] Fix CI caching (#325)
* Consolidated dockerfile commands

* Added teardown step

* FIxed typo

* Got rid of old caching action

* Added buildx

* Added steps for compiling test ENV to dockerfile

* Changed the order of things

* Added env vars

* more env vars

* Removed step

* No compile flag

* fetch quest

* Removed other thing

* volumes

* Rolling back some other changes

* more consolidation

* updated checkout runner
2024-07-17 14:21:01 -07:00
Kieran 029981b7ee [Enhancement] Support auto-generated subs when embedding subs in media file (#324)
* Allowed using auto subs when embedding

* Updated profile form
2024-07-17 10:33:50 -07:00
Kieran d423e4486f Added padding to playlist_index output template (#321) 2024-07-16 15:30:27 -07:00
Kieran 4c7a63c970 [Bugfix] Determine NFO season and episode from filepath (#320)
* Updated NFO generator to determine S/E from filepath

* Tests
2024-07-16 15:16:24 -07:00
Kieran 4822127e3f Added date-based outtmpl helper for static playlists (#319) 2024-07-16 14:06:54 -07:00
Kieran Eglin 4f25878f2f Bumped version 2024-07-16 10:00:09 -07:00
Kieran aec56e3a03 Added some improved output template helpers (#318) 2024-07-16 09:41:43 -07:00
Kieran e06e050f95 [Enhancement] Capture playlist_index for use with output templates (#315)
* Started capturing playlist_index on indexing pass

* Added playlist_index as a media item field

* Added playlist index to output variable templates

* Improved the way playlist_indexes are rejected on update

* Updated docs

* Undid unneeded changes
2024-07-15 16:06:13 -07:00
Kieran 5a10015634 [Enhancement] Download image when using playlists with media center apps (#313)
* [WIP] started adding calls for downloading posters for playlists

* Updated source image parser to work with playlists
2024-07-15 12:04:57 -07:00
Dan 0d5a41fd35 [Enhancement] Render URL attributes as a link (#299)
* Render URLs as links when listing items from a map

* Applies suggestions from MR feedback
2024-07-15 08:59:02 -07:00
Kieran 8f91c4e6a2 [Enhancement] Improve flow for adding many sources at once (#306)
* Added mechamism for using existing sources as a reference for adding new sources

* Added test
2024-07-12 08:53:26 -07:00
Kieran 7f1daf90ca Add date to 'prod' logger (#305) 2024-07-11 10:19:14 -07:00
Kieran bb0e104b7e Runs yarn install at project level on app boot (#304) 2024-07-11 09:29:53 -07:00
Kieran Eglin c0b8dec520 no-op change to force a rebuild with new yt-dlp version 2024-07-11 08:30:33 -07:00
Kieran Eglin b8da973a81 Bumped version 2024-06-25 08:19:06 -07:00
Kieran a46cfd1888 [Bugfix] Check for regex errors before saving a source (#296)
* Added error if provided regex is invalid

* improved test
2024-06-20 12:48:52 -07:00
Kieran Eglin f08768b887 Updated formatter 2024-06-20 11:33:49 -07:00
Kieran Eglin 199d0065e1 Updated screenshots 2024-06-14 09:23:40 -07:00
Kieran 781061b483 [Dev] Reduce config file clutter (#290)
* Added local tooling config files to the tooling/ directory

* Moved as many docker-related files as possible

* Updated dockerfile path

* Updated the correct dockerfile instead how about

* Off day - jeez louise

* Juggled around some more dockerfiles

* more dockerfile

* More docker tests

* it's docker time

* One more shot
2024-06-13 16:21:09 -07:00
Kieran 132f8e8da6 [Dev] Upgrade to Elixir 1.17.0 (#289)
* upgraded to Elixir 1.17.0

* Addressed deprecation warnings

* Updated Credo
2024-06-13 10:05:03 -07:00
78 changed files with 1210 additions and 463 deletions
+2
View File
@@ -1,3 +1,5 @@
# TODO: figure out why my vscode extension doesn't respect the formatter.exs file
# if it's in a subdirectory
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
+1 -1
View File
@@ -75,7 +75,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./selfhosted.Dockerfile
file: ./docker/selfhosted.Dockerfile
# If the event is a release, use the release_arch, otherwise use the
# platforms input if present, falling back to dev_arch
platforms: ${{ github.event_name == 'release' && env.release_arch || (github.event.inputs.platforms || env.dev_arch) }}
+15 -12
View File
@@ -16,24 +16,28 @@ jobs:
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
env:
COMPOSE_FILE: ./docker-compose.ci.yml
MIX_ENV: test
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Pull prebuilt images
run: docker compose pull
- name: Setup Docker layer caching
uses: jpribyl/action-docker-layer-caching@v0.1.1
continue-on-error: true
with:
key: ci-docker-cache-{hash}
restore-keys: |
ci-docker-cache-
layer-ci-docker-cache-
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Run Docker image
- name: Build docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/dev.Dockerfile
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run Docker image
run: docker compose up --detach
# NOTE: All exec commands use the -T flag to compensate for
@@ -43,8 +47,7 @@ jobs:
# See https://github.com/actions/runner/issues/241 and https://github.com/docker/compose/issues/8537
- name: Install Elixir and JS deps
run: |
docker compose exec -T phx yarn install && cd assets && yarn install && cd ..
docker compose exec -T phx mix deps.get
docker compose exec -T phx mix deps.get && yarn install && cd assets && yarn install && cd ..
- name: Create and Migrate database
run: |
-3
View File
@@ -1,3 +0,0 @@
assets/vendor/
deps/
_build/
+3 -3
View File
@@ -71,8 +71,8 @@ If it doesn't work for your use case, please make a feature request! You can als
## Screenshots
<img src="priv/static/images/app-form-screenshot.png" alt="Pinchflat screenshot" width="700" />
<img src="priv/static/images/app-screenshot.png" alt="Pinchflat screenshot" width="700" />
<img src="priv/static/images/app-form-screenshot.jpg" alt="Pinchflat screenshot" width="700" />
<img src="priv/static/images/app-screenshot.jpg" alt="Pinchflat screenshot" width="700" />
## Installation
@@ -108,7 +108,7 @@ services:
2. Prepare the docker image in one of the two ways below:
- **From GHCR:** `docker pull ghcr.io/kieraneglin/pinchflat:latest`
- NOTE: also available on Docker Hub at `keglin/pinchflat:latest`
- **Building locally:** `docker build . --file selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest`
- **Building locally:** `docker build . --file docker/selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest`
3. Run the container:
```bash
+2 -2
View File
@@ -65,7 +65,7 @@ config :pinchflat, Oban,
media_indexing: 2,
media_collection_indexing: 2,
media_fetching: 2,
local_metadata: 8,
local_data: 8,
remote_metadata: 4
]
@@ -102,7 +102,7 @@ config :tailwind,
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
format: "$date $time $metadata[$level] | $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
+4 -2
View File
@@ -1,13 +1,15 @@
version: '3'
services:
phx:
build:
context: .
dockerfile: dev.Dockerfile
dockerfile: ./docker/dev.Dockerfile
environment:
- MIX_ENV=test
volumes:
- '.:/app'
# These lines ensure the deps can be saved as build artifacts for caching
- '/app/deps'
- '/app/_build'
ports:
- '4008:4008'
command: tail -F /dev/null
+2 -4
View File
@@ -1,15 +1,13 @@
version: '3'
services:
phx:
build:
context: .
dockerfile: dev.Dockerfile
dockerfile: ./docker/dev.Dockerfile
volumes:
- '.:/app'
ports:
- '4008:4008'
command:
- ./docker-run.dev.sh
command: bash -c "chmod +x docker/docker-run.dev.sh && docker/docker-run.dev.sh"
stdin_open: true
tty: true
env_file:
+31 -33
View File
@@ -1,6 +1,6 @@
ARG ELIXIR_VERSION=1.16.2
ARG OTP_VERSION=26.2.2
ARG DEBIAN_VERSION=bookworm-20240130
ARG ELIXIR_VERSION=1.17.0
ARG OTP_VERSION=26.2.5
ARG DEBIAN_VERSION=bookworm-20240612-slim
ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
FROM ${DEV_IMAGE}
@@ -9,10 +9,10 @@ ARG TARGETPLATFORM
RUN echo "Building for ${TARGETPLATFORM:?}"
# Install debian packages
RUN apt-get update -qq
RUN apt-get install -y inotify-tools curl git openssh-client jq \
python3 python3-setuptools python3-wheel python3-dev pipx \
python3-mutagen locales procps build-essential graphviz
RUN apt-get update -qq && \
apt-get install -y inotify-tools curl git openssh-client jq \
python3 python3-setuptools python3-wheel python3-dev pipx \
python3-mutagen locales procps build-essential graphviz zsh
# Install ffmpeg
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
@@ -23,42 +23,40 @@ RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe"
# Install nodejs
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
RUN bash nodesource_setup.sh
RUN apt-get install nodejs
RUN npm install -g yarn
# Install baseline Elixir packages
RUN mix local.hex --force
RUN mix local.rebar --force
# Download and update YT-DLP
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
RUN chmod a+rx /usr/local/bin/yt-dlp
RUN yt-dlp -U
# Install Apprise
RUN export PIPX_HOME=/opt/pipx && \
export PIPX_BIN_DIR=/usr/local/bin && \
pipx install apprise
# Install nodejs and Yarn
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh && \
bash nodesource_setup.sh && \
apt-get install -y nodejs && \
npm install -g yarn && \
# Install baseline Elixir packages
mix local.hex --force && \
mix local.rebar --force && \
# Download and update YT-DLP
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp && \
yt-dlp -U && \
# Install Apprise
export PIPX_HOME=/opt/pipx && \
export PIPX_BIN_DIR=/usr/local/bin && \
pipx install apprise && \
# Set up ZSH tools
chsh -s $(which zsh) && \
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
# Create app directory and copy the Elixir projects into it.
WORKDIR /app
COPY . ./
# Needs permissions to be updated AFTER the copy step
RUN chmod +x ./docker-run.dev.sh
# Install Elixir deps
# RUN mix archive.install github hexpm/hex branch latest
RUN mix deps.get
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
# Gives us iex shell history
ENV ERL_AFLAGS="-kernel shell_history enabled"
@@ -2,12 +2,12 @@
set -e
# Ensure the app's deps are installed
echo "\nInstalling Elixir deps..."
mix deps.get
# Install JS deps
echo "\nInstalling JS..."
cd assets && yarn install
# Install both project-level and assets-level JS dependencies
echo "\nInstalling JS deps..."
yarn install && cd assets && yarn install
cd ..
# Potentially Set up the database
@@ -1,8 +1,8 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
# instead of Alpine to avoid DNS resolution issues in production.
ARG ELIXIR_VERSION=1.16.2
ARG OTP_VERSION=26.2.2
ARG DEBIAN_VERSION=bookworm-20240130-slim
ARG ELIXIR_VERSION=1.17.0
ARG OTP_VERSION=26.2.5
ARG DEBIAN_VERSION=bookworm-20240612-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
@@ -74,6 +74,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
{{:download_auto_subs, true}, %{download_subs: true}} ->
acc ++ [:write_auto_subs]
{{:download_auto_subs, true}, %{embed_subs: true}} ->
acc ++ [:write_auto_subs]
{{:embed_subs, true}, %{preferred_resolution: pr}} when pr != :audio ->
acc ++ [:embed_subs]
@@ -203,10 +206,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
"source_collection_id" => source.collection_id,
"source_collection_name" => source.collection_name,
"source_collection_type" => to_string(source.collection_type),
"media_upload_date_index" =>
media_item_with_preloads.upload_date_index
|> to_string()
|> String.pad_leading(2, "0")
"media_playlist_index" => pad_int(media_item_with_preloads.playlist_index),
"media_upload_date_index" => pad_int(media_item_with_preloads.upload_date_index)
}
end
@@ -223,6 +224,12 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|> build_output_path(media_item_with_preloads)
end
defp pad_int(integer, count \\ 2, padding \\ "0") do
integer
|> to_string()
|> String.pad_leading(count, padding)
end
defp base_directory do
Application.get_env(:pinchflat, :media_directory)
end
@@ -91,8 +91,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
[m, s, mp],
^MediaQuery.for_source(source) and
^MediaQuery.downloaded() and
not (^MediaQuery.download_prevented()) and
not (^MediaQuery.culled())
not (^MediaQuery.download_prevented())
)
)
|> Repo.all()
@@ -39,17 +39,14 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
- `quality_upgrade?`: re-downloads media, including the video. Does not force download
if the source is set to not download media
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
Returns :ok | {:error, any, ...any}
"""
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
should_force = Map.get(args, "force", false)
is_quality_upgrade = Map.get(args, "quality_upgrade?", false)
media_item =
media_item_id
|> Media.get_media_item!()
|> Repo.preload(:source)
media_item = fetch_and_run_prevent_download_user_script(media_item_id)
# If the source or media item is set to not download media, perform a no-op unless forced
if (media_item.source.download_media && !media_item.prevent_download) || should_force do
@@ -62,6 +59,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
end
# If a user script exists and, when run, returns a non-zero exit code, prevent this and all future downloads
# of the media item.
defp fetch_and_run_prevent_download_user_script(media_item_id) do
media_item = Media.get_media_item!(media_item_id)
{:ok, media_item} =
case run_user_script(:media_pre_download, media_item) do
{:ok, _, exit_code} when exit_code != 0 -> Media.update_media_item(media_item, %{prevent_download: true})
_ -> {:ok, media_item}
end
Repo.preload(media_item, :source)
end
defp download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force) do
overwrite_behaviour = if should_force || is_quality_upgrade, do: :force_overwrites, else: :no_force_overwrites
override_opts = [overwrite_behaviour: overwrite_behaviour]
@@ -74,9 +85,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
})
:ok = run_user_script(updated_media_item)
run_user_script(:media_downloaded, updated_media_item)
{:ok, updated_media_item}
:ok
{:recovered, _} ->
{:error, :retry}
@@ -112,9 +123,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
# NOTE: I like this pattern of using the default value so that I don't have to
# define it in config.exs (and friends). Consider using this elsewhere.
defp run_user_script(media_item) do
defp run_user_script(event, media_item) do
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
runner.run(:media_downloaded, media_item)
runner.run(event, media_item)
end
end
@@ -79,6 +79,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|> MetadataParser.parse_for_media_item()
|> Map.merge(%{
media_downloaded_at: DateTime.utc_now(),
culled_at: nil,
nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json),
metadata: %{
# IDEA: might be worth kicking off a job for this since thumbnail fetching
@@ -23,10 +23,10 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do
"""
@impl Oban.Worker
def perform(%Oban.Job{}) do
redownloadable_media = Media.list_redownloadable_media_items()
Logger.info("Redownloading #{length(redownloadable_media)} media items")
upgradable_media = Media.list_upgradeable_media_items()
Logger.info("Redownloading #{length(upgradable_media)} media items")
Enum.each(redownloadable_media, fn media_item ->
Enum.each(upgradable_media, fn media_item ->
MediaDownloadWorker.kickoff_with_task(media_item, %{quality_upgrade?: true})
end)
end
@@ -2,12 +2,15 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
@moduledoc false
use Oban.Worker,
queue: :local_metadata,
queue: :local_data,
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
tags: ["media_item", "local_metadata"]
tags: ["media_item", "local_data"]
use Pinchflat.Media.MediaQuery
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media
@doc """
@@ -20,14 +23,51 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
"""
@impl Oban.Worker
def perform(%Oban.Job{}) do
cullable_media = Media.list_cullable_media_items()
cull_cullable_media_items()
delete_media_items_from_before_cutoff()
:ok
end
defp cull_cullable_media_items do
cullable_media =
MediaQuery.new()
|> MediaQuery.require_assoc(:source)
|> where(^MediaQuery.cullable())
|> Repo.all()
Logger.info("Culling #{length(cullable_media)} media items past their retention date")
Enum.each(cullable_media, fn media_item ->
# Setting `prevent_download` does what it says on the tin, but `culled_at` is purely informational.
# We don't actually do anything with that in terms of queries and it gets set to nil if the media item
# gets re-downloaded.
Media.delete_media_files(media_item, %{
prevent_download: true,
culled_at: DateTime.utc_now()
})
end)
end
defp delete_media_items_from_before_cutoff do
deletable_media =
MediaQuery.new()
|> MediaQuery.require_assoc(:source)
|> where(^MediaQuery.deletable_based_on_source_cutoff())
|> Repo.all()
Logger.info("Deleting #{length(deletable_media)} media items that are from before the source cutoff")
Enum.each(deletable_media, fn media_item ->
# Note that I'm not setting `prevent_download` on the media_item here.
# That's because cutoff_date can easily change and it's a valid behavior to re-download older
# media items if the cutoff_date changes.
# Download is ultimately prevented because `MediaQuery.pending()` only returns media items
# from after the cutoff date (among other things), so it's not like the media will just immediately
# be re-downloaded.
Media.delete_media_files(media_item, %{
culled_at: DateTime.utc_now()
})
end)
end
end
@@ -54,7 +54,12 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
"season_from_date" => "%(upload_date>%Y)S",
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S",
"season_episode_index_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S{{ media_upload_date_index }}",
"artist_name" => "%(artist,creator,uploader,uploader_id)S"
"artist_name" => "%(artist,creator,uploader,uploader_id)S",
"static_season__episode_by_index" => "Season 1/s01e{{ media_playlist_index }}",
"static_season__episode_by_date" => "Season 1/s01e%(upload_date>%y%m%d)S",
"season_by_year__episode_by_date" => "Season %(upload_date>%Y)S/s%(upload_date>%Y)Se%(upload_date>%m%d)S",
"season_by_year__episode_by_date_and_index" =>
"Season %(upload_date>%Y)S/s%(upload_date>%Y)Se%(upload_date>%m%d)S{{ media_upload_date_index }}"
}
end
end
@@ -12,6 +12,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
@behaviour UserScriptCommandRunner
@event_types [
:media_pre_download,
:media_downloaded,
:media_deleted
]
@@ -22,24 +23,25 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
This function will succeed in almost all cases, even if the user script command
failed - this is because I don't want bad scripts to stop the whole process.
If something fails, it'll be logged.
If something fails, it'll be logged and returned BUT the tuple will always
start with {:ok, ...}.
The only things that can cause a true failure are passing in an invalid event
type or if the passed data cannot be encoded into JSON - both indicative of
failures in the development process.
Returns :ok
Returns {:ok, :no_executable} | {:ok, output, exit_code}
"""
@impl UserScriptCommandRunner
def run(event_type, encodable_data) when event_type in @event_types do
case backend_executable() do
{:ok, :no_executable} ->
:ok
{:ok, :no_executable}
{:ok, executable_path} ->
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
{_output, _exit_code} =
{output, exit_code} =
CliUtils.wrap_cmd(
executable_path,
[to_string(event_type), encoded_data],
@@ -47,7 +49,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
logging_arg_override: "[suppressed]"
)
:ok
{:ok, output, exit_code}
end
end
@@ -62,7 +64,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
if FilesystemUtils.exists_and_nonempty?(filepath) do
{:ok, filepath}
else
Logger.warning("User scripts lifecyle file either not present or is empty. Skipping.")
Logger.info("User scripts lifecyle file either not present or is empty. Skipping.")
{:ok, :no_executable}
end
+14 -20
View File
@@ -25,21 +25,10 @@ defmodule Pinchflat.Media do
end
@doc """
Returns a list of media_items that are cullable based on the retention period
of the source they belong to.
Returns [%MediaItem{}, ...]
"""
def list_cullable_media_items do
MediaQuery.new()
|> MediaQuery.require_assoc(:source)
|> where(^MediaQuery.cullable())
|> Repo.all()
end
@doc """
Returns a list of media_items that are redownloadable based on the redownload delay
of the media_profile their source belongs to.
Returns a list of media_items that are upgradeable based on the redownload delay
of the media_profile their source belongs to. In this context, upgradeable means
that it's been long enough since upload that the video may be in a higher quality
or have better sponsorblock segments (or similar).
The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
@@ -52,10 +41,10 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...]
"""
def list_redownloadable_media_items do
def list_upgradeable_media_items do
MediaQuery.new()
|> MediaQuery.require_assoc(:media_profile)
|> where(^MediaQuery.redownloadable())
|> where(^MediaQuery.upgradeable())
|> Repo.all()
end
@@ -142,12 +131,17 @@ defmodule Pinchflat.Media do
"""
def create_media_item_from_backend_attrs(source, media_attrs_struct) do
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
# Some fields should only be set on insert and not on update.
fields_to_drop_on_update = [:playlist_index]
%MediaItem{}
|> MediaItem.changeset(attrs)
|> Repo.insert(
on_conflict: [
set: Map.to_list(attrs)
set:
attrs
|> Map.drop(fields_to_drop_on_update)
|> Map.to_list()
],
conflict_target: [:source_id, :media_id]
)
@@ -177,7 +171,7 @@ defmodule Pinchflat.Media do
if delete_files do
{:ok, _} = do_delete_media_files(media_item)
:ok = run_user_script(:media_deleted, media_item)
run_user_script(:media_deleted, media_item)
end
# Should delete these no matter what
@@ -200,7 +194,7 @@ defmodule Pinchflat.Media do
Tasks.delete_tasks_for(media_item)
{:ok, _} = do_delete_media_files(media_item)
:ok = run_user_script(:media_deleted, media_item)
run_user_script(:media_deleted, media_item)
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
end
+3
View File
@@ -18,6 +18,8 @@ defmodule Pinchflat.Media.MediaItem do
alias Pinchflat.Media.MediaItemsSearchIndex
@allowed_fields [
# these fields are only captured on index
:playlist_index,
# these fields are captured on indexing (and again on download)
:title,
:media_id,
@@ -72,6 +74,7 @@ defmodule Pinchflat.Media.MediaItem do
field :uploaded_at, :utc_datetime
field :upload_date_index, :integer, default: 0
field :duration_seconds, :integer
field :playlist_index, :integer, default: 0
field :media_filepath, :string
field :media_size_bytes, :integer
+10 -3
View File
@@ -33,7 +33,6 @@ defmodule Pinchflat.Media.MediaQuery do
def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath))
def download_prevented, do: dynamic([mi], mi.prevent_download == true)
def culling_prevented, do: dynamic([mi], mi.prevent_culling == true)
def culled, do: dynamic([mi], not is_nil(mi.culled_at))
def redownloaded, do: dynamic([mi], not is_nil(mi.media_redownloaded_at))
def upload_date_matches(other_date), do: dynamic([mi], fragment("date(?) = date(?)", mi.uploaded_at, ^other_date))
@@ -108,6 +107,15 @@ defmodule Pinchflat.Media.MediaQuery do
)
end
def deletable_based_on_source_cutoff do
dynamic(
[mi, source],
^downloaded() and
not (^upload_date_after_source_cutoff()) and
not (^culling_prevented())
)
end
def pending do
dynamic(
[mi],
@@ -119,12 +127,11 @@ defmodule Pinchflat.Media.MediaQuery do
)
end
def redownloadable do
def upgradeable do
dynamic(
[mi, source],
^downloaded() and
not (^download_prevented()) and
not (^culled()) and
not (^redownloaded()) and
^past_redownload_delay()
)
@@ -49,7 +49,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
Returns {:ok, map()} | {:error, any}
"""
def read_compressed_metadata(filepath) do
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all))
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :eof))
Phoenix.json_library().decode(json)
end
@@ -124,6 +124,21 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
end
end
@doc """
Attempts to determine the season and episode number from a media filepath.
Returns {:ok, {binary(), binary()}} | {:error, :indeterminable}
"""
def season_and_episode_from_media_filepath(media_filepath) do
# matches s + 1 or more digits + e + 1 or more digits (case-insensitive)
season_episode_regex = ~r/s(\d+)e(\d+)/i
case Regex.scan(season_episode_regex, media_filepath) do
[[_, season, episode] | _] -> {:ok, {season, episode}}
_ -> {:error, :indeterminable}
end
end
defp generate_filepath_for(database_record, filename) do
Path.join([
metadata_directory_for(database_record),
+21 -9
View File
@@ -6,8 +6,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Metadata.MetadataFileHelpers
@doc """
Builds an NFO file for a media item (read: single "episode") and
@@ -15,12 +15,12 @@ defmodule Pinchflat.Metadata.NfoBuilder do
Returns the filepath of the NFO file.
"""
def build_and_store_for_media_item(filepath, metadata) do
nfo = build_for_media_item(metadata)
def build_and_store_for_media_item(nfo_filepath, metadata) do
nfo = build_for_media_item(nfo_filepath, metadata)
FilesystemUtils.write_p!(filepath, nfo)
FilesystemUtils.write_p!(nfo_filepath, nfo)
filepath
nfo_filepath
end
@doc """
@@ -37,10 +37,15 @@ defmodule Pinchflat.Metadata.NfoBuilder do
filepath
end
defp build_for_media_item(metadata) do
defp build_for_media_item(nfo_filepath, metadata) do
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
# NOTE: the filepath here isn't the path of the media item, it's the path that
# the NFO should be saved to. This works because the NFO's path is the same as
# the media's path, just with a different extension. If this ever changes I'll
# need to pass in the media item's path as well.
{season, episode} = determine_season_and_episode_number(nfo_filepath, upload_date)
# Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub.
# WHO NEEDS A FANCY XML PARSER ANYWAY?!
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<episodedetails>
@@ -49,8 +54,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do
<uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
<plot>#{safe(metadata["description"])}</plot>
<aired>#{safe(upload_date)}</aired>
<season>#{safe(upload_date.year)}</season>
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
<season>#{safe(season)}</season>
<episode>#{episode}</episode>
<genre>YouTube</genre>
</episodedetails>
"""
@@ -67,4 +72,11 @@ defmodule Pinchflat.Metadata.NfoBuilder do
</tvshow>
"""
end
defp determine_season_and_episode_number(filepath, upload_date) do
case MetadataFileHelpers.season_and_episode_from_media_filepath(filepath) do
{:ok, {season, episode}} -> {season, episode}
{:error, _} -> {upload_date.year, Calendar.strftime(upload_date, "%m%d")}
end
end
end
+48 -18
View File
@@ -20,29 +20,49 @@ defmodule Pinchflat.Metadata.SourceImageParser do
def store_source_images(base_directory, source_metadata) do
(source_metadata["thumbnails"] || [])
|> Enum.filter(&(&1["filepath"] != nil))
|> select_useful_images()
|> select_useful_images(source_metadata)
|> Enum.map(&move_image(&1, base_directory))
|> Enum.into(%{})
end
defp select_useful_images(images) do
defp select_useful_images(images, source_metadata) do
labelled_images =
Enum.reduce(images, [], fn image_map, acc ->
Enum.reduce(images, %{}, fn image_map, acc ->
case image_map do
%{"id" => "avatar_uncropped"} ->
acc ++ [{:poster, :poster_filepath, image_map["filepath"]}]
%{"id" => "banner_uncropped"} ->
acc ++ [{:fanart, :fanart_filepath, image_map["filepath"]}]
_ ->
acc
%{"id" => "avatar_uncropped"} -> put_image_key(acc, :poster, image_map["filepath"])
%{"id" => "banner_uncropped"} -> put_image_key(acc, :fanart, image_map["filepath"])
_ -> acc
end
end)
labelled_images
|> Enum.concat([{:banner, :banner_filepath, determine_best_banner(images)}])
|> Enum.filter(fn {_, _, tmp_filepath} -> tmp_filepath end)
|> add_fallback_poster(source_metadata)
|> put_image_key(:banner, determine_best_banner(images))
|> Enum.filter(fn {_key, attrs} -> attrs.current_filepath end)
end
# If a poster is set, short-circuit and return the images as-is
defp add_fallback_poster(%{poster: _} = images, _), do: images
# If a poster is NOT set, see if we can find a suitable image to use as a fallback
defp add_fallback_poster(images, source_metadata) do
case source_metadata["entries"] do
nil -> images
[] -> images
[first_entry | _] -> add_poster_from_entry_thumbnail(images, first_entry)
end
end
defp add_poster_from_entry_thumbnail(images, entry) do
thumbnail =
(entry["thumbnails"] || [])
|> Enum.reverse()
|> Enum.find(& &1["filepath"])
case thumbnail do
nil -> images
_ -> put_image_key(images, :poster, thumbnail["filepath"])
end
end
defp determine_best_banner(images) do
@@ -58,12 +78,22 @@ defmodule Pinchflat.Metadata.SourceImageParser do
Map.get(best_candidate || %{}, "filepath")
end
defp move_image({filename, source_attr_name, tmp_filepath}, base_directory) do
extension = Path.extname(tmp_filepath)
final_filepath = Path.join([base_directory, "#{filename}#{extension}"])
defp move_image({_key, attrs}, base_directory) do
extension = Path.extname(attrs.current_filepath)
final_filepath = Path.join([base_directory, "#{attrs.final_filename}#{extension}"])
FilesystemUtils.cp_p!(tmp_filepath, final_filepath)
FilesystemUtils.cp_p!(attrs.current_filepath, final_filepath)
{source_attr_name, final_filepath}
{attrs.attribute_name, final_filepath}
end
defp put_image_key(map, key, image) do
attribute_atom = String.to_existing_atom("#{key}_filepath")
Map.put(map, key, %{
attribute_name: attribute_atom,
final_filename: to_string(key),
current_filepath: image
})
end
end
@@ -77,10 +77,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
defp fetch_source_metadata_and_images(series_directory, source) do
metadata_directory = MetadataFileHelpers.metadata_directory_for(source)
tmp_output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: tmp_output_path]
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts)
{:ok, metadata} = fetch_metadata_for_source(source)
metadata_image_attrs = SourceImageParser.store_source_images(metadata_directory, metadata)
if source.media_profile.download_source_images && series_directory do
@@ -110,6 +108,20 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
end
end
defp fetch_metadata_for_source(source) do
tmp_output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
base_opts = [convert_thumbnails: "jpg", output: tmp_output_path]
opts =
if source.collection_type == :channel do
base_opts ++ [:write_all_thumbnails, playlist_items: 0]
else
base_opts ++ [:write_thumbnail, playlist_items: 1]
end
MediaCollection.get_source_metadata(source.original_url, opts)
end
defp tmp_directory do
Application.get_env(:pinchflat, :tmpfile_directory)
end
+3
View File
@@ -28,6 +28,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
livestream_behaviour
preferred_resolution
redownload_delay_days
marked_for_deletion_at
)a
@required_fields ~w(name output_path_template)a
@@ -65,6 +66,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
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 :marked_for_deletion_at, :utc_datetime
has_many :sources, Source
timestamps(type: :utc_datetime)
@@ -0,0 +1,38 @@
defmodule Pinchflat.Profiles.MediaProfileDeletionWorker do
@moduledoc false
use Oban.Worker,
queue: :local_data,
tags: ["media_profiles", "local_data"]
require Logger
alias __MODULE__
alias Pinchflat.Profiles
@doc """
Starts the profile deletion worker. Does not attach it to a task like `kickoff_with_task/2`
since deletion also cancels all tasks for the profile
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
"""
def kickoff(profile, job_args \\ %{}, job_opts \\ []) do
%{id: profile.id}
|> Map.merge(job_args)
|> MediaProfileDeletionWorker.new(job_opts)
|> Oban.insert()
end
@doc """
Deletes a profile and optionally deletes its files
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => profile_id} = args}) do
delete_files = Map.get(args, "delete_files", false)
profile = Profiles.get_media_profile!(profile_id)
Profiles.delete_media_profile(profile, delete_files: delete_files)
end
end
+13
View File
@@ -35,6 +35,7 @@ defmodule Pinchflat.Sources.Source do
title_filter_regex
media_profile_id
output_path_template_override
marked_for_deletion_at
)a
# Expensive API calls are made when a source is inserted/updated so
@@ -87,6 +88,8 @@ defmodule Pinchflat.Sources.Source do
field :fanart_filepath, :string
field :banner_filepath, :string
field :marked_for_deletion_at, :utc_datetime
belongs_to :media_profile, MediaProfile
has_one :metadata, SourceMetadata, on_replace: :update
@@ -112,6 +115,7 @@ defmodule Pinchflat.Sources.Source do
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(required_fields)
|> validate_title_regex()
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
@@ -149,6 +153,15 @@ defmodule Pinchflat.Sources.Source do
~r<^(?:(?!youtube\.com/(watch|shorts|embed)|youtu\.be).)*$>
end
defp validate_title_regex(%{changes: %{title_filter_regex: regex}} = changeset) when is_binary(regex) do
case Ecto.Adapters.SQL.query(Repo, "SELECT regexp_like('', ?)", [regex]) do
{:ok, _} -> changeset
_ -> add_error(changeset, :title_filter_regex, "is invalid")
end
end
defp validate_title_regex(changeset), do: changeset
defimpl Jason.Encoder, for: Source do
def encode(value, opts) do
value
@@ -0,0 +1,38 @@
defmodule Pinchflat.Sources.SourceDeletionWorker do
@moduledoc false
use Oban.Worker,
queue: :local_data,
tags: ["sources", "local_data"]
require Logger
alias __MODULE__
alias Pinchflat.Sources
@doc """
Starts the source deletion worker. Does not attach it to a task like `kickoff_with_task/2`
since deletion also cancels all tasks for the source
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
"""
def kickoff(source, job_args \\ %{}, job_opts \\ []) do
%{id: source.id}
|> Map.merge(job_args)
|> SourceDeletionWorker.new(job_opts)
|> Oban.insert()
end
@doc """
Deletes a source and optionally deletes its files
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => source_id} = args}) do
delete_files = Map.get(args, "delete_files", false)
source = Sources.get_source!(source_id)
Sources.delete_source(source, delete_files: delete_files)
end
end
+8 -4
View File
@@ -22,7 +22,8 @@ defmodule Pinchflat.YtDlp.Media do
:livestream,
:short_form_content,
:uploaded_at,
:duration_seconds
:duration_seconds,
:playlist_index
]
alias __MODULE__
@@ -63,7 +64,7 @@ defmodule Pinchflat.YtDlp.Media do
@doc """
Returns a map representing the media at the given URL.
Returns {:ok, [map()]} | {:error, any, ...}.
Returns {:ok, %Media{}} | {:error, any, ...}.
"""
def get_media_attributes(url) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
@@ -84,9 +85,11 @@ defmodule Pinchflat.YtDlp.Media do
@doc """
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.
"""
def indexing_output_template do
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp})j"
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j"
end
@doc """
@@ -104,7 +107,8 @@ defmodule Pinchflat.YtDlp.Media do
livestream: !!response["was_live"],
duration_seconds: response["duration"] && round(response["duration"]),
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),
playlist_index: response["playlist_index"] || 0
}
end
+13 -2
View File
@@ -99,10 +99,21 @@ defmodule Pinchflat.YtDlp.MediaCollection do
as a compressed blob for possible future use. That's why it's not getting formatted like
`get_source_details/1`
! IMPORTANT ! - you'll always want to set `playlist_items: int` in `addl_opts.
This is great if you want to also return details about the videos in the playlists,
but it should be set in all cases to not over-fetch data.
For channels you should usually set this to 0 since channels return all the
metadata we need without needing to fetch the videos. On the other hand, playlists
don't return very useful images so you can set this to 1 to get the first video's
images, for instance.
Returns {:ok, map()} | {:error, any, ...}.
"""
def get_source_metadata(source_url, addl_opts \\ []) do
opts = [playlist_items: 0] ++ addl_opts
def get_source_metadata(source_url, addl_opts \\ [playlist_items: 0]) do
# This only validates that the `playlist_items` key is present. It's otherwise unused
_playlist_items = Keyword.fetch!(addl_opts, :playlist_items)
opts = [:skip_download] ++ addl_opts
output_template = "playlist:%()j"
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
@@ -19,6 +19,7 @@ defmodule PinchflatWeb.CoreComponents do
import PinchflatWeb.Gettext
alias Phoenix.LiveView.JS
alias PinchflatWeb.CustomComponents.TextComponents
@doc """
Renders a modal.
@@ -644,7 +645,11 @@ defmodule PinchflatWeb.CoreComponents do
<li :for={{k, v} <- @iterable_attributes} class="mb-2 w-2/3">
<strong><%= k %>:</strong>
<code class="inline-block text-sm font-mono text-gray p-0.5 mx-0.5">
<%= v %>
<%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %>
<TextComponents.inline_link href={v}><%= v %></TextComponents.inline_link>
<% else %>
<%= v %>
<% end %>
</code>
</li>
</ul>
@@ -5,10 +5,12 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
alias Pinchflat.Repo
alias Pinchflat.Profiles
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Profiles.MediaProfileDeletionWorker
def index(conn, _params) do
media_profiles =
MediaProfile
|> where([mp], is_nil(mp.marked_for_deletion_at))
|> order_by(asc: :name)
|> Repo.all()
@@ -70,19 +72,15 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
end
def delete(conn, %{"id" => id} = params) do
delete_files = Map.get(params, "delete_files", false)
# This awkward comparison converts the string to a boolean
delete_files = Map.get(params, "delete_files", "") == "true"
media_profile = Profiles.get_media_profile!(id)
{:ok, _media_profile} = Profiles.delete_media_profile(media_profile, delete_files: delete_files)
flash_message =
if delete_files do
"Media profile, its sources, and its files deleted successfully."
else
"Media profile and its sources deleted successfully. Files were not deleted."
end
{:ok, _} = Profiles.update_media_profile(media_profile, %{marked_for_deletion_at: DateTime.utc_now()})
MediaProfileDeletionWorker.kickoff(media_profile, %{delete_files: delete_files})
conn
|> put_flash(:info, flash_message)
|> put_flash(:info, "Media Profile deletion started. This may take a while to complete.")
|> redirect(to: ~p"/media_profiles")
end
@@ -53,12 +53,24 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
]
end
def custom_output_template_options do
def media_center_custom_output_template_options do
%{
season_by_year__episode_by_date: "<code>Season YYYY/sYYYYeMMDD</code>",
season_by_year__episode_by_date_and_index:
"same as the above but it handles dates better. <strong>This is the recommended option</strong>",
static_season__episode_by_index:
"<code>Season 1/s01eXX</code> where <code>XX</code> is the video's position in the playlist. Only recommended for playlists (not channels) that don't change",
static_season__episode_by_date:
"<code>Season 1/s01eYYMMDD</code>. Recommended for playlists that might change or where order isn't important"
}
end
def other_custom_output_template_options do
%{
upload_day: nil,
upload_month: nil,
upload_year: nil,
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD",
upload_yyyy_mm_dd: "the upload date in the format <code>YYYY-MM-DD</code>",
source_custom_name: "the name of the sources that use this profile",
source_collection_id: "the YouTube ID of the sources that use this profile",
source_collection_name:
@@ -66,9 +78,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
artist_name: "the name of the artist with fallbacks to other uploader fields",
season_from_date: "alias for upload_year",
season_episode_from_date: "the upload date formatted as sYYYYeMMDD",
season_episode_from_date: "the upload date formatted as <code>sYYYYeMMDD</code>",
season_episode_index_from_date:
"the upload date formatted as sYYYYeMMDDII where II is an index to prevent date collisions"
"the upload date formatted as <code>sYYYYeMMDDII</code> where <code>II</code> is an index to prevent date collisions",
media_playlist_index:
"the place of the media item in the playlist. Do not use with channels. May not work if the playlist is updated"
}
end
@@ -77,7 +91,6 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
id
ext
title
fulltitle
uploader
channel
upload_date
@@ -99,7 +112,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
end
defp media_center_output_template do
"/shows/{{ source_custom_name }}/Season {{ season_from_date }}/{{ season_episode_index_from_date }} - {{ title }}.{{ ext }}"
"/shows/{{ source_custom_name }}/{{ season_by_year__episode_by_date_and_index }} - {{ title }}.{{ ext }}"
end
defp audio_output_template do
@@ -84,22 +84,22 @@
/>
</section>
<section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: false } }">
<.input
field={f[:download_auto_subs]}
type="toggle"
label="Download Autogenerated Subtitles"
help="Prefers normal subs but will download autogenerated if needed. Requires 'Download Subtitles' to be enabled"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
<.input
field={f[:embed_subs]}
type="toggle"
label="Embed Subtitles"
help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles' (recommended)"
help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles'"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: false } }">
<.input
field={f[:download_auto_subs]}
type="toggle"
label="Use Autogenerated Subtitles"
help="Prefers normal subs with 'Download Subtitles' or 'Embed Subtitles' but will use autogenerated subs if needed."
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
@@ -1,7 +1,7 @@
<%!-- The heex HTML formatter is really struggling with this file - I apologize in advance --%>
<aside>
<h2 class="text-xl font-bold mb-2">Output Template Syntax</h2>
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
<section class="ml-2 md:ml-4 mb-4 max-w-prose overflow-auto">
<p>When generating an output template, you have 3 options for syntax:</p>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li>
@@ -46,7 +46,7 @@
</section>
<h2 class="text-xl font-bold mb-2">Template Options</h2>
<section class="ml-2 md:ml-4 mb-4">
<section class="ml-2 md:ml-4 mb-4 overflow-auto">
<p class="max-w-prose">
Any single-word <code class="text-sm">yt-dlp</code>
option
@@ -56,16 +56,23 @@
can be used with the curly braced liquid-style syntax.
This is just a list of the most common options as well as some custom aliases
</p>
<h3 class="text-lg font-bold mb-2">Custom Aliases</h3>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li :for={{k, v} <- custom_output_template_options()}>
<h3 class="text-lg font-bold my-2">Media Center Custom Aliases</h3>
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
<li :for={{k, v} <- media_center_custom_output_template_options()} class="mt-1">
<.inline_code>{{ <%= k %> }}</.inline_code>
<span :if={v}>- <%= v %></span>
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
</li>
</ul>
<h3 class="text-lg font-bold mb-2">Other Custom Aliases</h3>
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
<li :for={{k, v} <- other_custom_output_template_options()} class="mt-1">
<.inline_code>{{ <%= k %> }}</.inline_code>
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
</li>
</ul>
<h3 class="text-lg font-bold mb-2">Common Options</h3>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li :for={opt <- common_output_template_options()}>
<li :for={opt <- common_output_template_options()} class="mt-1">
<.inline_code>{{ <%= opt %> }}</.inline_code>
</li>
</ul>
@@ -8,6 +8,7 @@ defmodule PinchflatWeb.Sources.SourceController do
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Sources.SourceDeletionWorker
alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker
@@ -17,6 +18,7 @@ defmodule PinchflatWeb.Sources.SourceController do
from s in Source,
as: :source,
inner_join: mp in assoc(s, :media_profile),
where: is_nil(s.marked_for_deletion_at) and is_nil(mp.marked_for_deletion_at),
preload: [media_profile: mp],
order_by: [asc: s.custom_name],
select: map(s, ^Source.__schema__(:fields)),
@@ -41,13 +43,30 @@ defmodule PinchflatWeb.Sources.SourceController do
render(conn, :index, sources: Repo.all(source_query))
end
def new(conn, _params) do
changeset = Sources.change_source(%Source{})
def new(conn, params) do
# This lets me preload the settings from another source for more efficient creation
cs_struct =
case to_string(params["template_id"]) do
"" -> %Source{}
template_id -> Repo.get(Source, template_id) || %Source{}
end
render(conn, :new,
changeset: changeset,
media_profiles: media_profiles(),
layout: get_onboarding_layout()
layout: get_onboarding_layout(),
# Most of these don't actually _need_ to be nullified at this point,
# but if I don't do it now I know it'll bite me
changeset:
Sources.change_source(%Source{
cs_struct
| id: nil,
uuid: nil,
custom_name: nil,
collection_name: nil,
collection_id: nil,
collection_type: nil,
original_url: nil
})
)
end
@@ -107,19 +126,15 @@ defmodule PinchflatWeb.Sources.SourceController do
end
def delete(conn, %{"id" => id} = params) do
delete_files = Map.get(params, "delete_files", false)
# This awkward comparison converts the string to a boolean
delete_files = Map.get(params, "delete_files", "") == "true"
source = Sources.get_source!(id)
{:ok, _source} = Sources.delete_source(source, delete_files: delete_files)
flash_message =
if delete_files do
"Source and files deleted successfully."
else
"Source deleted successfully. Files were not deleted."
end
{:ok, _} = Sources.update_source(source, %{marked_for_deletion_at: DateTime.utc_now()})
SourceDeletionWorker.kickoff(source, %{delete_files: delete_files})
conn
|> put_flash(:info, flash_message)
|> put_flash(:info, "Source deletion started. This may take a while to complete.")
|> redirect(to: ~p"/sources")
end
@@ -9,6 +9,7 @@ defmodule PinchflatWeb.Sources.SourceHTML do
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
attr :media_profiles, :list, required: true
attr :method, :string, required: true
def source_form(assigns)
@@ -26,6 +26,11 @@
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span>
</:option>
<:option>
<.link href={~p"/sources/new?template_id=#{@source}"} method="get">
Use as Template
</.link>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
@@ -10,7 +10,12 @@
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div class="max-w-full">
<div class="flex flex-col gap-10">
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources/#{@source}"} />
<.source_form
changeset={@changeset}
media_profiles={@media_profiles}
action={~p"/sources/#{@source}"}
method="patch"
/>
</div>
</div>
</div>
@@ -8,7 +8,7 @@
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div class="max-w-full">
<div class="flex flex-col gap-10">
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources"} />
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources"} method="post" />
</div>
</div>
</div>
@@ -2,6 +2,7 @@
:let={f}
for={@changeset}
action={@action}
method={@method}
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
>
@@ -10,7 +11,7 @@
</.error>
<section x-data="{ mediaProfileId: null }">
<section class="flex justify-between items-center mt-8">
<section class="flex justify-between items-center mt-4">
<h3 class=" text-2xl text-black dark:text-white">
General Options
</h3>
@@ -19,6 +20,14 @@
</span>
</section>
<.input
field={f[:original_url]}
type="text"
label="Source URL"
help="URL of a channel or playlist (required)"
x-init="$el.focus()"
/>
<.input
field={f[:custom_name]}
type="text"
@@ -26,8 +35,6 @@
help="Does not impact indexing or downloading. Will be inferred from the source if left blank"
/>
<.input field={f[:original_url]} type="text" label="Source URL" help="URL of a channel or playlist (required)" />
<.input
field={f[:media_profile_id]}
options={Enum.map(@media_profiles, &{&1.name, &1.id})}
@@ -87,7 +94,7 @@
maxlength="10"
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
title="YYYY-MM-DD"
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format. Old media may be deleted or downloaded if you change this date"
/>
<.input
+7 -4
View File
@@ -4,8 +4,8 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "2024.6.10",
elixir: "~> 1.16",
version: "2024.7.30",
elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
@@ -71,7 +71,7 @@ defmodule Pinchflat.MixProject do
{:nimble_parsec, "~> 1.4"},
{:timex, "~> 3.0"},
{:mox, "~> 1.0", only: :test},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7.7", only: [:dev, :test], runtime: false},
{:credo_naming, "~> 2.1", only: [:dev, :test], runtime: false},
{:ex_check, "~> 0.14.0", only: [:dev, :test], runtime: false},
{:faker, "~> 0.17", only: :test},
@@ -87,6 +87,8 @@ defmodule Pinchflat.MixProject do
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
check: "check --config=tooling/.check.exs",
credo: "credo --config-file=tooling/.credo.exs",
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
@@ -101,7 +103,8 @@ defmodule Pinchflat.MixProject do
"ecto.rollback": [
"ecto.rollback",
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
]
],
"version.bump": "cmd ./tooling/version_bump.sh"
]
end
end
+1 -1
View File
@@ -7,7 +7,7 @@
"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_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"},
"credo": {:hex, :credo, "1.7.3", "05bb11eaf2f2b8db370ecaa6a6bda2ec49b2acd5e0418bc106b73b07128c0436", [: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", "35ea675a094c934c22fb1dca3696f3c31f2728ae6ef5a53b5d648c11180a4535"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
+3 -1
View File
@@ -5,7 +5,9 @@
"sqleton": "^2.2.0"
},
"scripts": {
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db"
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db",
"lint:check": "prettier . --check --config=tooling/.prettierrc.js --ignore-path=tooling/.prettierignore --ignore-path=.gitignore",
"lint:fix": "prettier . --write --config=tooling/.prettierrc.js --ignore-path=tooling/.prettierignore --ignore-path=.gitignore"
},
"private": true
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

After

Width:  |  Height:  |  Size: 498 KiB

@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddPlaylistIndexToMediaItems do
use Ecto.Migration
def change do
alter table(:media_items) do
add :playlist_index, :integer, null: false, default: 0
end
end
end
@@ -0,0 +1,13 @@
defmodule Pinchflat.Repo.Migrations.AddMarkedForDeletionAtToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :marked_for_deletion_at, :utc_datetime
end
alter table(:media_profiles) do
add :marked_for_deletion_at, :utc_datetime
end
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

@@ -93,15 +93,23 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end
test "includes :write_auto_subs option when specified", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{download_subs: true, download_auto_subs: true})
media_item_1 = update_media_profile_attribute(media_item, %{download_subs: true, download_auto_subs: true})
media_item_2 = update_media_profile_attribute(media_item, %{embed_subs: true, download_auto_subs: true})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:ok, res_1} = DownloadOptionBuilder.build(media_item_1)
assert {:ok, res_2} = DownloadOptionBuilder.build(media_item_2)
assert :write_auto_subs in res
assert :write_auto_subs in res_1
assert :write_auto_subs in res_2
end
test "doesn't include :write_auto_subs option when download_subs is false", %{media_item: media_item} do
media_item = update_media_profile_attribute(media_item, %{download_subs: false, download_auto_subs: true})
test "doesn't include :write_auto_subs option when download_subs and embed_subs is false", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{
download_subs: false,
embed_subs: false,
download_auto_subs: true
})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
@@ -130,9 +130,6 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
_download_prevented =
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", prevent_download: true)
_culled =
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", culled_at: now())
assert [] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
refute_enqueued(worker: MediaDownloadWorker)
@@ -10,7 +10,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
media_item =
@@ -162,20 +162,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
assert media_item.media_redownloaded_at == nil
end
test "calls the user script runner", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(UserScriptRunnerMock, :run, fn :media_downloaded, data ->
assert data.id == media_item.id
:ok
end)
perform_job(MediaDownloadWorker, %{id: media_item.id})
end
test "does not blow up if the record doesn't exist" do
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
end
@@ -237,4 +223,59 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
end
describe "perform/1 when testing user script callbacks" do
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
:ok
end
test "calls the media_pre_download user script runner", %{media_item: media_item} do
expect(UserScriptRunnerMock, :run, fn :media_pre_download, data ->
assert data.id == media_item.id
{:ok, "", 0}
end)
expect(UserScriptRunnerMock, :run, fn :media_downloaded, _ -> {:ok, "", 0} end)
perform_job(MediaDownloadWorker, %{id: media_item.id})
end
test "does not download the media if the pre-download script returns an error", %{media_item: media_item} do
expect(UserScriptRunnerMock, :run, fn :media_pre_download, _ -> {:ok, "", 1} end)
assert :ok = perform_job(MediaDownloadWorker, %{id: media_item.id})
media_item = Repo.reload!(media_item)
refute media_item.media_filepath
assert media_item.prevent_download
end
test "downloads media if the pre-download script is not present", %{media_item: media_item} do
expect(UserScriptRunnerMock, :run, fn :media_pre_download, _ -> {:ok, :no_executable} end)
expect(UserScriptRunnerMock, :run, fn :media_downloaded, _ -> {:ok, :no_executable} end)
assert :ok = perform_job(MediaDownloadWorker, %{id: media_item.id})
media_item = Repo.reload!(media_item)
assert media_item.media_filepath
refute media_item.prevent_download
end
test "calls the media_downloaded user script runner", %{media_item: media_item} do
expect(UserScriptRunnerMock, :run, fn :media_pre_download, _ -> {:ok, "", 0} end)
expect(UserScriptRunnerMock, :run, fn :media_downloaded, data ->
assert data.id == media_item.id
{:ok, "", 0}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id})
end
end
end
@@ -5,6 +5,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Media
alias Pinchflat.Downloading.MediaDownloader
setup do
@@ -123,6 +124,12 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert DateTime.diff(DateTime.utc_now(), updated_media_item.media_downloaded_at) < 2
end
test "it sets the culled_at to nil", %{media_item: media_item} do
Media.update_media_item(media_item, %{culled_at: DateTime.utc_now()})
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
assert updated_media_item.culled_at == nil
end
test "it extracts the title", %{media_item: media_item} do
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
assert updated_media_item.title == "Pinchflat Example Video"
@@ -7,15 +7,36 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
alias Pinchflat.Media
alias Pinchflat.Downloading.MediaRetentionWorker
describe "perform/1" do
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
:ok
:ok
end
describe "perform/1" do
test "sets deleted media to not re-download" do
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(new_media_item).prevent_download
assert Repo.reload!(old_media_item).prevent_download
end
test "sets culled_at timestamp on deleted media" do
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(new_media_item).culled_at
assert Repo.reload!(old_media_item).culled_at
assert DateTime.diff(now(), Repo.reload!(old_media_item).culled_at) < 1
end
end
describe "perform/1 when testing retention_period-based culling" do
test "deletes media files that are past their retention date" do
{_source, old_media_item, new_media_item} = prepare_records()
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
perform_job(MediaRetentionWorker, %{})
@@ -25,27 +46,33 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
refute Repo.reload!(old_media_item).media_filepath
end
test "sets deleted media to not re-download" do
{_source, old_media_item, new_media_item} = prepare_records()
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(new_media_item).prevent_download
assert Repo.reload!(old_media_item).prevent_download
end
test "sets culled_at timestamp on deleted media" do
{_source, old_media_item, new_media_item} = prepare_records()
test "sets culled_at and prevent_download" do
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(new_media_item).culled_at
assert Repo.reload!(old_media_item).culled_at
assert DateTime.diff(now(), Repo.reload!(old_media_item).culled_at) < 1
refute Repo.reload!(new_media_item).prevent_download
assert Repo.reload!(old_media_item).prevent_download
end
test "doesn't cull if the source doesn't have a retention period" do
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date(nil)
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
assert Repo.reload!(old_media_item).media_filepath
refute Repo.reload!(new_media_item).culled_at
refute Repo.reload!(old_media_item).culled_at
end
test "doesn't cull media items that have prevent_culling set" do
{_source, old_media_item, _new_media_item} = prepare_records()
{_source, old_media_item, _new_media_item} = prepare_records_for_retention_date()
Media.update_media_item(old_media_item, %{prevent_culling: true})
@@ -53,11 +80,99 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(old_media_item).media_filepath
refute Repo.reload!(old_media_item).culled_at
end
test "doesn't cull if the media item has no media_filepath" do
{_source, old_media_item, _new_media_item} = prepare_records_for_retention_date()
Media.update_media_item(old_media_item, %{media_filepath: nil})
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(old_media_item).culled_at
end
end
defp prepare_records do
source = source_fixture(%{retention_period_days: 2})
describe "perform/1 when testing source cutoff-based culling" do
test "culls media from before the cutoff date" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date()
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 but not prevent_download" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date()
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(new_media_item).culled_at
assert Repo.reload!(old_media_item).culled_at
refute Repo.reload!(new_media_item).prevent_download
refute Repo.reload!(old_media_item).prevent_download
end
test "doesn't cull media if the source doesn't have a cutoff date" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(nil)
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 from on or after the cutoff date" do
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(2)
Media.update_media_item(old_media_item, %{uploaded_at: now_minus(2, :days)})
Media.update_media_item(new_media_item, %{uploaded_at: now_minus(1, :day)})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
assert Repo.reload!(old_media_item).media_filepath
refute Repo.reload!(new_media_item).culled_at
refute Repo.reload!(old_media_item).culled_at
end
test "doesn't cull media items that have prevent_culling set" do
{_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date()
Media.update_media_item(old_media_item, %{prevent_culling: true})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(old_media_item).media_filepath
refute Repo.reload!(old_media_item).culled_at
end
test "doesn't cull if the media item has no media_filepath" do
{_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date()
Media.update_media_item(old_media_item, %{media_filepath: nil})
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(old_media_item).culled_at
end
end
defp prepare_records_for_retention_date(retention_period_days \\ 2) do
source = source_fixture(%{retention_period_days: retention_period_days})
old_media_item =
media_item_with_attachments(%{
@@ -73,4 +188,23 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
{source, old_media_item, new_media_item}
end
defp prepare_records_for_source_cutoff_date(download_cutoff_date_days_ago \\ 2) do
cutoff_date = if download_cutoff_date_days_ago, do: now_minus(download_cutoff_date_days_ago, :days), else: nil
source = source_fixture(%{download_cutoff_date: cutoff_date})
old_media_item =
media_item_with_attachments(%{
source_id: source.id,
uploaded_at: now_minus(3, :days)
})
new_media_item =
media_item_with_attachments(%{
source_id: source.id,
uploaded_at: now_minus(1, :day)
})
{source, old_media_item, new_media_item}
end
end
@@ -19,7 +19,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunnerTest do
File.write(filepath(), "#!/bin/bash\ntouch #{filename}\n")
refute File.exists?(filename)
assert :ok = Runner.run(:media_downloaded, %{})
assert {:ok, _, _} = Runner.run(:media_downloaded, %{})
assert File.exists?(filename)
end
@@ -27,7 +27,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunnerTest do
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
File.write(filepath(), "#!/bin/bash\necho $1 > #{tmp_dir}/event_name\n")
assert :ok = Runner.run(:media_downloaded, %{})
assert {:ok, _, _} = Runner.run(:media_downloaded, %{})
assert File.read!("#{tmp_dir}/event_name") == "media_downloaded\n"
end
@@ -35,26 +35,32 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunnerTest do
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
File.write(filepath(), "#!/bin/bash\necho $2 > #{tmp_dir}/encoded_data\n")
assert :ok = Runner.run(:media_downloaded, %{foo: "bar"})
assert {:ok, _, _} = Runner.run(:media_downloaded, %{foo: "bar"})
assert File.read!("#{tmp_dir}/encoded_data") == "{\"foo\":\"bar\"}\n"
end
test "does nothing if the lifecycle file is not present" do
:ok = File.rm(filepath())
assert :ok = Runner.run(:media_downloaded, %{})
assert {:ok, :no_executable} = Runner.run(:media_downloaded, %{})
end
test "does nothing if the lifecycle file is empty" do
File.write(filepath(), "")
assert :ok = Runner.run(:media_downloaded, %{})
assert {:ok, :no_executable} = Runner.run(:media_downloaded, %{})
end
test "returns :ok if the command exits with a non-zero status" do
File.write(filepath(), "#!/bin/bash\nexit 1\n")
assert :ok = Runner.run(:media_downloaded, %{})
assert {:ok, _, 1} = Runner.run(:media_downloaded, %{})
end
test "returns the output of the command" do
File.write(filepath(), "#!/bin/bash\necho 'hello'\n")
assert {:ok, "hello\n", 0} = Runner.run(:media_downloaded, %{})
end
test "gets upset if you pass an invalid event type" do
+33 -108
View File
@@ -41,99 +41,7 @@ defmodule Pinchflat.MediaTest do
end
end
describe "list_cullable_media_items/0" do
test "returns media items where the source has a retention period" do
source_one = source_fixture(%{retention_period_days: 2})
source_two = source_fixture(%{retention_period_days: 0})
source_three = source_fixture(%{retention_period_days: nil})
_media_item =
media_item_fixture(%{
source_id: source_two.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
_media_item =
media_item_fixture(%{
source_id: source_three.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
expected_media_item =
media_item_fixture(%{
source_id: source_one.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_cullable_media_items() == [expected_media_item]
end
test "returns media_items with a media_filepath" do
source = source_fixture(%{retention_period_days: 2})
_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: nil,
media_downloaded_at: now_minus(3, :days)
})
expected_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_cullable_media_items() == [expected_media_item]
end
test "returns items that have passed their retention period" do
source = source_fixture(%{retention_period_days: 2})
_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(2, :days)
})
expected_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_cullable_media_items() == [expected_media_item]
end
test "doesn't return items that are set to prevent culling" do
source = source_fixture(%{retention_period_days: 2})
_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days),
prevent_culling: true
})
expected_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_cullable_media_items() == [expected_media_item]
end
end
describe "list_redownloadable_media_items/0" do
describe "list_upgradeable_media_items/0" do
setup do
media_profile = media_profile_fixture(%{redownload_delay_days: 4})
source = source_fixture(%{media_profile_id: media_profile.id, inserted_at: now_minus(10, :days)})
@@ -149,7 +57,7 @@ defmodule Pinchflat.MediaTest do
media_downloaded_at: now_minus(5, :days)
})
assert Media.list_redownloadable_media_items() == [media_item]
assert Media.list_upgradeable_media_items() == [media_item]
end
test "returns media items that were downloaded in past but still meet redownload delay", %{source: source} do
@@ -160,7 +68,7 @@ defmodule Pinchflat.MediaTest do
media_downloaded_at: now_minus(19, :days)
})
assert Media.list_redownloadable_media_items() == [media_item]
assert Media.list_upgradeable_media_items() == [media_item]
end
test "does not return media items without a media_downloaded_at", %{source: source} do
@@ -171,7 +79,7 @@ defmodule Pinchflat.MediaTest do
media_downloaded_at: nil
})
assert Media.list_redownloadable_media_items() == []
assert Media.list_upgradeable_media_items() == []
end
test "does not return media items that are set to prevent download", %{source: source} do
@@ -183,7 +91,7 @@ defmodule Pinchflat.MediaTest do
prevent_download: true
})
assert Media.list_redownloadable_media_items() == []
assert Media.list_upgradeable_media_items() == []
end
test "does not return media items that have been culled", %{source: source} do
@@ -195,7 +103,7 @@ defmodule Pinchflat.MediaTest do
culled_at: now()
})
assert Media.list_redownloadable_media_items() == []
assert Media.list_upgradeable_media_items() == []
end
test "does not return media items before the download delay", %{source: source} do
@@ -206,7 +114,7 @@ defmodule Pinchflat.MediaTest do
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_redownloadable_media_items() == []
assert Media.list_upgradeable_media_items() == []
end
test "does not return media items that have already been redownloaded", %{source: source} do
@@ -218,7 +126,7 @@ defmodule Pinchflat.MediaTest do
media_redownloaded_at: now()
})
assert Media.list_redownloadable_media_items() == []
assert Media.list_upgradeable_media_items() == []
end
test "does not return media items that were first downloaded well after the uploaded_at", %{source: source} do
@@ -229,7 +137,7 @@ defmodule Pinchflat.MediaTest do
uploaded_at: now_minus(20, :days)
})
assert Media.list_redownloadable_media_items() == []
assert Media.list_upgradeable_media_items() == []
end
test "does not return media items that were recently uploaded", %{source: source} do
@@ -240,7 +148,7 @@ defmodule Pinchflat.MediaTest do
uploaded_at: now_minus(2, :days)
})
assert Media.list_redownloadable_media_items() == []
assert Media.list_upgradeable_media_items() == []
end
test "does not return media items without a redownload delay" do
@@ -254,7 +162,7 @@ defmodule Pinchflat.MediaTest do
media_downloaded_at: now_minus(5, :days)
})
assert Media.list_redownloadable_media_items() == []
assert Media.list_upgradeable_media_items() == []
end
end
@@ -689,7 +597,24 @@ defmodule Pinchflat.MediaTest do
assert {:ok, %MediaItem{} = media_item_2} = Media.create_media_item_from_backend_attrs(source, different_attrs)
assert media_item_1.id == media_item_2.id
assert media_item_2.title == different_attrs.title
assert Repo.reload(media_item_2).title == different_attrs.title
end
test "doesn't update fields like playlist_index" do
source = source_fixture()
media_attrs =
media_attributes_return_fixture()
|> Phoenix.json_library().decode!()
|> Map.put("playlist_index", 1)
|> YtDlpMedia.response_to_struct()
different_attrs = %YtDlpMedia{media_attrs | playlist_index: 9999}
assert {:ok, %MediaItem{} = _media_item_1} = Media.create_media_item_from_backend_attrs(source, media_attrs)
assert {:ok, %MediaItem{} = media_item_2} = Media.create_media_item_from_backend_attrs(source, different_attrs)
assert Repo.reload(media_item_2).playlist_index == media_attrs.playlist_index
end
end
@@ -759,7 +684,7 @@ defmodule Pinchflat.MediaTest do
describe "delete_media_item/2 when testing file deletion" do
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
:ok
end
@@ -820,7 +745,7 @@ defmodule Pinchflat.MediaTest do
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
assert data.id == media_item.id
:ok
{:ok, "", 0}
end)
assert {:ok, _} = Media.delete_media_item(media_item, delete_files: true)
@@ -829,7 +754,7 @@ defmodule Pinchflat.MediaTest do
describe "delete_media_files/2" do
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
:ok
end
@@ -892,7 +817,7 @@ defmodule Pinchflat.MediaTest do
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
assert data.id == media_item.id
:ok
{:ok, "", 0}
end)
assert {:ok, _} = Media.delete_media_files(media_item)
@@ -11,6 +11,16 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
{:ok, %{media_item: media_item}}
end
describe "metadata_directory_for/1" do
test "returns the metadata directory for the given record", %{media_item: media_item} do
base_metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
metadata_directory = Helpers.metadata_directory_for(media_item)
assert metadata_directory == Path.join([base_metadata_directory, "media_items", "#{media_item.id}"])
end
end
describe "compress_and_store_metadata_for/2" do
test "returns the filepath", %{media_item: media_item} do
metadata_map = %{"foo" => "bar"}
@@ -32,7 +42,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
metadata_map = %{"foo" => "bar"}
filepath = Helpers.compress_and_store_metadata_for(media_item, metadata_map)
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all))
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :eof))
assert json == Phoenix.json_library().encode!(metadata_map)
end
@@ -142,13 +152,20 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end
end
describe "metadata_directory_for/1" do
test "returns the metadata directory for the given record", %{media_item: media_item} do
base_metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
describe "season_and_episode_from_media_filepath/1" do
test "returns a season and episode if one can be determined" do
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/s1e2 - test.mp4")
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/S1E2 - test.mp4")
assert {:ok, {"001", "002"}} = Helpers.season_and_episode_from_media_filepath("/foo/s001e002 - test.mp4")
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/s1e2bar - test.mp4")
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/bar s1e2 - test.mp4")
end
metadata_directory = Helpers.metadata_directory_for(media_item)
assert metadata_directory == Path.join([base_metadata_directory, "media_items", "#{media_item.id}"])
test "returns an error if a season and episode can't be determined" do
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/test.mp4")
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1 - test.mp4")
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1e - test.mp4")
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1etest.mp4")
end
end
end
+38 -1
View File
@@ -5,7 +5,7 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
alias Pinchflat.Utils.FilesystemUtils
setup do
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
filepath = FilesystemUtils.generate_metadata_tmpfile(:nfo)
on_exit(fn -> File.rm!(filepath) end)
@@ -45,6 +45,43 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
assert String.contains?(nfo, "hello&#39; &amp; &lt;world&gt;")
end
test "uses the season and episode number from the filepath if it can be determined" do
metadata = %{
"title" => "title",
"uploader" => "uploader",
"id" => "id",
"description" => "description",
"upload_date" => "20210101"
}
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
filepath = Path.join([tmpfile_directory, "foo/s0123e456.nfo"])
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
nfo = File.read!(result)
assert String.contains?(nfo, "<season>0123</season>")
assert String.contains?(nfo, "<episode>456</episode>")
File.rm!(filepath)
end
test "uses the upload date if the season and episode number can't be determined", %{filepath: filepath} do
metadata = %{
"title" => "title",
"uploader" => "uploader",
"id" => "id",
"description" => "description",
"upload_date" => "20210101"
}
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
nfo = File.read!(result)
assert String.contains?(nfo, "<season>2021</season>")
assert String.contains?(nfo, "<episode>0101</episode>")
end
end
describe "build_and_store_for_source/2" do
@@ -75,4 +75,51 @@ defmodule Pinchflat.Metadata.SourceImageParserTest do
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
end
end
describe "store_source_images/2 when testing fallbacks" do
test "uses the entries list for a fallback poster if needed" do
metadata = %{
"thumbnails" => [],
"entries" => [
%{
"thumbnails" => [%{"filepath" => "/app/test/support/files/channel_photos/a.0.jpg"}]
}
]
}
expected = %{
poster_filepath: "#{@base_dir}/poster.jpg"
}
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
end
test "doesn't blow up if the entries list doesn't have any suitable thumbnails" do
metadata = %{
"thumbnails" => [],
"entries" => [
%{"thumbnails" => [%{"id" => "1"}]}
]
}
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
end
test "doesn't use the entries list if it's empty" do
metadata = %{
"thumbnails" => [],
"entries" => []
}
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
end
test "doesn't use the entries list if it's not present" do
metadata = %{
"thumbnails" => []
}
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
end
end
end
@@ -143,7 +143,9 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
_url, opts, ot when ot == @metadata_ot ->
assert {:convert_thumbnails, "jpg"} in opts
{:ok, render_metadata(:channel_source_metadata)}
end)
@@ -164,6 +166,42 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
Sources.delete_source(source, delete_files: true)
end
test "calls one set of yt-dlp metadata opts for channels" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
{:ok, source_details_return_fixture()}
_url, opts, ot when ot == @metadata_ot ->
assert {:playlist_items, 0} in opts
assert :write_all_thumbnails in opts
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id, collection_type: :channel)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
end
test "calls another set of yt-dlp metadata opts for playlists" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
{:ok, source_details_return_fixture()}
_url, opts, ot when ot == @metadata_ot ->
assert {:playlist_items, 1} in opts
assert :write_thumbnail in opts
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id, collection_type: :playlist)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
end
test "does not store source images if the profile is not set to" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
@@ -0,0 +1,57 @@
defmodule Pinchflat.Profiles.MediaProfileDeletionWorkerTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Profiles.MediaProfileDeletionWorker
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
{:ok, %{profile: media_profile_fixture()}}
end
describe "kickoff/3" do
test "starts the worker", %{profile: profile} do
assert [] = all_enqueued(worker: MediaProfileDeletionWorker)
assert {:ok, _} = MediaProfileDeletionWorker.kickoff(profile)
assert [_] = all_enqueued(worker: MediaProfileDeletionWorker)
end
test "can be called with additional job arguments", %{profile: profile} do
job_args = %{"delete_files" => true}
assert {:ok, _} = MediaProfileDeletionWorker.kickoff(profile, job_args)
assert_enqueued(worker: MediaProfileDeletionWorker, args: %{"id" => profile.id, "delete_files" => true})
end
end
describe "perform/1" do
test "deletes the profile, sources, and media but leaves the files", %{profile: profile} do
source = source_fixture(%{media_profile_id: profile.id})
media_item = media_item_with_attachments(%{source_id: source.id})
perform_job(MediaProfileDeletionWorker, %{"id" => profile.id})
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(profile) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
assert File.exists?(media_item.media_filepath)
end
test "deletes the profile, sources, and media and files if specified", %{profile: profile} do
source = source_fixture(%{media_profile_id: profile.id})
media_item = media_item_with_attachments(%{source_id: source.id})
perform_job(MediaProfileDeletionWorker, %{"id" => profile.id, "delete_files" => true})
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(profile) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
refute File.exists?(media_item.media_filepath)
end
end
end
+1 -1
View File
@@ -113,7 +113,7 @@ defmodule Pinchflat.ProfilesTest do
describe "delete_media_profile/2 when deleting files" do
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
:ok
end
@@ -0,0 +1,52 @@
defmodule Pinchflat.Sources.SourceDeletionWorkerTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Sources.SourceDeletionWorker
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
{:ok, %{source: source_fixture()}}
end
describe "kickoff/3" do
test "starts the worker", %{source: source} do
assert [] = all_enqueued(worker: SourceDeletionWorker)
assert {:ok, _} = SourceDeletionWorker.kickoff(source)
assert [_] = all_enqueued(worker: SourceDeletionWorker)
end
test "can be called with additional job arguments", %{source: source} do
job_args = %{"delete_files" => true}
assert {:ok, _} = SourceDeletionWorker.kickoff(source, job_args)
assert_enqueued(worker: SourceDeletionWorker, args: %{"id" => source.id, "delete_files" => true})
end
end
describe "perform/1" do
test "deletes the source but leaves the files", %{source: source} do
media_item = media_item_with_attachments(%{source_id: source.id})
perform_job(SourceDeletionWorker, %{"id" => source.id})
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
assert File.exists?(media_item.media_filepath)
end
test "deletes the source and files if specified", %{source: source} do
media_item = media_item_with_attachments(%{source_id: source.id})
perform_job(SourceDeletionWorker, %{"id" => source.id, "delete_files" => true})
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
refute File.exists?(media_item.media_filepath)
end
end
end
+23 -1
View File
@@ -617,7 +617,7 @@ defmodule Pinchflat.SourcesTest do
describe "delete_source/2 when deleting files" do
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
:ok
end
@@ -659,6 +659,28 @@ defmodule Pinchflat.SourcesTest do
end
end
describe "change_source/3 when testing regex validation" do
test "succeeds when a valid regex is provided" do
source = source_fixture()
assert %{errors: []} = Sources.change_source(source, %{title_filter_regex: "(?i)^How to Bike$"})
end
test "succeeds when a regex is set back to nil" do
source = source_fixture(%{title_filter_regex: "(?i)^How to Bike$"})
assert %{errors: []} = Sources.change_source(source, %{title_filter_regex: nil})
end
test "fails when an invalid regex is provided" do
source = source_fixture()
changeset = Sources.change_source(source, %{title_filter_regex: "*FOO"})
assert "is invalid" in errors_on(changeset).title_filter_regex
end
end
describe "change_source/3 when testing original_url validation" do
test "succeeds when an original URL is valid" do
source = source_fixture()
@@ -129,7 +129,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
test "it passes the expected args to the backend runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
assert opts == [playlist_items: 0]
assert opts == [:skip_download, playlist_items: 0]
assert ot == "playlist:%()j"
{:ok, "{}"}
@@ -152,12 +152,18 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
test "allows you to pass additional opts" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
assert opts == [playlist_items: 0, real_opt: :yup]
assert opts == [:skip_download, playlist_items: 1, real_opt: :yup]
{:ok, "{}"}
end)
assert {:ok, _} = MediaCollection.get_source_metadata(@channel_url, real_opt: :yup)
assert {:ok, _} = MediaCollection.get_source_metadata(@channel_url, playlist_items: 1, real_opt: :yup)
end
test "blows up if you pass addl opts but don't pass playlist items" do
assert_raise KeyError, fn ->
MediaCollection.get_source_metadata(@channel_url, real_opt: :yup)
end
end
end
end
+19 -4
View File
@@ -110,8 +110,10 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "indexing_output_template/0" do
test "contains all the greatest hits" do
assert "%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp})j" ==
Media.indexing_output_template()
attrs = ~w(id title was_live webpage_url description aspect_ratio duration upload_date timestamp playlist_index)a
formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j"
assert formatted_attrs == Media.indexing_output_template()
end
end
@@ -126,7 +128,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
"aspect_ratio" => 1.0,
"duration" => 60,
"upload_date" => "20210101",
"timestamp" => 1_600_000_000
"timestamp" => 1_600_000_000,
"playlist_index" => 1
}
assert %Media{
@@ -137,7 +140,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
livestream: false,
short_form_content: false,
uploaded_at: ~U[2020-09-13 12:26:40Z],
duration_seconds: 60
duration_seconds: 60,
playlist_index: 1
} == Media.response_to_struct(response)
end
@@ -217,6 +221,17 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert %Media{livestream: false} = Media.response_to_struct(response)
end
test "doesn't blow up if playlist_index is missing" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => nil,
"upload_date" => "20210101"
}
assert %Media{playlist_index: 0} = Media.response_to_struct(response)
end
end
describe "response_to_struct/1 when testing uploaded_at" do
@@ -56,7 +56,7 @@ defmodule PinchflatWeb.MediaItemControllerTest do
describe "delete media" do
setup do
media_item = media_item_with_attachments()
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
%{media_item: media_item}
end
@@ -1,12 +1,11 @@
defmodule PinchflatWeb.MediaProfileControllerTest do
use PinchflatWeb.ConnCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Repo
alias Pinchflat.Settings
alias Pinchflat.Profiles.MediaProfileDeletionWorker
@create_attrs %{name: "some name", output_path_template: "output_template.{{ ext }}"}
@update_attrs %{
@@ -23,8 +22,17 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
describe "index" do
test "lists all media_profiles", %{conn: conn} do
profile = media_profile_fixture()
conn = get(conn, ~p"/media_profiles")
assert html_response(conn, 200) =~ "Media Profiles"
assert html_response(conn, 200) =~ profile.name
end
test "omits profiles that have marked_for_deletion_at set", %{conn: conn} do
profile = media_profile_fixture(marked_for_deletion_at: DateTime.utc_now())
conn = get(conn, ~p"/media_profiles")
refute html_response(conn, 200) =~ profile.name
end
end
@@ -102,34 +110,28 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
end
end
describe "delete media_profile when just deleting the records" do
describe "delete media_profile in all cases" do
setup [:create_media_profile]
test "deletes chosen media_profile and its associations", %{conn: conn, media_profile: media_profile} do
source = source_fixture(media_profile_id: media_profile.id)
media_item = media_item_with_attachments(%{source_id: source.id})
conn = delete(conn, ~p"/media_profiles/#{media_profile}")
assert redirected_to(conn) == ~p"/media_profiles"
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_profile) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
end
test "redirects to the media_profiles page", %{conn: conn, media_profile: media_profile} do
conn = delete(conn, ~p"/media_profiles/#{media_profile}")
assert redirected_to(conn) == ~p"/media_profiles"
end
test "doesn't delete any files", %{conn: conn, media_profile: media_profile} do
source = source_fixture(media_profile_id: media_profile.id)
media_item = media_item_with_attachments(%{source_id: source.id})
test "sets marked_for_deletion_at", %{conn: conn, media_profile: media_profile} do
delete(conn, ~p"/media_profiles/#{media_profile}")
assert Repo.reload!(media_profile).marked_for_deletion_at
end
end
describe "delete media_profile when just deleting the records" do
setup [:create_media_profile]
test "enqueues a job without the delete_files arg", %{conn: conn, media_profile: media_profile} do
delete(conn, ~p"/media_profiles/#{media_profile}")
assert File.exists?(media_item.media_filepath)
assert [%{args: %{"delete_files" => false}}] = all_enqueued(worker: MediaProfileDeletionWorker)
end
end
@@ -137,36 +139,15 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
setup [:create_media_profile]
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
:ok
end
test "deletes chosen media_profile and its associations", %{conn: conn, media_profile: media_profile} do
source = source_fixture(media_profile_id: media_profile.id)
media_item = media_item_with_attachments(%{source_id: source.id})
conn = delete(conn, ~p"/media_profiles/#{media_profile}?delete_files=true")
assert redirected_to(conn) == ~p"/media_profiles"
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_profile) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
end
test "redirects to the media_profiles page", %{conn: conn, media_profile: media_profile} do
conn = delete(conn, ~p"/media_profiles/#{media_profile}?delete_files=true")
assert redirected_to(conn) == ~p"/media_profiles"
end
test "deletes the files", %{conn: conn, media_profile: media_profile} do
source = source_fixture(media_profile_id: media_profile.id)
media_item = media_item_with_attachments(%{source_id: source.id})
test "enqueues a job with the delete_files arg", %{conn: conn, media_profile: media_profile} do
delete(conn, ~p"/media_profiles/#{media_profile}?delete_files=true")
refute File.exists?(media_item.media_filepath)
assert [%{args: %{"delete_files" => true}}] = all_enqueued(worker: MediaProfileDeletionWorker)
end
end
@@ -7,6 +7,7 @@ defmodule PinchflatWeb.SourceControllerTest do
alias Pinchflat.Repo
alias Pinchflat.Settings
alias Pinchflat.Sources.SourceDeletionWorker
alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.Metadata.SourceMetadataStorageWorker
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
@@ -33,8 +34,26 @@ defmodule PinchflatWeb.SourceControllerTest do
describe "index" do
test "lists all sources", %{conn: conn} do
source = source_fixture()
conn = get(conn, ~p"/sources")
assert html_response(conn, 200) =~ "Sources"
assert html_response(conn, 200) =~ source.custom_name
end
test "omits sources that have marked_for_deletion_at set", %{conn: conn} do
source = source_fixture(marked_for_deletion_at: DateTime.utc_now())
conn = get(conn, ~p"/sources")
refute html_response(conn, 200) =~ source.custom_name
end
test "omits sources who's media profile has marked_for_deletion_at set", %{conn: conn} do
media_profile = media_profile_fixture(marked_for_deletion_at: DateTime.utc_now())
source = source_fixture(media_profile_id: media_profile.id)
conn = get(conn, ~p"/sources")
refute html_response(conn, 200) =~ source.custom_name
end
end
@@ -50,6 +69,15 @@ defmodule PinchflatWeb.SourceControllerTest do
refute html_response(conn, 200) =~ "MENU"
end
test "preloads some attributes when using a template", %{conn: conn} do
source = source_fixture(custom_name: "My first source", download_cutoff_date: "2021-01-01")
conn = get(conn, ~p"/sources/new", %{"template_id" => source.id})
assert html_response(conn, 200) =~ "New Source"
assert html_response(conn, 200) =~ "2021-01-01"
refute html_response(conn, 200) =~ source.custom_name
end
end
describe "create source" do
@@ -118,51 +146,37 @@ defmodule PinchflatWeb.SourceControllerTest do
end
end
describe "delete source when just deleting the records" do
describe "delete source in all cases" do
setup [:create_source]
test "deletes chosen source and media_items", %{conn: conn, source: source, media_item: media_item} do
delete(conn, ~p"/sources/#{source}")
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
end
test "redirects to the sources page", %{conn: conn, source: source} do
conn = delete(conn, ~p"/sources/#{source}")
assert redirected_to(conn) == ~p"/sources"
end
test "does not delete the files", %{conn: conn, source: source, media_item: media_item} do
test "sets marked_for_deletion_at", %{conn: conn, source: source} do
delete(conn, ~p"/sources/#{source}")
assert File.exists?(media_item.media_filepath)
assert Repo.reload!(source).marked_for_deletion_at
end
end
describe "delete source when just deleting the records" do
setup [:create_source]
test "enqueues a job without the delete_files arg", %{conn: conn, source: source} do
delete(conn, ~p"/sources/#{source}")
assert [%{args: %{"delete_files" => false}}] = all_enqueued(worker: SourceDeletionWorker)
end
end
describe "delete source when deleting the records and files" do
setup [:create_source]
setup do
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
:ok
end
test "deletes chosen source and media_items", %{conn: conn, source: source, media_item: media_item} do
test "enqueues a job without the delete_files arg", %{conn: conn, source: source} do
delete(conn, ~p"/sources/#{source}?delete_files=true")
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
end
test "redirects to the sources page", %{conn: conn, source: source} do
conn = delete(conn, ~p"/sources/#{source}?delete_files=true")
assert redirected_to(conn) == ~p"/sources"
end
test "deletes the files", %{conn: conn, source: source, media_item: media_item} do
delete(conn, ~p"/sources/#{source}?delete_files=true")
refute File.exists?(media_item.media_filepath)
assert [%{args: %{"delete_files" => true}}] = all_enqueued(worker: SourceDeletionWorker)
end
end
+1 -1
View File
@@ -16,7 +16,7 @@
{:compiler, env: %{"MIX_ENV" => "test"}},
{:formatter, env: %{"MIX_ENV" => "test"}},
{:sobelow, "mix sobelow --config"},
{:prettier_formatting, "yarn run prettier . --check", fix: "yarn run prettier . --write"},
{:prettier_formatting, "yarn run lint:check", fix: "yarn run lint:fix"},
{:npm_test, false}
## curated tools may be disabled (e.g. the check for compilation warnings)
View File
+1
View File
@@ -0,0 +1 @@
../assets/vendor/
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Get the current date in the format YYYY.MM.DD (stripping leading zeros)
DATE=$(date +"%Y.%-m.%-d")
# Get the current version from mix.exs
VERSION=$(grep "version: " mix.exs | cut -d '"' -f2)
echo "Bumping version from $VERSION to $DATE"
# Replace the version in mix.exs with the new version
sed -i "s/version: \"$VERSION\"/version: \"$DATE\"/g" mix.exs
# Run checks to ensure it's a valid mix.exs file
mix check