Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9381c80aac | |||
| ce35130a93 | |||
| 3b1c1692fb | |||
| 79c61bca4f | |||
| f9c2f7b8f2 | |||
| 2f9abe86b4 | |||
| 22fbb4b930 | |||
| 3daf72a161 | |||
| 4e26253b33 | |||
| c58e176619 | |||
| 0eca0acbe8 | |||
| 89f4634d1f | |||
| 32ac51d7b0 | |||
| 198bef71bc | |||
| 54ea8a373a | |||
| 0722f43727 | |||
| 9dd64f3a2d | |||
| fae7ba3304 | |||
| bdd97bde01 | |||
| edb12b088f | |||
| 964ab2f89b | |||
| 216e05567f | |||
| 2d8b985f13 | |||
| d1fe87bdf3 | |||
| 23d65eaf5f | |||
| 4d6a93d16c | |||
| 9985aeca86 | |||
| f38d793f4e | |||
| 2006f33792 | |||
| 320b25f5b6 | |||
| d043e5574b | |||
| af86fede6c | |||
| 7411177e7d | |||
| 42a418f79e | |||
| c45c030c00 | |||
| c08654714a | |||
| 5c5e7a8a91 | |||
| 1b0d71ea48 | |||
| af803b7081 | |||
| fe641cfc49 | |||
| 65533b82da | |||
| 1923098427 | |||
| 6ad9e68682 | |||
| 9baaa65c1e | |||
| bd6ea15118 | |||
| e92e2001bf | |||
| a3d0927dfb | |||
| 54eacf52b3 | |||
| d0f7338f42 | |||
| ecc73d25a5 | |||
| b18c305957 | |||
| ccae3366a5 | |||
| 182ba7abe7 | |||
| f06c02bf62 | |||
| 37eb745d03 | |||
| 66b14c3d8f | |||
| d9b63875ab | |||
| aed2322c63 | |||
| 723cf2a7e9 | |||
| b4cdca5206 | |||
| 9310b5e26e | |||
| 3c522d8d6b | |||
| 1328dff7d6 | |||
| e2385eccd6 | |||
| 565a1d4972 | |||
| 05f3deebfa | |||
| f91c707c7c | |||
| 6e7e8dc96c | |||
| b633d0f75c | |||
| a135746c97 | |||
| c67278ab5c | |||
| 5bde1205cc | |||
| c1e3ed2128 | |||
| 1cc8731914 | |||
| abe99ce6f0 | |||
| f506b9bc28 | |||
| 5b2d6b094d | |||
| 33baa99aae | |||
| 7fa5c4da63 | |||
| 993865909c | |||
| f156fec4b7 | |||
| e1fc361e98 | |||
| 2267b47de8 | |||
| e6b04bd675 | |||
| ea4d689fea | |||
| 2f6a19f0e7 | |||
| f60ec4f49d | |||
| c0a11e98d9 | |||
| 19ec13e64c | |||
| e1f8e94686 | |||
| 128485c30a | |||
| b7beeaf9eb | |||
| 36daeff48c | |||
| 0948bebb9d | |||
| ef4a5cc99f | |||
| 82eb12b983 | |||
| a4f4afeac9 | |||
| 90a0022d85 | |||
| f55cdc80dd | |||
| b370c97bfb | |||
| 0f40dcf5dd | |||
| c37157d644 | |||
| 09448e3fcc | |||
| 9e75092876 | |||
| 36f27c76e8 | |||
| be8bcf0eb2 | |||
| 7809a25f2d | |||
| d2c2cda479 | |||
| 1f4823bda9 | |||
| 0020e5083b | |||
| c77047951f | |||
| 4094da9e03 | |||
| 3b94b269d9 | |||
| 927560beb6 | |||
| ab9b657394 | |||
| 610acb15f1 | |||
| 3efc533fb7 | |||
| e3f1b409b9 | |||
| 01042ebd1b | |||
| 1ec1a48235 | |||
| af7e76c1d1 | |||
| 69c47c2c65 | |||
| 1f56db01a5 | |||
| b81c8d64b3 | |||
| 53bd8681c3 | |||
| c5fcb8fe12 | |||
| 58771ee37b | |||
| 850cf2db73 | |||
| 77f06bfbc8 | |||
| e0637331bb | |||
| e1565ad22f | |||
| 9e4fbfa35d | |||
| b19c01e3ed | |||
| 366fd80213 | |||
| 977b69b7c3 | |||
| 4dd9d837a3 | |||
| 44bec0a127 | |||
| 89b1640eb6 | |||
| c03fd837ea |
@@ -1,54 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
type: choice
|
||||
description: 'Docker Image Tag'
|
||||
required: true
|
||||
default: 'dev'
|
||||
options:
|
||||
- 'dev'
|
||||
- 'latest'
|
||||
platforms:
|
||||
type: choice
|
||||
description: 'Build Platforms'
|
||||
required: true
|
||||
default: 'linux/amd64,linux/arm64'
|
||||
options:
|
||||
- 'linux/amd64,linux/arm64'
|
||||
- 'linux/amd64'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: ${{ github.event.inputs.platforms }}
|
||||
push: true
|
||||
file: ./selfhosted.Dockerfile
|
||||
tags: |
|
||||
keglin/pinchflat:${{ github.event.inputs.image_tag }}
|
||||
ghcr.io/${{ github.repository_owner }}/pinchflat:${{ github.event.inputs.image_tag }}
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
type: choice
|
||||
description: 'Build Platforms'
|
||||
required: true
|
||||
default: 'linux/amd64'
|
||||
options:
|
||||
- 'linux/amd64'
|
||||
- 'linux/amd64,linux/arm64'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
build_and_push_docker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
dev_arch: 'linux/amd64'
|
||||
release_arch: 'linux/amd64,linux/arm64'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/keglin/pinchflat
|
||||
ghcr.io/${{ github.repository }}
|
||||
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./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) }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -48,9 +48,11 @@ If it doesn't work for your use case, please make a feature request! You can als
|
||||
- Uses a novel approach to download new content more quickly than other apps
|
||||
- Supports downloading audio content
|
||||
- Custom rules for handling YouTube Shorts and livestreams
|
||||
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
|
||||
- Advanced options like setting cutoff dates and filtering by title
|
||||
- Reliable hands-off operation
|
||||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||
- Sponsorblock integration
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -65,6 +67,8 @@ Simply search for Pinchflat in the Community Apps store!
|
||||
|
||||
### Portainer
|
||||
|
||||
Important: See the note below about storing config on a network file share. It's preferred to store the config on a local disk if at all possible.
|
||||
|
||||
Docker Compose file:
|
||||
|
||||
```yaml
|
||||
@@ -104,6 +108,14 @@ You _must_ ensure the host directories you've mounted are writable by the user r
|
||||
|
||||
It's recommended to not run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media. If you need to run any command as root, you can run `su` from the container's shell as there is no password set for the root user.
|
||||
|
||||
### Advanced: storing Pinchflat config directory on a network share
|
||||
|
||||
README: This is currently in the testing phase and not a recommended option (yet). The implications of changing this setting isn't clear and this could, conceivably, result in data loss. Only change this setting if you know what you're doing, why this is important, and are okay with possible data loss or DB corruption. This may become the default in the future once it's been tested more thoroughly.
|
||||
|
||||
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
|
||||
|
||||
If you change this setting and it works well for you, please leave a comment on [#137](https://github.com/kieraneglin/pinchflat/issues/137)! Doubly so if it does _not_ work well.
|
||||
|
||||
## EFF donations
|
||||
|
||||
A portion of all donations to Pinchflat will be donated to the [Electronic Frontier Foundation](https://www.eff.org/). The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down. [See here](https://github.com/kieraneglin/pinchflat/wiki/EFF-Donation-Receipts) for a list of donation receipts.
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Socket } from 'phoenix'
|
||||
import { LiveSocket } from 'phoenix_live_view'
|
||||
import topbar from '../vendor/topbar'
|
||||
import Alpine from 'alpinejs'
|
||||
import './tabs'
|
||||
|
||||
window.Alpine = Alpine
|
||||
Alpine.start()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
window.setTabIndex = (index) => {
|
||||
window.location.hash = `tab-${index}`
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
// The conditionals and currIndex stuff ensures that
|
||||
// the tab index is always set to 0 if the hash is empty
|
||||
// AND other hash values are ignored
|
||||
window.getTabIndex = (currIndex) => {
|
||||
if (window.location.hash === '' || window.location.hash === '#') {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (window.location.hash.startsWith('#tab-')) {
|
||||
return parseInt(window.location.hash.replace('#tab-', ''))
|
||||
}
|
||||
|
||||
return currIndex
|
||||
}
|
||||
+11
-1
@@ -25,6 +25,10 @@ config :pinchflat,
|
||||
expose_feed_endpoints: false,
|
||||
file_watcher_poll_interval: 1000
|
||||
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
journal_mode: :wal,
|
||||
pool_size: 5
|
||||
|
||||
# Configures the endpoint
|
||||
config :pinchflat, PinchflatWeb.Endpoint,
|
||||
url: [host: "localhost", port: 8945],
|
||||
@@ -42,7 +46,13 @@ config :pinchflat, Oban,
|
||||
engine: Oban.Engines.Lite,
|
||||
repo: Pinchflat.Repo,
|
||||
# Keep old jobs for 30 days for display in the UI
|
||||
plugins: [{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60}],
|
||||
plugins: [
|
||||
{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
|
||||
{Oban.Plugins.Cron,
|
||||
crontab: [
|
||||
{"@daily", Pinchflat.Downloading.MediaRetentionWorker}
|
||||
]}
|
||||
],
|
||||
# TODO: consider making this an env var or something?
|
||||
queues: [
|
||||
default: 10,
|
||||
|
||||
+4
-10
@@ -50,16 +50,10 @@ if config_env() == :prod do
|
||||
# For running PF as a podcast host on self-hosted environments
|
||||
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
||||
|
||||
# We want to force _some_ level of useful logging in production
|
||||
acceptable_log_levels = ~w(debug info)a
|
||||
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
||||
# For testing alternate journal modes (see issue #137)
|
||||
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
|
||||
|
||||
if log_level in acceptable_log_levels do
|
||||
config :logger, level: log_level
|
||||
else
|
||||
Logger.error("Invalid log level: #{log_level}. Defaulting to info.")
|
||||
config :logger, level: :info
|
||||
end
|
||||
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
||||
|
||||
config :pinchflat,
|
||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||
@@ -72,7 +66,7 @@ if config_env() == :prod do
|
||||
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
database: db_path,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||
journal_mode: journal_mode
|
||||
|
||||
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||
# A default value is used in config/dev.exs and config/test.exs but you
|
||||
|
||||
+1
-3
@@ -17,7 +17,6 @@ config :pinchflat, Oban, testing: :manual
|
||||
# Run `mix help test` for more information.
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)),
|
||||
pool_size: 5,
|
||||
pool: Ecto.Adapters.SQL.Sandbox
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
@@ -33,8 +32,7 @@ config :pinchflat, Pinchflat.Mailer, adapter: Swoosh.Adapters.Test
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
config :swoosh, :api_client, false
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warning
|
||||
config :logger, level: :critical
|
||||
|
||||
# Initialize plugs at runtime for faster test compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
defmodule Pinchflat.Boot.DataBackfillWorker do
|
||||
@moduledoc false
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :local_metadata,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]
|
||||
|
||||
# This one is going to be a little more self-contained
|
||||
# instead of relying on outside modules for the methods.
|
||||
# That's because, for now, these methods are not intended
|
||||
# to be used elsewhere.
|
||||
#
|
||||
# I'm just trying out that pattern and seeing if I like it better
|
||||
# so this may change.
|
||||
import Ecto.Query, warn: false
|
||||
require Logger
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Repo
|
||||
|
||||
@doc """
|
||||
Cancels all pending backfill jobs. Useful for ensuring worker runs immediately
|
||||
on app boot.
|
||||
|
||||
Returns {:ok, integer()}
|
||||
"""
|
||||
def cancel_pending_backfill_jobs do
|
||||
Oban.Job
|
||||
|> where(worker: "Pinchflat.Boot.DataBackfillWorker")
|
||||
|> Oban.cancel_all_jobs()
|
||||
end
|
||||
|
||||
@impl Oban.Worker
|
||||
@doc """
|
||||
Performs one-off tasks to get data in the right shape.
|
||||
This can be needed when we add new features or change the way
|
||||
we store data. Must be idempotent. All new data should already
|
||||
conform to the expected schema so this should only be needed
|
||||
for existing data. Still runs periodically to be safe.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
def perform(%Oban.Job{}) do
|
||||
Logger.info("Running data backfill worker")
|
||||
# Nothing to do for now - just reschedule
|
||||
# Keeping in-place because we _will_ need it in the future
|
||||
|
||||
reschedule_backfill()
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp reschedule_backfill do
|
||||
# Run hourly
|
||||
next_run_in = 60 * 60
|
||||
|
||||
%{}
|
||||
|> DataBackfillWorker.new(schedule_in: next_run_in)
|
||||
|> Repo.insert_unique_job()
|
||||
end
|
||||
end
|
||||
@@ -11,9 +11,6 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
|
||||
use GenServer, restart: :temporary
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Boot.DataBackfillWorker
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, %{}, opts)
|
||||
end
|
||||
@@ -29,16 +26,7 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
|
||||
"""
|
||||
@impl true
|
||||
def init(state) do
|
||||
enqueue_backfill_worker()
|
||||
|
||||
# Empty for now, keeping because tasks _will_ be added in future
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
defp enqueue_backfill_worker do
|
||||
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||
|
||||
%{}
|
||||
|> DataBackfillWorker.new()
|
||||
|> Repo.insert_unique_job()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,6 +14,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.YtDlp.CommandRunner
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
@@ -62,7 +63,10 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
end
|
||||
|
||||
defp apply_default_settings do
|
||||
{:ok, yt_dlp_version} = CommandRunner.version()
|
||||
|
||||
Settings.fetch!(:onboarding, true)
|
||||
Settings.fetch!(:pro_enabled, false)
|
||||
Settings.set!(:yt_dlp_version, yt_dlp_version)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,6 +22,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
thumbnail_options(media_item_with_preloads) ++
|
||||
metadata_options(media_profile) ++
|
||||
quality_options(media_profile) ++
|
||||
sponsorblock_options(media_profile) ++
|
||||
output_options(media_item_with_preloads)
|
||||
|
||||
{:ok, built_options}
|
||||
@@ -116,6 +117,17 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
defp sponsorblock_options(media_profile) do
|
||||
categories = media_profile.sponsorblock_categories
|
||||
behaviour = media_profile.sponsorblock_behaviour
|
||||
|
||||
case {behaviour, categories} do
|
||||
{_, []} -> []
|
||||
{:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")]
|
||||
{:disabled, _} -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp output_options(media_item_with_preloads) do
|
||||
[
|
||||
output: build_output_path_for(media_item_with_preloads.source)
|
||||
|
||||
@@ -57,7 +57,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
{:ok, updated_media_item}
|
||||
|
||||
err ->
|
||||
err
|
||||
Logger.error("Failed to download media for media item #{media_item.id}: #{inspect(err)}")
|
||||
|
||||
{:error, :download_failed}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
||||
@moduledoc false
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :local_metadata,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||
tags: ["media_item", "local_metadata"]
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Media
|
||||
|
||||
@doc """
|
||||
Deletes media items that are past their retention date and prevents
|
||||
them from being re-downloaded.
|
||||
|
||||
This worker is scheduled to run daily via the Oban Cron plugin.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
cullable_media = Media.list_cullable_media_items()
|
||||
Logger.info("Culling #{length(cullable_media)} media items past their retention date")
|
||||
|
||||
Enum.each(cullable_media, fn media_item ->
|
||||
Media.delete_media_files(media_item, %{
|
||||
prevent_download: true,
|
||||
culled_at: DateTime.utc_now()
|
||||
})
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -5,8 +5,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
Many of these methods are made to be kickoff or be consumed by workers.
|
||||
"""
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.FastIndexing.YoutubeRss
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
||||
@@ -27,7 +29,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
"""
|
||||
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
|
||||
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
existing_media_items = Media.list_media_items_by_media_id_for(source, media_ids)
|
||||
existing_media_items = list_media_items_by_media_id_for(source, media_ids)
|
||||
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
|
||||
|
||||
Enum.each(new_media_ids, fn media_id ->
|
||||
@@ -61,6 +63,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
end
|
||||
end
|
||||
|
||||
defp list_media_items_by_media_id_for(source, media_ids) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_ids(media_ids)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp create_media_item_from_url(source, url) do
|
||||
{:ok, media_attrs} = YtDlpMedia.get_media_attributes(url)
|
||||
|
||||
|
||||
+53
-101
@@ -9,6 +9,7 @@ defmodule Pinchflat.Media do
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
@@ -22,29 +23,17 @@ defmodule Pinchflat.Media do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of media_items for a given source.
|
||||
Returns a list of media_items that are cullable based on the retention period
|
||||
of the source they belong to.
|
||||
|
||||
Returns [%MediaItem{}, ...].
|
||||
Returns [%MediaItem{}, ...]
|
||||
"""
|
||||
def list_media_items_for(%Source{} = source) do
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches all media items belonging to a given source that have a media_id in the given list.
|
||||
Useful for determining the what media items we DON'T already have for fast indexing.
|
||||
|
||||
NOTE: These queries are getting a little tedious. When I have the time, I should see about
|
||||
implementing a query pattern and having these compose queries from a common base. This would
|
||||
also let me compose simple queries in the module using them for one-off methods
|
||||
|
||||
Returns [%MediaItem{}, ...].
|
||||
"""
|
||||
def list_media_items_by_media_id_for(%Source{} = source, media_ids) do
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id and mi.media_id in ^media_ids)
|
||||
def list_cullable_media_items do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.join_sources()
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> MediaQuery.with_passed_retention_period()
|
||||
|> MediaQuery.with_no_culling_prevention()
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -53,20 +42,18 @@ defmodule Pinchflat.Media do
|
||||
pending means the `media_filepath` is `nil` AND the media_item
|
||||
matches the format selection rules of the parent media_profile.
|
||||
|
||||
See `build_format_clauses` but tl;dr is it _may_ filter based
|
||||
on shorts or livestreams depending on the media_profile settings.
|
||||
See `matching_download_criteria_for` but tl;dr is it _may_ filter based
|
||||
on shorts livestreams depending on the media_profile settings.
|
||||
|
||||
Returns [%MediaItem{}, ...].
|
||||
"""
|
||||
def list_pending_media_items_for(%Source{} = source, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, nil)
|
||||
media_profile = Repo.preload(source, :media_profile).media_profile
|
||||
source = Repo.preload(source, :media_profile)
|
||||
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|
||||
|> where(^build_format_clauses(media_profile))
|
||||
|> where(^maybe_apply_cutoff_date(source))
|
||||
|> where(^maybe_apply_title_regex(source))
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> matching_download_criteria_for(source)
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
end
|
||||
@@ -79,8 +66,9 @@ defmodule Pinchflat.Media do
|
||||
def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, nil)
|
||||
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath))
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
end
|
||||
@@ -97,11 +85,9 @@ defmodule Pinchflat.Media do
|
||||
def pending_download?(%MediaItem{} = media_item) do
|
||||
media_item = Repo.preload(media_item, source: :media_profile)
|
||||
|
||||
MediaItem
|
||||
|> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath))
|
||||
|> where(^build_format_clauses(media_item.source.media_profile))
|
||||
|> where(^maybe_apply_cutoff_date(media_item.source))
|
||||
|> where(^maybe_apply_title_regex(media_item.source))
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_id(media_item.id)
|
||||
|> matching_download_criteria_for(media_item.source)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@@ -120,20 +106,9 @@ defmodule Pinchflat.Media do
|
||||
def search(search_term, opts) do
|
||||
limit = Keyword.get(opts, :limit, 50)
|
||||
|
||||
from(mi in MediaItem,
|
||||
join: mi_search_index in assoc(mi, :media_items_search_index),
|
||||
where: fragment("media_items_search_index MATCH ?", ^search_term),
|
||||
select_merge: %{
|
||||
matching_search_term:
|
||||
fragment("""
|
||||
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
|
||||
' ' ||
|
||||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
|
||||
""")
|
||||
},
|
||||
order_by: [desc: fragment("rank")],
|
||||
limit: ^limit
|
||||
)
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.matching_search_term(search_term)
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -201,7 +176,7 @@ defmodule Pinchflat.Media do
|
||||
Tasks.delete_tasks_for(media_item)
|
||||
|
||||
if delete_files do
|
||||
{:ok, _} = delete_media_files(media_item)
|
||||
{:ok, _} = do_delete_media_files(media_item)
|
||||
end
|
||||
|
||||
# Should delete these no matter what
|
||||
@@ -209,6 +184,25 @@ defmodule Pinchflat.Media do
|
||||
Repo.delete(media_item)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the tasks and media files associated with a media_item but leaves the
|
||||
media_item in the database. Does not delete anything to do with associated metadata.
|
||||
|
||||
Optionally accepts a second argument `addl_attrs` which will be merged into the
|
||||
media_item before it is updated. Useful for setting things like `prevent_download`
|
||||
and `culled_at`, if wanted
|
||||
|
||||
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def delete_media_files(%MediaItem{} = media_item, addl_attrs \\ %{}) do
|
||||
filepath_attrs = MediaItem.filepath_attribute_defaults()
|
||||
|
||||
Tasks.delete_tasks_for(media_item)
|
||||
{:ok, _} = do_delete_media_files(media_item)
|
||||
|
||||
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
|
||||
"""
|
||||
@@ -216,7 +210,7 @@ defmodule Pinchflat.Media do
|
||||
MediaItem.changeset(media_item, attrs)
|
||||
end
|
||||
|
||||
defp delete_media_files(media_item) do
|
||||
defp do_delete_media_files(media_item) do
|
||||
mapped_struct = Map.from_struct(media_item)
|
||||
|
||||
MediaItem.filepath_attributes()
|
||||
@@ -241,54 +235,12 @@ defmodule Pinchflat.Media do
|
||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||
end
|
||||
|
||||
defp maybe_apply_cutoff_date(source) do
|
||||
if source.download_cutoff_date do
|
||||
dynamic([mi], mi.upload_date >= ^source.download_cutoff_date)
|
||||
else
|
||||
dynamic(true)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_apply_title_regex(source) do
|
||||
if source.title_filter_regex do
|
||||
dynamic([mi], fragment("regexp_like(?, ?)", mi.title, ^source.title_filter_regex))
|
||||
else
|
||||
dynamic(true)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_format_clauses(media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
|
||||
case {attr, media_profile} do
|
||||
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
|
||||
dynamic(
|
||||
[mi],
|
||||
^dynamic and (mi.livestream == true or mi.short_form_content == true)
|
||||
)
|
||||
|
||||
# Technically redundant, but makes the other clauses easier to parse
|
||||
# (redundant because this condition is the same as the condition above, just flipped)
|
||||
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
|
||||
dynamic
|
||||
|
||||
{{:shorts_behaviour, :only}, _} ->
|
||||
dynamic([mi], ^dynamic and mi.short_form_content == true)
|
||||
|
||||
{{:livestream_behaviour, :only}, _} ->
|
||||
dynamic([mi], ^dynamic and mi.livestream == true)
|
||||
|
||||
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
|
||||
dynamic([mi], ^dynamic and mi.short_form_content == false)
|
||||
|
||||
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
|
||||
# return records with livestream: false
|
||||
dynamic([mi], ^dynamic and mi.livestream == false)
|
||||
|
||||
_ ->
|
||||
dynamic
|
||||
end
|
||||
end)
|
||||
defp matching_download_criteria_for(query, source_with_preloads) do
|
||||
query
|
||||
|> MediaQuery.with_no_prevented_download()
|
||||
|> MediaQuery.with_no_media_filepath()
|
||||
|> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
|
||||
|> MediaQuery.with_format_preference(source_with_preloads.media_profile)
|
||||
|> MediaQuery.matching_title_regex(source_with_preloads.title_filter_regex)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,6 +22,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
:source_id,
|
||||
:short_form_content,
|
||||
:upload_date,
|
||||
:duration_seconds,
|
||||
# these fields are captured only on download
|
||||
:media_downloaded_at,
|
||||
:media_filepath,
|
||||
@@ -29,7 +30,11 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
:subtitle_filepaths,
|
||||
:thumbnail_filepath,
|
||||
:metadata_filepath,
|
||||
:nfo_filepath
|
||||
:nfo_filepath,
|
||||
# These are user or system controlled fields
|
||||
:prevent_download,
|
||||
:prevent_culling,
|
||||
:culled_at
|
||||
]
|
||||
# Pretty much all the fields captured at index are required.
|
||||
@required_fields ~w(
|
||||
@@ -41,7 +46,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
source_id
|
||||
upload_date
|
||||
short_form_content
|
||||
)a
|
||||
)a
|
||||
|
||||
schema "media_items" do
|
||||
# This is _not_ used as the primary key or internally in the database
|
||||
@@ -57,6 +62,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
field :short_form_content, :boolean, default: false
|
||||
field :media_downloaded_at, :utc_datetime
|
||||
field :upload_date, :date
|
||||
field :duration_seconds, :integer
|
||||
|
||||
field :media_filepath, :string
|
||||
field :media_size_bytes, :integer
|
||||
@@ -68,6 +74,10 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
# Will very likely revisit because I can't leave well-enough alone.
|
||||
field :subtitle_filepaths, {:array, {:array, :string}}, default: []
|
||||
|
||||
field :prevent_download, :boolean, default: false
|
||||
field :prevent_culling, :boolean, default: false
|
||||
field :culled_at, :utc_datetime
|
||||
|
||||
field :matching_search_term, :string, virtual: true
|
||||
|
||||
belongs_to :source, Source
|
||||
@@ -94,4 +104,14 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
def filepath_attributes do
|
||||
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
|
||||
end
|
||||
|
||||
@doc false
|
||||
def filepath_attribute_defaults do
|
||||
filepath_attributes()
|
||||
|> Enum.map(fn
|
||||
:subtitle_filepaths -> {:subtitle_filepaths, []}
|
||||
field -> {field, nil}
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
defmodule Pinchflat.Media.MediaQuery do
|
||||
@moduledoc """
|
||||
Query helpers for the Media context.
|
||||
|
||||
These methods are made to be one-ish liners used
|
||||
to compose queries for media items. Each method should
|
||||
strive to do _one_ thing. These don't need to be tested
|
||||
as they are just building blocks for other functionality
|
||||
which, itself, will be tested.
|
||||
|
||||
ALSO, this is me trying something new. If I like it,
|
||||
I'll refactor other contexts to use this pattern.
|
||||
"""
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Pinchflat.Media.MediaItem
|
||||
|
||||
# Prefixes:
|
||||
# - for_* - belonging to a certain record
|
||||
# - join_* - for joining on a certain record
|
||||
# - with_* - for filtering based on full, concrete attributes
|
||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
||||
#
|
||||
# Suffixes:
|
||||
# - _for - the arg passed is an association record
|
||||
|
||||
def new do
|
||||
MediaItem
|
||||
end
|
||||
|
||||
def for_source(query, source) do
|
||||
where(query, [mi], mi.source_id == ^source.id)
|
||||
end
|
||||
|
||||
def join_sources(query) do
|
||||
from(mi in query, join: s in assoc(mi, :source), as: :sources)
|
||||
end
|
||||
|
||||
def with_passed_retention_period(query) do
|
||||
where(
|
||||
query,
|
||||
[mi, sources],
|
||||
fragment(
|
||||
"IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
|
||||
sources.retention_period_days,
|
||||
sources.retention_period_days,
|
||||
mi.media_downloaded_at
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def with_no_culling_prevention(query) do
|
||||
where(query, [mi], mi.prevent_culling == false)
|
||||
end
|
||||
|
||||
def with_id(query, id) do
|
||||
where(query, [mi], mi.id == ^id)
|
||||
end
|
||||
|
||||
def with_media_ids(query, media_ids) do
|
||||
where(query, [mi], mi.media_id in ^media_ids)
|
||||
end
|
||||
|
||||
def with_media_filepath(query) do
|
||||
where(query, [mi], not is_nil(mi.media_filepath))
|
||||
end
|
||||
|
||||
def with_no_media_filepath(query) do
|
||||
where(query, [mi], is_nil(mi.media_filepath))
|
||||
end
|
||||
|
||||
def with_upload_date_after(query, nil), do: query
|
||||
|
||||
def with_upload_date_after(query, date) do
|
||||
where(query, [mi], mi.upload_date >= ^date)
|
||||
end
|
||||
|
||||
def with_no_prevented_download(query) do
|
||||
where(query, [mi], mi.prevent_download == false)
|
||||
end
|
||||
|
||||
def matching_title_regex(query, nil), do: query
|
||||
|
||||
def matching_title_regex(query, regex) do
|
||||
where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^regex))
|
||||
end
|
||||
|
||||
def matching_search_term(query, nil), do: query
|
||||
|
||||
def matching_search_term(query, term) do
|
||||
from(mi in query,
|
||||
join: mi_search_index in assoc(mi, :media_items_search_index),
|
||||
where: fragment("media_items_search_index MATCH ?", ^term),
|
||||
select_merge: %{
|
||||
matching_search_term:
|
||||
fragment("""
|
||||
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
|
||||
' ' ||
|
||||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
|
||||
""")
|
||||
},
|
||||
order_by: [desc: fragment("rank")]
|
||||
)
|
||||
end
|
||||
|
||||
# NOTE: this method breaks the contract set by other methods in that it
|
||||
# takes a media_profile struct instead of taking just the attributes it
|
||||
# cares about. Consider refactoring but low priority.
|
||||
def with_format_preference(query, media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
finders =
|
||||
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
|
||||
case {attr, media_profile} do
|
||||
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
|
||||
dynamic(
|
||||
[mi],
|
||||
^dynamic and (mi.livestream == true or mi.short_form_content == true)
|
||||
)
|
||||
|
||||
# Technically redundant, but makes the other clauses easier to parse
|
||||
# (redundant because this condition is the same as the condition above, just flipped)
|
||||
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
|
||||
dynamic
|
||||
|
||||
{{:shorts_behaviour, :only}, _} ->
|
||||
dynamic([mi], ^dynamic and mi.short_form_content == true)
|
||||
|
||||
{{:livestream_behaviour, :only}, _} ->
|
||||
dynamic([mi], ^dynamic and mi.livestream == true)
|
||||
|
||||
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
|
||||
dynamic([mi], ^dynamic and mi.short_form_content == false)
|
||||
|
||||
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
|
||||
# return records with livestream: false
|
||||
dynamic([mi], ^dynamic and mi.livestream == false)
|
||||
|
||||
_ ->
|
||||
dynamic
|
||||
end
|
||||
end)
|
||||
|
||||
where(query, ^finders)
|
||||
end
|
||||
end
|
||||
@@ -30,7 +30,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
||||
original_url: metadata["original_url"],
|
||||
description: metadata["description"],
|
||||
media_filepath: metadata["filepath"],
|
||||
livestream: metadata["was_live"]
|
||||
livestream: metadata["was_live"],
|
||||
duration_seconds: metadata["duration"] && round(metadata["duration"])
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -43,25 +43,25 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>#{safe(source.custom_name)}</title>
|
||||
<link>#{source.original_url}</link>
|
||||
<link>#{safe(source.original_url)}</link>
|
||||
<description>#{safe(source.description)}</description>
|
||||
<category>TV & Film</category>
|
||||
<generator>Generated by Pinchflat</generator>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>#{Calendar.strftime(source.updated_at, @datetime_format)}</lastBuildDate>
|
||||
<pubDate>#{Calendar.strftime(source.inserted_at, @datetime_format)}</pubDate>
|
||||
<atom:link href="#{generate_self_link(url_base, source)}" rel="self" type="application/rss+xml" />
|
||||
<atom:link href="#{safe(generate_self_link(url_base, source))}" rel="self" type="application/rss+xml" />
|
||||
<podcast:locked>yes</podcast:locked>
|
||||
<podcast:guid>#{source.uuid}</podcast:guid>
|
||||
<image>
|
||||
<url>#{feed_image_path}</url>
|
||||
<url>#{safe(feed_image_path)}</url>
|
||||
<title>#{safe(source.custom_name)}</title>
|
||||
<link>#{source.original_url}</link>
|
||||
<link>#{safe(source.original_url)}</link>
|
||||
</image>
|
||||
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
||||
<itunes:subtitle>#{safe(source.custom_name)}</itunes:subtitle>
|
||||
<itunes:block>yes</itunes:block>
|
||||
<itunes:image href="#{feed_image_path}"></itunes:image>
|
||||
<itunes:image href="#{safe(feed_image_path)}"></itunes:image>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
<itunes:category text="TV & Film"></itunes:category>
|
||||
|
||||
@@ -77,9 +77,10 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
<item>
|
||||
<guid isPermaLink="false">#{media_item.uuid}</guid>
|
||||
<title>#{safe(media_item.title)}</title>
|
||||
<link>#{media_item.original_url}</link>
|
||||
<link>#{safe(media_item.original_url)}</link>
|
||||
<description>#{safe(media_item.description)}</description>
|
||||
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
||||
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
||||
<enclosure
|
||||
url="#{media_stream_path(url_base, media_item)}"
|
||||
length="#{media_item.media_size_bytes}"
|
||||
|
||||
@@ -21,6 +21,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
download_metadata
|
||||
embed_metadata
|
||||
download_nfo
|
||||
sponsorblock_behaviour
|
||||
sponsorblock_categories
|
||||
shorts_behaviour
|
||||
livestream_behaviour
|
||||
preferred_resolution
|
||||
@@ -47,6 +49,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
field :embed_metadata, :boolean, default: false
|
||||
|
||||
field :download_nfo, :boolean, default: false
|
||||
field :sponsorblock_behaviour, Ecto.Enum, values: [:disabled, :remove], default: :disabled
|
||||
field :sponsorblock_categories, {:array, :string}, default: []
|
||||
# NOTE: these do NOT speed up indexing - the indexer still has to go
|
||||
# through the entire collection to determine if a media is a short or
|
||||
# a livestream.
|
||||
|
||||
@@ -25,14 +25,15 @@ defmodule Pinchflat.Release do
|
||||
def check_file_permissions do
|
||||
load_app()
|
||||
|
||||
directories = [
|
||||
"/config",
|
||||
"/downloads",
|
||||
Application.get_env(:pinchflat, :media_directory),
|
||||
Application.get_env(:pinchflat, :tmpfile_directory),
|
||||
Application.get_env(:pinchflat, :extras_directory),
|
||||
Application.get_env(:pinchflat, :metadata_directory)
|
||||
]
|
||||
directories =
|
||||
Enum.uniq([
|
||||
"/config",
|
||||
"/downloads",
|
||||
Application.get_env(:pinchflat, :media_directory),
|
||||
Application.get_env(:pinchflat, :tmpfile_directory),
|
||||
Application.get_env(:pinchflat, :extras_directory),
|
||||
Application.get_env(:pinchflat, :metadata_directory)
|
||||
])
|
||||
|
||||
Enum.each(directories, fn dir ->
|
||||
Logger.info("Checking permissions for #{dir}")
|
||||
|
||||
@@ -29,6 +29,7 @@ defmodule Pinchflat.Sources.Source do
|
||||
last_indexed_at
|
||||
original_url
|
||||
download_cutoff_date
|
||||
retention_period_days
|
||||
title_filter_regex
|
||||
media_profile_id
|
||||
)a
|
||||
@@ -72,6 +73,7 @@ defmodule Pinchflat.Sources.Source do
|
||||
field :last_indexed_at, :utc_datetime
|
||||
# Only download media items that were published after this date
|
||||
field :download_cutoff_date, :date
|
||||
field :retention_period_days, :integer
|
||||
field :original_url, :string
|
||||
field :title_filter_regex, :string
|
||||
|
||||
@@ -106,7 +108,9 @@ 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_number(:retention_period_days, greater_than_or_equal_to: 0)
|
||||
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|
||||
|> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule Pinchflat.Sources do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
alias Pinchflat.YtDlp.MediaCollection
|
||||
alias Pinchflat.Metadata.SourceMetadata
|
||||
@@ -112,8 +113,9 @@ defmodule Pinchflat.Sources do
|
||||
delete_files = Keyword.get(opts, :delete_files, false)
|
||||
Tasks.delete_tasks_for(source)
|
||||
|
||||
source
|
||||
|> Media.list_media_items_for()
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> Repo.all()
|
||||
|> Enum.each(fn media_item ->
|
||||
Media.delete_media_item(media_item, delete_files: delete_files)
|
||||
end)
|
||||
|
||||
@@ -8,4 +8,5 @@ defmodule Pinchflat.YtDlp.BackendCommandRunner do
|
||||
|
||||
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
|
||||
@callback run(binary(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
|
||||
@callback version() :: {:ok, binary()} | {:error, binary()}
|
||||
end
|
||||
|
||||
@@ -48,6 +48,19 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
||||
end
|
||||
end
|
||||
|
||||
@impl BackendCommandRunner
|
||||
def version do
|
||||
command = backend_executable()
|
||||
|
||||
case System.cmd(command, ["--version"]) do
|
||||
{output, 0} ->
|
||||
{:ok, String.trim(output)}
|
||||
|
||||
{output, _} ->
|
||||
{:error, output}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_cookie_options do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
cookie_file = Path.join(base_dir, "cookies.txt")
|
||||
|
||||
@@ -10,7 +10,8 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
:original_url,
|
||||
:livestream,
|
||||
:short_form_content,
|
||||
:upload_date
|
||||
:upload_date,
|
||||
:duration_seconds
|
||||
]
|
||||
|
||||
defstruct [
|
||||
@@ -20,7 +21,8 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
:original_url,
|
||||
:livestream,
|
||||
:short_form_content,
|
||||
:upload_date
|
||||
:upload_date,
|
||||
:duration_seconds
|
||||
]
|
||||
|
||||
alias __MODULE__
|
||||
@@ -86,6 +88,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
description: response["description"],
|
||||
original_url: response["webpage_url"],
|
||||
livestream: response["was_live"],
|
||||
duration_seconds: response["duration"] && round(response["duration"]),
|
||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
||||
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file hidden month number password
|
||||
toggle range radio search select tel text textarea time url week)
|
||||
checkbox_group toggle range radio search select tel text textarea time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
@@ -304,6 +304,33 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox_group"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}>
|
||||
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
||||
</.label>
|
||||
<section class="grid grid-cols-1 gap-2 md:grid-cols-2 max-w-prose mb-4 ml-1">
|
||||
<div :for={{option_name, option_value} <- @options} class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={"#{@id}-#{option_value}"}
|
||||
name={"#{@name}[]"}
|
||||
value={option_value}
|
||||
checked={option_value in @value}
|
||||
class={["rounded focus:ring-offset-0 ring-offset-0 focus:ring-0 h-5 w-5 ", @inputclass]}
|
||||
/>
|
||||
<label for={"#{@id}-#{option_value}"} class="ml-2 cursor-pointer select-none">
|
||||
<%= option_name %>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<.help :if={@help}><%= @help %></.help>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "toggle"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
@@ -601,6 +628,10 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
_ ->
|
||||
true
|
||||
end)
|
||||
|> Enum.map(fn
|
||||
{k, v} when is_list(v) -> {k, Enum.join(v, ", ")}
|
||||
rest -> rest
|
||||
end)
|
||||
|
||||
assigns = assign(assigns, iterable_attributes: attrs)
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
||||
def tabbed_layout(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
||||
x-data="{ openTab: getTabIndex(0), activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
||||
@hashchange.window="openTab = getTabIndex(openTab)"
|
||||
class="w-full"
|
||||
>
|
||||
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
||||
@@ -22,7 +23,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
||||
<a
|
||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
||||
href="#"
|
||||
@click.prevent={"openTab = #{idx}"}
|
||||
@click.prevent={"openTab = setTabIndex(#{idx})"}
|
||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
||||
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
|
||||
>
|
||||
|
||||
@@ -58,8 +58,11 @@
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="group relative flex items-center gap-2.5 px-4 py-2 text-sm">
|
||||
v<%= Application.spec(:pinchflat)[:vsn] %>
|
||||
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
||||
Pinchflat v<%= Application.spec(:pinchflat)[:vsn] %>
|
||||
</span>
|
||||
<span class="group relative flex items-center gap-2.5 px-4 text-sm">
|
||||
yt-dlp <%= Settings.get!(:yt_dlp_version) %>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule PinchflatWeb.HealthController do
|
||||
use PinchflatWeb, :controller
|
||||
|
||||
def check(conn, _params) do
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{status: "ok"})
|
||||
end
|
||||
end
|
||||
@@ -16,20 +16,34 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||
render(conn, :show, media_item: media_item)
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id} = params) do
|
||||
delete_files = Map.get(params, "delete_files", false)
|
||||
def edit(conn, %{"id" => id}) do
|
||||
media_item = Media.get_media_item!(id)
|
||||
{:ok, _} = Media.delete_media_item(media_item, delete_files: delete_files)
|
||||
changeset = Media.change_media_item(media_item)
|
||||
|
||||
flash_message =
|
||||
if delete_files do
|
||||
"Record and files deleted successfully."
|
||||
else
|
||||
"Record deleted successfully. Files were not deleted."
|
||||
end
|
||||
render(conn, :edit, media_item: media_item, changeset: changeset)
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id, "media_item" => params}) do
|
||||
media_item = Media.get_media_item!(id)
|
||||
|
||||
case Media.update_media_item(media_item, params) do
|
||||
{:ok, media_item} ->
|
||||
conn
|
||||
|> put_flash(:info, "Media Item updated successfully.")
|
||||
|> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, :edit, media_item: media_item, changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id} = params) do
|
||||
prevent_download = Map.get(params, "prevent_download", false)
|
||||
media_item = Media.get_media_item!(id)
|
||||
{:ok, _} = Media.delete_media_files(media_item, %{prevent_download: prevent_download})
|
||||
|
||||
conn
|
||||
|> put_flash(:info, flash_message)
|
||||
|> put_flash(:info, "Files deleted successfully.")
|
||||
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
||||
end
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
|
||||
|
||||
embed_templates "media_item_html/*"
|
||||
|
||||
@doc """
|
||||
Renders a media item form.
|
||||
"""
|
||||
attr :changeset, Ecto.Changeset, required: true
|
||||
attr :action, :string, required: true
|
||||
|
||||
def media_item_form(assigns)
|
||||
|
||||
def media_file_exists?(media_item) do
|
||||
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="mb-6 flex gap-3 flex-row items-center">
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||
Editing "<%= StringUtils.truncate(@media_item.title, 35) %>"
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.media_item_form changeset={@changeset} action={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<.simple_form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
action={@action}
|
||||
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
|
||||
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
|
||||
>
|
||||
<.error :if={@changeset.action}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
|
||||
<h3 class=" text-2xl text-black dark:text-white">
|
||||
General Options
|
||||
</h3>
|
||||
|
||||
<.input
|
||||
field={f[:prevent_download]}
|
||||
type="toggle"
|
||||
label="Prevent Download"
|
||||
help="Checking excludes this media item from being downloaded"
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={f[:prevent_culling]}
|
||||
type="toggle"
|
||||
label="Prevent Automatic Deletion"
|
||||
help="Checking excludes media from being automatically deleted based on media retention rules"
|
||||
/>
|
||||
|
||||
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media Item</.button>
|
||||
</.simple_form>
|
||||
@@ -4,9 +4,17 @@
|
||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||
</.link>
|
||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||
Media Item #<%= @media_item.id %>
|
||||
<%= StringUtils.truncate(@media_item.title, 35) %>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<.link href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}/edit"}>
|
||||
<.button color="bg-primary" rounding="rounded-lg">
|
||||
<.icon name="hero-pencil-square" class="mr-2" /> Edit
|
||||
</.button>
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
@@ -15,13 +23,22 @@
|
||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
|
||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
|
||||
data-confirm="Are you sure you want to delete all files for this media item? This cannot be undone."
|
||||
>
|
||||
Delete Files
|
||||
</.link>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?prevent_download=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete all files for this media item and prevent it from re-downloading in the future? This cannot be undone."
|
||||
>
|
||||
Delete and Ignore
|
||||
</.link>
|
||||
</:option>
|
||||
</.button_dropdown>
|
||||
</:tab_append>
|
||||
|
||||
@@ -32,6 +49,7 @@
|
||||
<.media_preview media_item={@media_item} />
|
||||
<% end %>
|
||||
|
||||
<h2 class="font-bold text-2xl"><%= @media_item.title %></h2>
|
||||
<h3 class="font-bold text-xl">Attributes</h3>
|
||||
<section>
|
||||
<strong>Source:</strong>
|
||||
|
||||
@@ -32,6 +32,26 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
]
|
||||
end
|
||||
|
||||
def friendly_sponsorblock_options do
|
||||
[
|
||||
{"Disabled (default)", "disabled"},
|
||||
{"Remove Segments", "remove"}
|
||||
]
|
||||
end
|
||||
|
||||
def frieldly_sponsorblock_categories do
|
||||
[
|
||||
{"Sponsor", "sponsor"},
|
||||
{"Intro/Intermission", "intro"},
|
||||
{"Outro/Credits", "outro"},
|
||||
{"Self Promotion", "selfpromo"},
|
||||
{"Preview/Recap", "preview"},
|
||||
{"Filler Tangent", "filler"},
|
||||
{"Interaction Reminder", "interaction"},
|
||||
{"Non-music Section", "music_offtopic"}
|
||||
]
|
||||
end
|
||||
|
||||
def custom_output_template_options do
|
||||
%{
|
||||
upload_day: nil,
|
||||
|
||||
+30
@@ -238,6 +238,36 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<h3 class="mt-10 text-2xl text-black dark:text-white">
|
||||
SponsorBlock Options
|
||||
</h3>
|
||||
|
||||
<section x-data="{ sponsorblockBehaviour: null }">
|
||||
<section x-data="{ presets: { default: 'disabled', media_center: 'disabled', audio: 'disabled', archiving: 'disabled' } }">
|
||||
<.input
|
||||
field={f[:sponsorblock_behaviour]}
|
||||
options={friendly_sponsorblock_options()}
|
||||
type="select"
|
||||
label="SponsorBlock Behaviour"
|
||||
help="Action to take when SponsorBlock segments are found. 'Disabled' won't take any action"
|
||||
x-model="sponsorblockBehaviour"
|
||||
x-init="
|
||||
sponsorblockBehaviour = $el.value
|
||||
$watch('selectedPreset', p => p && ($el.value = presets[p]))
|
||||
"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section x-show="sponsorblockBehaviour !== 'disabled'" x-transition>
|
||||
<.input
|
||||
field={f[:sponsorblock_categories]}
|
||||
options={frieldly_sponsorblock_categories()}
|
||||
type="checkbox_group"
|
||||
label="SponsorBlock Categories"
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
<:tab title="Sources">
|
||||
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
|
||||
<:col :let={source} label="Name">
|
||||
<%= source.custom_name || source.collection_name %>
|
||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||
<%= source.custom_name || source.collection_name %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||
<:col :let={source} label="Should Download?">
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||
<div class="mt-4 flex flex-col items-center justify-center">
|
||||
<a href={~p"/media_profiles"} class="mt-4 flex flex-col items-center justify-center">
|
||||
<span class="text-md font-medium">Media Profile(s)</span>
|
||||
<h4 class="text-title-md font-bold text-white">
|
||||
<%= @media_profile_count %>
|
||||
</h4>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||
<div class="mt-4 flex flex-col items-center justify-center">
|
||||
<a href={~p"/sources"} class="mt-4 flex flex-col items-center justify-center">
|
||||
<span class="text-md font-medium">Source(s)</span>
|
||||
<h4 class="text-title-md font-bold text-white">
|
||||
<%= @source_count %>
|
||||
</h4>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||
<div class="mt-4 flex flex-col items-center justify-center">
|
||||
<a href="#" class="mt-4 flex flex-col items-center justify-center">
|
||||
<span class="text-md font-medium">Media Item(s)</span>
|
||||
<h4 class="text-title-md font-bold text-white">
|
||||
<%= @media_item_count %>
|
||||
</h4>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<span class="text-strokedark">I know this page isn't super useful yet, but give it time :)</span>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Profiles
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
|
||||
def index(conn, _params) do
|
||||
sources = Repo.preload(Sources.list_sources(), :media_profile)
|
||||
@@ -111,11 +111,9 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||
# NOTE: should move this out of the controller
|
||||
# once I finally add some query fragment layer
|
||||
defp total_downloaded_for(source) do
|
||||
from(
|
||||
m in MediaItem,
|
||||
where: m.source_id == ^source.id,
|
||||
where: not is_nil(m.media_filepath)
|
||||
)
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
<:col :let={source} label="Should Download?">
|
||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||
</:col>
|
||||
<:col :let={source} label="Retention">
|
||||
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
||||
<%= source.retention_period_days %> day(s)
|
||||
<% else %>
|
||||
<span class="text-lg">∞</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col :let={source} label="Media Profile">
|
||||
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
||||
<%= source.media_profile.name %>
|
||||
|
||||
@@ -77,10 +77,17 @@
|
||||
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4>
|
||||
<.table rows={@pending_media} table_class="text-black dark:text-white">
|
||||
<:col :let={media_item} label="Title">
|
||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link
|
||||
href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"}
|
||||
icon="hero-pencil-square"
|
||||
class="mx-1"
|
||||
/>
|
||||
</:col>
|
||||
</.table>
|
||||
<% else %>
|
||||
@@ -92,10 +99,17 @@
|
||||
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items (<%= @total_downloaded %> total)</h4>
|
||||
<.table rows={@downloaded_media} table_class="text-black dark:text-white">
|
||||
<:col :let={media_item} label="Title">
|
||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link
|
||||
href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"}
|
||||
icon="hero-pencil-square"
|
||||
class="mx-1"
|
||||
/>
|
||||
</:col>
|
||||
</.table>
|
||||
<% else %>
|
||||
|
||||
@@ -88,6 +88,14 @@
|
||||
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={f[:retention_period_days]}
|
||||
type="number"
|
||||
label="Retention Period (days)"
|
||||
min="0"
|
||||
help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely"
|
||||
/>
|
||||
|
||||
<section x-show="advancedMode">
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Advanced Options
|
||||
|
||||
+18
-14
@@ -1,5 +1,6 @@
|
||||
defmodule PinchflatWeb.Router do
|
||||
use PinchflatWeb, :router
|
||||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
||||
# the extension from the path
|
||||
@@ -11,6 +12,7 @@ defmodule PinchflatWeb.Router do
|
||||
plug :put_root_layout, html: {PinchflatWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :allow_iframe_embed
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
@@ -30,7 +32,7 @@ defmodule PinchflatWeb.Router do
|
||||
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
||||
|
||||
resources "/sources", Sources.SourceController do
|
||||
resources "/media", MediaItems.MediaItemController, only: [:show, :delete]
|
||||
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,21 +47,19 @@ defmodule PinchflatWeb.Router do
|
||||
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
||||
end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
if Application.compile_env(:pinchflat, :dev_routes) do
|
||||
# If you want to use the LiveDashboard in production, you should put
|
||||
# it behind authentication and allow only admins to access it.
|
||||
# If your application does not have an admins-only section yet,
|
||||
# you can use Plug.BasicAuth to set up some basic authentication
|
||||
# as long as you are also using SSL (which you should anyway).
|
||||
import Phoenix.LiveDashboard.Router
|
||||
# No auth or CSRF protection for the health check endpoint
|
||||
scope "/", PinchflatWeb do
|
||||
pipe_through :api
|
||||
|
||||
scope "/dev" do
|
||||
pipe_through :browser
|
||||
get "/healthcheck", HealthController, :check
|
||||
end
|
||||
|
||||
live_dashboard "/dashboard", metrics: PinchflatWeb.Telemetry
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
end
|
||||
scope "/dev" do
|
||||
pipe_through :browser
|
||||
|
||||
live_dashboard "/dashboard",
|
||||
metrics: PinchflatWeb.Telemetry,
|
||||
ecto_repos: [Pinchflat.Repo]
|
||||
end
|
||||
|
||||
defp maybe_basic_auth(conn, opts) do
|
||||
@@ -84,4 +84,8 @@ defmodule PinchflatWeb.Router do
|
||||
defp credential_set?(credential) do
|
||||
credential && credential != ""
|
||||
end
|
||||
|
||||
defp allow_iframe_embed(conn, _opts) do
|
||||
delete_resp_header(conn, "x-frame-options")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :pinchflat,
|
||||
version: "0.1.4",
|
||||
version: "0.1.7",
|
||||
elixir: "~> 1.16",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
@@ -33,7 +33,7 @@ defmodule Pinchflat.MixProject do
|
||||
def application do
|
||||
[
|
||||
mod: {Pinchflat.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools, :inets]
|
||||
extra_applications: [:logger, :runtime_tools, :inets, :os_mon]
|
||||
]
|
||||
end
|
||||
|
||||
@@ -50,6 +50,7 @@ defmodule Pinchflat.MixProject do
|
||||
{:phoenix_ecto, "~> 4.4"},
|
||||
{:ecto_sql, "~> 3.10"},
|
||||
{:ecto_sqlite3, ">= 0.0.0"},
|
||||
{:ecto_sqlite3_extras, "~> 1.2.0"},
|
||||
{:phoenix_html, "~> 3.3"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_view, "~> 0.20.1"},
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
|
||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"},
|
||||
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"},
|
||||
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
|
||||
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
||||
@@ -46,6 +47,7 @@
|
||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
|
||||
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
|
||||
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"},
|
||||
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
|
||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddSponsorblockToMediaProfiles do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_profiles) do
|
||||
add :sponsorblock_behaviour, :string, default: "disabled"
|
||||
add :sponsorblock_categories, {:array, :string}, default: []
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddDurationToMediaItems do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_items) do
|
||||
add :duration_seconds, :integer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
defmodule Pinchflat.Repo.Migrations.ReReAddSourceUniquenessIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute """
|
||||
CREATE UNIQUE INDEX sources_collection_id_media_profile_id_title_filter_regex_index ON sources (
|
||||
collection_id,
|
||||
media_profile_id,
|
||||
IFNULL(title_filter_regex, '')
|
||||
);
|
||||
"""
|
||||
end
|
||||
|
||||
def down do
|
||||
execute """
|
||||
DROP INDEX sources_collection_id_media_profile_id_title_filter_regex_index;
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddPreventDownloadToMediaItems do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_items) do
|
||||
add :prevent_download, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddRetentionPeriodToSources do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:sources) do
|
||||
add :retention_period_days, :integer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddCulledAtToMediaItems do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_items) do
|
||||
add :culled_at, :utc_datetime
|
||||
add :prevent_culling, :boolean, default: false
|
||||
end
|
||||
end
|
||||
end
|
||||
+4
-16
@@ -95,11 +95,9 @@ ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
RUN chown nobody /app
|
||||
|
||||
# Set up data volumes
|
||||
RUN mkdir /config /downloads
|
||||
RUN chown nobody /config /downloads
|
||||
|
||||
# set runner ENV
|
||||
ENV MIX_ENV="prod"
|
||||
@@ -108,25 +106,15 @@ ENV RUN_CONTEXT="selfhosted"
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/pinchflat ./
|
||||
|
||||
# NEVER do this if you're running in an environment where you don't trust the user
|
||||
# (ie: most environments). This is only acceptable in a self-hosted environment.
|
||||
# The user could just run the whole container as root and bypass this anyway so
|
||||
# it's not a huge deal.
|
||||
# This removes the root password to allow users to assume root if needed. This is
|
||||
# preferrable to running the whole container as root so that the files/directories
|
||||
# created by the app aren't owned by root and are therefore easier for other users
|
||||
# and processes to interact with. If you want to just run the whole container as
|
||||
# root, use --user 0:0 or something.
|
||||
RUN passwd -d root
|
||||
|
||||
USER nobody
|
||||
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
# advised to add an init process such as tini via `apt-get install`
|
||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
HEALTHCHECK --interval=120s --start-period=10s \
|
||||
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
|
||||
|
||||
# Start the app
|
||||
CMD ["/app/bin/docker_start"]
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
defmodule Pinchflat.Boot.DataBackfillWorkerTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
alias Pinchflat.Boot.DataBackfillWorker
|
||||
alias Pinchflat.JobFixtures.TestJobWorker
|
||||
|
||||
describe "cancel_pending_backfill_jobs/0" do
|
||||
test "cancels all pending backfill jobs" do
|
||||
%{}
|
||||
|> DataBackfillWorker.new()
|
||||
|> Repo.insert_unique_job()
|
||||
|
||||
assert_enqueued(worker: DataBackfillWorker)
|
||||
|
||||
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||
|
||||
refute_enqueued(worker: DataBackfillWorker)
|
||||
end
|
||||
|
||||
test "does not cancel jobs for other workers" do
|
||||
%{id: 0}
|
||||
|> TestJobWorker.new()
|
||||
|> Repo.insert_unique_job()
|
||||
|
||||
assert_enqueued(worker: TestJobWorker)
|
||||
|
||||
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||
|
||||
assert_enqueued(worker: TestJobWorker)
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
setup do
|
||||
DataBackfillWorker.cancel_pending_backfill_jobs()
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "reschedules itself once complete" do
|
||||
perform_job(DataBackfillWorker, %{})
|
||||
|
||||
assert_enqueued(worker: DataBackfillWorker, scheduled_at: now_plus(60, :minutes))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -222,6 +222,45 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "build/1 when testing sponsorblock options" do
|
||||
test "includes :sponsorblock_remove option when specified", %{media_item: media_item} do
|
||||
media_item =
|
||||
update_media_profile_attribute(media_item, %{
|
||||
sponsorblock_behaviour: :remove,
|
||||
sponsorblock_categories: ["sponsor", "intro"]
|
||||
})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert {:sponsorblock_remove, "sponsor,intro"} in res
|
||||
end
|
||||
|
||||
test "does not include :sponsorblock_remove option without categories", %{media_item: media_item} do
|
||||
media_item =
|
||||
update_media_profile_attribute(media_item, %{
|
||||
sponsorblock_behaviour: :remove,
|
||||
sponsorblock_categories: []
|
||||
})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
refute {:sponsorblock_remove, ""} in res
|
||||
refute {:sponsorblock_remove, []} in res
|
||||
refute :sponsorblock_remove in res
|
||||
end
|
||||
|
||||
test "does not include any sponsorblock options when disabled", %{media_item: media_item} do
|
||||
media_item =
|
||||
update_media_profile_attribute(media_item, %{sponsorblock_behaviour: :disabled})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
refute {:sponsorblock_remove, ""} in res
|
||||
refute {:sponsorblock_remove, []} in res
|
||||
refute :sponsorblock_remove in res
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_output_path_for/1" do
|
||||
test "builds an output path for a source", %{media_item: media_item} do
|
||||
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
|
||||
|
||||
@@ -74,6 +74,12 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||
end)
|
||||
end
|
||||
|
||||
test "it ensures error are returned in a 2-item tuple", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error", 1} end)
|
||||
|
||||
assert {:error, :download_failed} = perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||
end
|
||||
|
||||
test "it does not download if the source is set to not download", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> :ok end)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
|
||||
end
|
||||
|
||||
test "it saves the metadata filepatha to the database", %{media_item: media_item} do
|
||||
test "it saves the metadata filepath to the database", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
{:ok, render_metadata(:media_metadata)}
|
||||
end)
|
||||
@@ -93,6 +93,12 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths
|
||||
end
|
||||
|
||||
test "it extracts the duration_seconds", %{media_item: media_item} do
|
||||
assert media_item.duration_seconds == nil
|
||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||
assert is_integer(updated_media_item.duration_seconds)
|
||||
end
|
||||
|
||||
test "it extracts the thumbnail_filepath", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
metadata = render_parsed_metadata(:media_metadata)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Downloading.MediaRetentionWorker
|
||||
|
||||
describe "perform/1" do
|
||||
test "deletes media files that are past their retention date" do
|
||||
{_source, old_media_item, new_media_item} = prepare_records()
|
||||
|
||||
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 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()
|
||||
|
||||
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
|
||||
|
||||
test "doesn't cull media items that have prevent_culling set" do
|
||||
{_source, old_media_item, _new_media_item} = prepare_records()
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_records do
|
||||
source = source_fixture(%{retention_period_days: 2})
|
||||
|
||||
old_media_item =
|
||||
media_item_with_attachments(%{
|
||||
source_id: source.id,
|
||||
media_downloaded_at: now_minus(3, :days)
|
||||
})
|
||||
|
||||
new_media_item =
|
||||
media_item_with_attachments(%{
|
||||
source_id: source.id,
|
||||
media_downloaded_at: now_minus(1, :day)
|
||||
})
|
||||
|
||||
{source, old_media_item, new_media_item}
|
||||
end
|
||||
end
|
||||
+162
-17
@@ -38,29 +38,95 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_media_items_for/1" do
|
||||
test "it returns media_items for a given source" do
|
||||
source = source_fixture()
|
||||
media_item = media_item_fixture(%{source_id: source.id})
|
||||
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})
|
||||
|
||||
assert Media.list_media_items_for(source) == [media_item]
|
||||
end
|
||||
end
|
||||
_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)
|
||||
})
|
||||
|
||||
describe "list_media_items_by_media_id_for/2" do
|
||||
test "returns media_items for a given source and media_ids" do
|
||||
source = source_fixture()
|
||||
media_item = media_item_fixture(%{source_id: source.id, media_id: "123"})
|
||||
_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)
|
||||
})
|
||||
|
||||
assert Media.list_media_items_by_media_id_for(source, ["123"]) == [media_item]
|
||||
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 "does not return matching media_ids for a different source" do
|
||||
source = source_fixture()
|
||||
other_source = source_fixture()
|
||||
_media_item = media_item_fixture(%{source_id: other_source.id, media_id: "123"})
|
||||
test "returns media_items with a media_filepath" do
|
||||
source = source_fixture(%{retention_period_days: 2})
|
||||
|
||||
assert Media.list_media_items_by_media_id_for(source, ["123"]) == []
|
||||
_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
|
||||
|
||||
@@ -259,6 +325,16 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_pending_media_items_for/1 when testing download prevention" do
|
||||
test "returns only media items that are not prevented from downloading" do
|
||||
source = source_fixture()
|
||||
_prevented_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, prevent_download: true})
|
||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, prevent_download: false})
|
||||
|
||||
assert Media.list_pending_media_items_for(source) == [media_item]
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_downloaded_media_items_for/1" do
|
||||
test "returns only media items with a media_filepath" do
|
||||
source = source_fixture()
|
||||
@@ -346,6 +422,18 @@ defmodule Pinchflat.MediaTest do
|
||||
|
||||
assert Media.pending_download?(media_item)
|
||||
end
|
||||
|
||||
test "returns true if the media item is not prevented from downloading" do
|
||||
media_item = media_item_fixture(%{media_filepath: nil, prevent_download: false})
|
||||
|
||||
assert Media.pending_download?(media_item)
|
||||
end
|
||||
|
||||
test "returns false if the media item is prevented from downloading" do
|
||||
media_item = media_item_fixture(%{media_filepath: nil, prevent_download: true})
|
||||
|
||||
refute Media.pending_download?(media_item)
|
||||
end
|
||||
end
|
||||
|
||||
describe "search/1" do
|
||||
@@ -613,6 +701,63 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_media_files/2" do
|
||||
test "does not delete the media_item" do
|
||||
media_item = media_item_fixture()
|
||||
|
||||
assert {:ok, %MediaItem{}} = Media.delete_media_files(media_item)
|
||||
assert Repo.reload!(media_item)
|
||||
end
|
||||
|
||||
test "deletes attached tasks" do
|
||||
media_item = media_item_fixture()
|
||||
task = task_fixture(%{media_item_id: media_item.id})
|
||||
|
||||
assert {:ok, %MediaItem{}} = Media.delete_media_files(media_item)
|
||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
||||
end
|
||||
|
||||
test "deletes the media_item's files" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
assert File.exists?(media_item.media_filepath)
|
||||
assert {:ok, _} = Media.delete_media_files(media_item)
|
||||
refute File.exists?(media_item.media_filepath)
|
||||
end
|
||||
|
||||
test "does not delete the media item's metadata files" do
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
||||
|
||||
update_attrs = %{
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||
thumbnail_filepath:
|
||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, %{
|
||||
"thumbnail" => "https://example.com/thumbnail.jpg"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
{:ok, updated_media_item} = Media.update_media_item(media_item, update_attrs)
|
||||
metadata = Repo.preload(updated_media_item, :metadata).metadata
|
||||
|
||||
assert {:ok, _} = Media.delete_media_files(updated_media_item)
|
||||
assert Repo.reload(metadata)
|
||||
assert File.exists?(updated_media_item.metadata.metadata_filepath)
|
||||
|
||||
# cleanup
|
||||
Media.delete_media_item(updated_media_item, delete_files: true)
|
||||
end
|
||||
|
||||
test "can take additional attributes update media item" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
assert {:ok, updated_media_item} = Media.delete_media_files(media_item, %{prevent_download: true})
|
||||
assert updated_media_item.prevent_download
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_media_item/1" do
|
||||
test "change_media_item/1 returns a media_item changeset" do
|
||||
media_item = media_item_fixture()
|
||||
|
||||
@@ -45,6 +45,12 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
||||
|
||||
assert result.livestream == metadata["was_live"]
|
||||
end
|
||||
|
||||
test "it extracts the duration in seconds", %{metadata: metadata} do
|
||||
result = Parser.parse_for_media_item(metadata)
|
||||
|
||||
assert result.duration_seconds == round(metadata["duration"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_for_media_item/1 when testing subtitle metadata" do
|
||||
|
||||
@@ -137,6 +137,75 @@ defmodule Pinchflat.SourcesTest do
|
||||
assert source.custom_name == "some channel name"
|
||||
end
|
||||
|
||||
test "creation enforces uniqueness of collection_id scoped to the media_profile and title regex" do
|
||||
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
|
||||
{:ok,
|
||||
Phoenix.json_library().encode!(%{
|
||||
channel: "some channel name",
|
||||
channel_id: "some_channel_id_12345678",
|
||||
playlist_id: "some_channel_id_12345678",
|
||||
playlist_title: "some channel name - videos"
|
||||
})}
|
||||
end)
|
||||
|
||||
valid_once_attrs = %{
|
||||
media_profile_id: media_profile_fixture().id,
|
||||
original_url: "https://www.youtube.com/channel/abc123",
|
||||
title_filter_regex: nil
|
||||
}
|
||||
|
||||
assert {:ok, %Source{}} = Sources.create_source(valid_once_attrs)
|
||||
assert {:error, %Ecto.Changeset{}} = Sources.create_source(valid_once_attrs)
|
||||
end
|
||||
|
||||
test "creation lets you duplicate collection_ids and profiles as long as the regex is different" do
|
||||
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
|
||||
{:ok,
|
||||
Phoenix.json_library().encode!(%{
|
||||
channel: "some channel name",
|
||||
channel_id: "some_channel_id_12345678",
|
||||
playlist_id: "some_channel_id_12345678",
|
||||
playlist_title: "some channel name - videos"
|
||||
})}
|
||||
end)
|
||||
|
||||
valid_attrs = %{
|
||||
media_profile_id: media_profile_fixture().id,
|
||||
name: "some name",
|
||||
original_url: "https://www.youtube.com/channel/abc123"
|
||||
}
|
||||
|
||||
source_1_attrs = Map.merge(valid_attrs, %{title_filter_regex: "foo"})
|
||||
source_2_attrs = Map.merge(valid_attrs, %{title_filter_regex: "bar"})
|
||||
|
||||
assert {:ok, %Source{}} = Sources.create_source(source_1_attrs)
|
||||
assert {:ok, %Source{}} = Sources.create_source(source_2_attrs)
|
||||
end
|
||||
|
||||
test "creation lets you duplicate collection_ids as long as the media profile is different" do
|
||||
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
|
||||
{:ok,
|
||||
Phoenix.json_library().encode!(%{
|
||||
channel: "some channel name",
|
||||
channel_id: "some_channel_id_12345678",
|
||||
playlist_id: "some_channel_id_12345678",
|
||||
playlist_title: "some channel name - videos"
|
||||
})}
|
||||
end)
|
||||
|
||||
valid_attrs = %{
|
||||
name: "some name",
|
||||
original_url: "https://www.youtube.com/channel/abc123",
|
||||
title_filter_regex: "TEST"
|
||||
}
|
||||
|
||||
source_1_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
|
||||
source_2_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
|
||||
|
||||
assert {:ok, %Source{}} = Sources.create_source(source_1_attrs)
|
||||
assert {:ok, %Source{}} = Sources.create_source(source_2_attrs)
|
||||
end
|
||||
|
||||
test "collection_type is inferred from source details" do
|
||||
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||
expect(YtDlpRunnerMock, :run, &playlist_mock/3)
|
||||
|
||||
@@ -102,6 +102,14 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "version/0" do
|
||||
test "adds the version arg" do
|
||||
assert {:ok, output} = Runner.version()
|
||||
|
||||
assert String.contains?(output, "--version")
|
||||
end
|
||||
end
|
||||
|
||||
defp wrap_executable(new_executable, fun) do
|
||||
Application.put_env(:pinchflat, :yt_dlp_executable, new_executable)
|
||||
fun.()
|
||||
|
||||
@@ -104,7 +104,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||
livestream: false,
|
||||
short_form_content: false,
|
||||
upload_date: Date.from_iso8601!("2021-01-01")
|
||||
upload_date: Date.from_iso8601!("2021-01-01"),
|
||||
duration_seconds: 60
|
||||
} == Media.response_to_struct(response)
|
||||
end
|
||||
|
||||
@@ -174,5 +175,27 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
|
||||
assert %Media{upload_date: nil} = Media.response_to_struct(response)
|
||||
end
|
||||
|
||||
test "parses the duration" do
|
||||
response = %{
|
||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||
"aspect_ratio" => 1.0,
|
||||
"duration" => 60.4,
|
||||
"upload_date" => "20210101"
|
||||
}
|
||||
|
||||
assert %Media{duration_seconds: 60} = Media.response_to_struct(response)
|
||||
end
|
||||
|
||||
test "doesn't blow up if duration is missing" do
|
||||
response = %{
|
||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||
"aspect_ratio" => 1.0,
|
||||
"duration" => nil,
|
||||
"upload_date" => "20210101"
|
||||
}
|
||||
|
||||
assert %Media{duration_seconds: nil} = Media.response_to_struct(response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
defmodule PinchflatWeb.HealthControllerTest do
|
||||
use PinchflatWeb.ConnCase
|
||||
|
||||
describe "GET /healthcheck" do
|
||||
test "returns ok", %{conn: conn} do
|
||||
conn = get(conn, "/healthcheck")
|
||||
assert json_response(conn, 200) == %{"status" => "ok"}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,27 +10,58 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
||||
|
||||
test "renders the page", %{conn: conn, media_item: media_item} do
|
||||
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
||||
assert html_response(conn, 200) =~ "Media Item ##{media_item.id}"
|
||||
|
||||
assert html_response(conn, 200) =~ "#{media_item.title}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete media when just deleting the records" do
|
||||
describe "edit media" do
|
||||
setup [:create_media_item]
|
||||
|
||||
test "renders form for editing chosen media_item", %{conn: conn, media_item: media_item} do
|
||||
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}/edit")
|
||||
|
||||
assert html_response(conn, 200) =~ "Editing"
|
||||
end
|
||||
end
|
||||
|
||||
describe "update media" do
|
||||
setup [:create_media_item]
|
||||
|
||||
test "redirects when data is valid", %{conn: conn, media_item: media_item} do
|
||||
update_attrs = %{title: "New Title"}
|
||||
|
||||
conn = put(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}", media_item: update_attrs)
|
||||
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}/media/#{media_item}"
|
||||
|
||||
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
||||
assert html_response(conn, 200) =~ update_attrs[:title]
|
||||
end
|
||||
|
||||
test "renders errors when data is invalid", %{conn: conn, media_item: media_item} do
|
||||
conn = put(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}", media_item: %{title: nil})
|
||||
|
||||
assert html_response(conn, 200) =~ "Editing"
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete media" do
|
||||
setup do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
%{media_item: media_item}
|
||||
end
|
||||
|
||||
test "the media item is deleted", %{conn: conn, media_item: media_item} do
|
||||
test "the media item not is deleted", %{conn: conn, media_item: media_item} do
|
||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
|
||||
|
||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
|
||||
assert Repo.reload!(media_item)
|
||||
end
|
||||
|
||||
test "the files are not deleted", %{conn: conn, media_item: media_item} do
|
||||
test "the files are deleted", %{conn: conn, media_item: media_item} do
|
||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
|
||||
|
||||
assert File.exists?(media_item.media_filepath)
|
||||
refute File.exists?(media_item.media_filepath)
|
||||
end
|
||||
|
||||
test "redirects to the source page", %{conn: conn, media_item: media_item} do
|
||||
@@ -38,31 +69,21 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
||||
|
||||
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete media when deleting the records and files" do
|
||||
setup do
|
||||
media_item = media_item_with_attachments()
|
||||
test "doesn't prevent re-download by default", %{conn: conn, media_item: media_item} do
|
||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
|
||||
|
||||
%{media_item: media_item}
|
||||
media_item = Repo.reload(media_item)
|
||||
|
||||
refute media_item.prevent_download
|
||||
end
|
||||
|
||||
test "the media item is deleted", %{conn: conn, media_item: media_item} do
|
||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
|
||||
test "can optionally prevent re-download", %{conn: conn, media_item: media_item} do
|
||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?prevent_download=true")
|
||||
|
||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
|
||||
end
|
||||
media_item = Repo.reload(media_item)
|
||||
|
||||
test "the files are deleted", %{conn: conn, media_item: media_item} do
|
||||
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
|
||||
|
||||
refute File.exists?(media_item.media_filepath)
|
||||
end
|
||||
|
||||
test "redirects to the source page", %{conn: conn, media_item: media_item} do
|
||||
conn = delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
|
||||
|
||||
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}"
|
||||
assert media_item.prevent_download
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -12,5 +12,9 @@ for ((i = 1; i <= $#; i++)); do
|
||||
fi
|
||||
done
|
||||
|
||||
# Write all args to the file
|
||||
echo "$@" >"$file_location"
|
||||
if [ "${!i}" == "--print-to-file" ]; then
|
||||
# Write all args to the file
|
||||
echo "$@" >"$file_location"
|
||||
else
|
||||
echo "$@"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user