Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1994ea5b08 | |||
| f9e4e44b0c | |||
| f2ee3d77a2 | |||
| 7fc70da14a | |||
| 8a0ae89bc0 | |||
| a9f40ed843 | |||
| f27323ffa3 | |||
| 81b49f55bf | |||
| 24875eaeac | |||
| d9053fff0c | |||
| 70c1f97625 | |||
| 332485cdaf | |||
| e55bcaddd0 | |||
| 4b12764f45 | |||
| b872c5c20b | |||
| 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,12 @@ 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
|
- Uses a novel approach to download new content more quickly than other apps
|
||||||
- Supports downloading audio content
|
- Supports downloading audio content
|
||||||
- Custom rules for handling YouTube Shorts and livestreams
|
- Custom rules for handling YouTube Shorts and livestreams
|
||||||
|
- Apprise support for notifications
|
||||||
|
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
|
||||||
- Advanced options like setting cutoff dates and filtering by title
|
- Advanced options like setting cutoff dates and filtering by title
|
||||||
- Reliable hands-off operation
|
- Reliable hands-off operation
|
||||||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||||
|
- Sponsorblock integration
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -65,6 +68,8 @@ Simply search for Pinchflat in the Community Apps store!
|
|||||||
|
|
||||||
### Portainer
|
### 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:
|
Docker Compose file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -104,6 +109,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.
|
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
|
## 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.
|
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 { LiveSocket } from 'phoenix_live_view'
|
||||||
import topbar from '../vendor/topbar'
|
import topbar from '../vendor/topbar'
|
||||||
import Alpine from 'alpinejs'
|
import Alpine from 'alpinejs'
|
||||||
|
import './tabs'
|
||||||
|
|
||||||
window.Alpine = Alpine
|
window.Alpine = Alpine
|
||||||
Alpine.start()
|
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
|
||||||
|
}
|
||||||
+14
-2
@@ -12,7 +12,9 @@ config :pinchflat,
|
|||||||
generators: [timestamp_type: :utc_datetime],
|
generators: [timestamp_type: :utc_datetime],
|
||||||
# Specifying backend data here makes mocking and local testing SUPER easy
|
# Specifying backend data here makes mocking and local testing SUPER easy
|
||||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||||
|
apprise_executable: System.find_executable("apprise"),
|
||||||
yt_dlp_runner: Pinchflat.YtDlp.CommandRunner,
|
yt_dlp_runner: Pinchflat.YtDlp.CommandRunner,
|
||||||
|
apprise_runner: Pinchflat.Notifications.CommandRunner,
|
||||||
media_directory: "/downloads",
|
media_directory: "/downloads",
|
||||||
# The user may or may not store metadata for their needs, but the app will always store its copy
|
# The user may or may not store metadata for their needs, but the app will always store its copy
|
||||||
metadata_directory: "/config/metadata",
|
metadata_directory: "/config/metadata",
|
||||||
@@ -25,6 +27,10 @@ config :pinchflat,
|
|||||||
expose_feed_endpoints: false,
|
expose_feed_endpoints: false,
|
||||||
file_watcher_poll_interval: 1000
|
file_watcher_poll_interval: 1000
|
||||||
|
|
||||||
|
config :pinchflat, Pinchflat.Repo,
|
||||||
|
journal_mode: :wal,
|
||||||
|
pool_size: 5
|
||||||
|
|
||||||
# Configures the endpoint
|
# Configures the endpoint
|
||||||
config :pinchflat, PinchflatWeb.Endpoint,
|
config :pinchflat, PinchflatWeb.Endpoint,
|
||||||
url: [host: "localhost", port: 8945],
|
url: [host: "localhost", port: 8945],
|
||||||
@@ -42,7 +48,13 @@ config :pinchflat, Oban,
|
|||||||
engine: Oban.Engines.Lite,
|
engine: Oban.Engines.Lite,
|
||||||
repo: Pinchflat.Repo,
|
repo: Pinchflat.Repo,
|
||||||
# Keep old jobs for 30 days for display in the UI
|
# 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?
|
# TODO: consider making this an env var or something?
|
||||||
queues: [
|
queues: [
|
||||||
default: 10,
|
default: 10,
|
||||||
@@ -75,7 +87,7 @@ config :esbuild,
|
|||||||
|
|
||||||
# Configure tailwind (the version is required)
|
# Configure tailwind (the version is required)
|
||||||
config :tailwind,
|
config :tailwind,
|
||||||
version: "3.3.2",
|
version: "3.4.3",
|
||||||
default: [
|
default: [
|
||||||
args: ~w(
|
args: ~w(
|
||||||
--config=tailwind.config.js
|
--config=tailwind.config.js
|
||||||
|
|||||||
+5
-10
@@ -50,19 +50,14 @@ if config_env() == :prod do
|
|||||||
# For running PF as a podcast host on self-hosted environments
|
# For running PF as a podcast host on self-hosted environments
|
||||||
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
||||||
|
|
||||||
# We want to force _some_ level of useful logging in production
|
# For testing alternate journal modes (see issue #137)
|
||||||
acceptable_log_levels = ~w(debug info)a
|
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
|
||||||
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
|
||||||
|
|
||||||
if log_level in acceptable_log_levels do
|
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
||||||
config :logger, level: log_level
|
|
||||||
else
|
|
||||||
Logger.error("Invalid log level: #{log_level}. Defaulting to info.")
|
|
||||||
config :logger, level: :info
|
|
||||||
end
|
|
||||||
|
|
||||||
config :pinchflat,
|
config :pinchflat,
|
||||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||||
|
apprise_executable: System.find_executable("apprise"),
|
||||||
media_directory: "/downloads",
|
media_directory: "/downloads",
|
||||||
metadata_directory: metadata_path,
|
metadata_directory: metadata_path,
|
||||||
extras_directory: extras_path,
|
extras_directory: extras_path,
|
||||||
@@ -72,7 +67,7 @@ if config_env() == :prod do
|
|||||||
|
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
database: db_path,
|
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.
|
# 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
|
# A default value is used in config/dev.exs and config/test.exs but you
|
||||||
|
|||||||
+2
-3
@@ -3,6 +3,7 @@ import Config
|
|||||||
config :pinchflat,
|
config :pinchflat,
|
||||||
# Specifying backend data here makes mocking and local testing SUPER easy
|
# Specifying backend data here makes mocking and local testing SUPER easy
|
||||||
yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
|
yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
|
||||||
|
apprise_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
|
||||||
media_directory: Path.join([System.tmp_dir!(), "test", "media"]),
|
media_directory: Path.join([System.tmp_dir!(), "test", "media"]),
|
||||||
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]),
|
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]),
|
||||||
tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]),
|
tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]),
|
||||||
@@ -17,7 +18,6 @@ config :pinchflat, Oban, testing: :manual
|
|||||||
# Run `mix help test` for more information.
|
# Run `mix help test` for more information.
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)),
|
database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)),
|
||||||
pool_size: 5,
|
|
||||||
pool: Ecto.Adapters.SQL.Sandbox
|
pool: Ecto.Adapters.SQL.Sandbox
|
||||||
|
|
||||||
# We don't run a server during test. If one is required,
|
# We don't run a server during test. If one is required,
|
||||||
@@ -33,8 +33,7 @@ config :pinchflat, Pinchflat.Mailer, adapter: Swoosh.Adapters.Test
|
|||||||
# Disable swoosh api client as it is only required for production adapters.
|
# Disable swoosh api client as it is only required for production adapters.
|
||||||
config :swoosh, :api_client, false
|
config :swoosh, :api_client, false
|
||||||
|
|
||||||
# Print only warnings and errors during test
|
config :logger, level: :critical
|
||||||
config :logger, level: :warning
|
|
||||||
|
|
||||||
# Initialize plugs at runtime for faster test compilation
|
# Initialize plugs at runtime for faster test compilation
|
||||||
config :phoenix, :plug_init_mode, :runtime
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|||||||
+14
-8
@@ -5,15 +5,10 @@ ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEB
|
|||||||
|
|
||||||
FROM ${DEV_IMAGE}
|
FROM ${DEV_IMAGE}
|
||||||
|
|
||||||
# Set the locale deets
|
|
||||||
ENV LANG en_US.UTF-8
|
|
||||||
ENV LANGUAGE en_US:en
|
|
||||||
ENV LC_ALL en_US.UTF-8
|
|
||||||
|
|
||||||
# Install debian packages
|
# Install debian packages
|
||||||
RUN apt-get update -qq
|
RUN apt-get update -qq
|
||||||
RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client \
|
RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client \
|
||||||
python3 python3-pip python3-setuptools python3-wheel python3-dev
|
python3 python3-pip python3-setuptools python3-wheel python3-dev locales
|
||||||
|
|
||||||
# Install nodejs
|
# Install nodejs
|
||||||
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
|
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
|
||||||
@@ -25,9 +20,20 @@ RUN npm install -g yarn
|
|||||||
RUN mix local.hex --force
|
RUN mix local.hex --force
|
||||||
RUN mix local.rebar --force
|
RUN mix local.rebar --force
|
||||||
|
|
||||||
# Download YT-DLP
|
# Download and update YT-DLP
|
||||||
# NOTE: If you're seeing weird issues, consider using the FFMPEG released by yt-dlp
|
# NOTE: If you're seeing weird issues, consider using the FFMPEG released by yt-dlp
|
||||||
RUN python3 -m pip install -U --pre yt-dlp --break-system-packages
|
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
|
||||||
|
RUN chmod a+rx /usr/local/bin/yt-dlp
|
||||||
|
RUN yt-dlp -U
|
||||||
|
|
||||||
|
# Download Apprise
|
||||||
|
RUN python3 -m pip install -U apprise --break-system-packages
|
||||||
|
|
||||||
|
# Set the locale
|
||||||
|
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||||
|
ENV LANG en_US.UTF-8
|
||||||
|
ENV LANGUAGE en_US:en
|
||||||
|
ENV LC_ALL en_US.UTF-8
|
||||||
|
|
||||||
# Create app directory and copy the Elixir projects into it.
|
# Create app directory and copy the Elixir projects into it.
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
defmodule Pinchflat.Boot.NfoBackfillWorker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :local_metadata,
|
||||||
|
# This should have it running once _ever_ (until the job is pruned, anyway)
|
||||||
|
# NOTE: remove within the next month
|
||||||
|
unique: [period: :infinity, states: Oban.Job.states()],
|
||||||
|
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Media
|
||||||
|
alias Pinchflat.Media.MediaItem
|
||||||
|
alias Pinchflat.Metadata.NfoBuilder
|
||||||
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Runs a one-off backfill job to regenerate NFO files for media items that have
|
||||||
|
both an NFO file and a metadata file. This is needed because NFO files weren't
|
||||||
|
escaping characters properly so we need to regenerate them.
|
||||||
|
|
||||||
|
This job will only run once as long as I remove it before the jobs are pruned in a month.
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{}) do
|
||||||
|
Logger.info("Running NFO backfill worker")
|
||||||
|
|
||||||
|
media_items = get_media_items_to_backfill()
|
||||||
|
|
||||||
|
Enum.each(media_items, fn media_item ->
|
||||||
|
nfo_exists = File.exists?(media_item.nfo_filepath)
|
||||||
|
metadata_exists = File.exists?(media_item.metadata.metadata_filepath)
|
||||||
|
|
||||||
|
if nfo_exists && metadata_exists do
|
||||||
|
Logger.info("NFO and metadata exist for media item #{media_item.id} - proceeding")
|
||||||
|
|
||||||
|
regenerate_nfo_for_media_item(media_item)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_media_items_to_backfill do
|
||||||
|
from(m in MediaItem, where: not is_nil(m.nfo_filepath))
|
||||||
|
|> Repo.all()
|
||||||
|
|> Repo.preload([:metadata, source: :media_profile])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp regenerate_nfo_for_media_item(media_item) do
|
||||||
|
try do
|
||||||
|
case MetadataFileHelpers.read_compressed_metadata(media_item.metadata.metadata_filepath) do
|
||||||
|
{:ok, metadata} ->
|
||||||
|
Media.update_media_item(media_item, %{
|
||||||
|
nfo_filepath: NfoBuilder.build_and_store_for_media_item(media_item.nfo_filepath, metadata)
|
||||||
|
})
|
||||||
|
|
||||||
|
_err ->
|
||||||
|
Logger.error("Failed to read metadata for media item #{media_item.id}")
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
e -> Logger.error("Unknown error regenerating NFO file for MI ##{media_item.id}: #{inspect(e)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,13 +7,13 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
|
|||||||
Phoenix supervision tree.
|
Phoenix supervision tree.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Boot.NfoBackfillWorker
|
||||||
|
|
||||||
# restart: :temporary means that this process will never be restarted (ie: will run once and then die)
|
# restart: :temporary means that this process will never be restarted (ie: will run once and then die)
|
||||||
use GenServer, restart: :temporary
|
use GenServer, restart: :temporary
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
|
||||||
alias Pinchflat.Boot.DataBackfillWorker
|
|
||||||
|
|
||||||
def start_link(opts \\ []) do
|
def start_link(opts \\ []) do
|
||||||
GenServer.start_link(__MODULE__, %{}, opts)
|
GenServer.start_link(__MODULE__, %{}, opts)
|
||||||
end
|
end
|
||||||
@@ -29,16 +29,8 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
|
|||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def init(state) do
|
def init(state) do
|
||||||
enqueue_backfill_worker()
|
Repo.insert_unique_job(NfoBackfillWorker.new(%{}))
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp enqueue_backfill_worker do
|
|
||||||
DataBackfillWorker.cancel_pending_backfill_jobs()
|
|
||||||
|
|
||||||
%{}
|
|
||||||
|> DataBackfillWorker.new()
|
|
||||||
|> Repo.insert_unique_job()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -55,14 +55,25 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||||||
filepath = Path.join(base_dir, "cookies.txt")
|
filepath = Path.join(base_dir, "cookies.txt")
|
||||||
|
|
||||||
if !File.exists?(filepath) do
|
if !File.exists?(filepath) do
|
||||||
Logger.info("Cookies does not exist - creating it")
|
Logger.info("yt-dlp cookie file does not exist - creating it")
|
||||||
|
|
||||||
FilesystemHelpers.write_p!(filepath, "")
|
FilesystemHelpers.write_p!(filepath, "")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_default_settings do
|
defp apply_default_settings do
|
||||||
Settings.fetch!(:onboarding, true)
|
{:ok, yt_dlp_version} = yt_dlp_runner().version()
|
||||||
Settings.fetch!(:pro_enabled, false)
|
{:ok, apprise_version} = apprise_runner().version()
|
||||||
|
|
||||||
|
Settings.set(yt_dlp_version: yt_dlp_version)
|
||||||
|
Settings.set(apprise_version: apprise_version)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp yt_dlp_runner do
|
||||||
|
Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apprise_runner do
|
||||||
|
Application.get_env(:pinchflat, :apprise_runner)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
thumbnail_options(media_item_with_preloads) ++
|
thumbnail_options(media_item_with_preloads) ++
|
||||||
metadata_options(media_profile) ++
|
metadata_options(media_profile) ++
|
||||||
quality_options(media_profile) ++
|
quality_options(media_profile) ++
|
||||||
|
sponsorblock_options(media_profile) ++
|
||||||
output_options(media_item_with_preloads)
|
output_options(media_item_with_preloads)
|
||||||
|
|
||||||
{:ok, built_options}
|
{:ok, built_options}
|
||||||
@@ -116,6 +117,17 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
end
|
end
|
||||||
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
|
defp output_options(media_item_with_preloads) do
|
||||||
[
|
[
|
||||||
output: build_output_path_for(media_item_with_preloads.source)
|
output: build_output_path_for(media_item_with_preloads.source)
|
||||||
@@ -132,6 +144,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
defp output_options_map(source) do
|
defp output_options_map(source) do
|
||||||
%{
|
%{
|
||||||
"source_custom_name" => source.custom_name,
|
"source_custom_name" => source.custom_name,
|
||||||
|
"source_collection_id" => source.collection_id,
|
||||||
|
"source_collection_name" => source.collection_name,
|
||||||
"source_collection_type" => source.collection_type
|
"source_collection_type" => source.collection_type
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,27 +19,29 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
|
|
||||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def kickoff_with_task(media_item, opts \\ []) do
|
def kickoff_with_task(media_item, job_args \\ %{}, job_opts \\ []) do
|
||||||
%{id: media_item.id}
|
%{id: media_item.id}
|
||||||
|> MediaDownloadWorker.new(opts)
|
|> Map.merge(job_args)
|
||||||
|
|> MediaDownloadWorker.new(job_opts)
|
||||||
|> Tasks.create_job_with_task(media_item)
|
|> Tasks.create_job_with_task(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
For a given media item, download the media alongside any options.
|
For a given media item, download the media alongside any options.
|
||||||
Does not download media if its source is set to not download media.
|
Does not download media if its source is set to not download media
|
||||||
|
(unless forced).
|
||||||
|
|
||||||
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
|
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
||||||
media_item =
|
media_item =
|
||||||
media_item_id
|
media_item_id
|
||||||
|> Media.get_media_item!()
|
|> Media.get_media_item!()
|
||||||
|> Repo.preload(:source)
|
|> Repo.preload(:source)
|
||||||
|
|
||||||
# If the source is set to not download media, perform a no-op
|
# If the source or media item is set to not download media, perform a no-op unless forced
|
||||||
if media_item.source.download_media do
|
if (media_item.source.download_media && !media_item.prevent_download) || args["force"] do
|
||||||
download_media_and_schedule_jobs(media_item)
|
download_media_and_schedule_jobs(media_item)
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
@@ -56,8 +58,11 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
|
|
||||||
{:ok, updated_media_item}
|
{:ok, updated_media_item}
|
||||||
|
|
||||||
err ->
|
{:recovered, _} ->
|
||||||
err
|
{:error, :retry}
|
||||||
|
|
||||||
|
{:error, _message} ->
|
||||||
|
{:error, :download_failed}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
to download the media with the desired options.
|
to download the media with the desired options.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Metadata.NfoBuilder
|
alias Pinchflat.Metadata.NfoBuilder
|
||||||
alias Pinchflat.Metadata.MetadataParser
|
alias Pinchflat.Metadata.MetadataParser
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
alias Pinchflat.Downloading.DownloadOptionBuilder
|
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||||
|
|
||||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
@@ -27,33 +30,69 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
|
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
def download_for_media_item(%MediaItem{} = media_item) do
|
def download_for_media_item(%MediaItem{} = media_item) do
|
||||||
item_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
|
output_filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
||||||
|
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
|
||||||
|
|
||||||
case download_with_options(media_item.original_url, item_with_preloads) do
|
case download_with_options(media_item.original_url, media_with_preloads, output_filepath) do
|
||||||
{:ok, parsed_json} ->
|
{:ok, parsed_json} ->
|
||||||
parsed_attrs =
|
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
|
||||||
parsed_json
|
|
||||||
|> MetadataParser.parse_for_media_item()
|
|
||||||
|> Map.merge(%{
|
|
||||||
media_downloaded_at: DateTime.utc_now(),
|
|
||||||
nfo_filepath: determine_nfo_filepath(item_with_preloads, parsed_json),
|
|
||||||
metadata: %{
|
|
||||||
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
|
||||||
# could fail and I want to handle that in isolation
|
|
||||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, parsed_json),
|
|
||||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item, parsed_json)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
# Don't forgor to use preloaded associations or updates to
|
{:error, message, _exit_code} ->
|
||||||
# associations won't work!
|
Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
|
||||||
Media.update_media_item(item_with_preloads, parsed_attrs)
|
|
||||||
|
if String.contains?(to_string(message), recoverable_errors()) do
|
||||||
|
attempt_update_media_item(media_with_preloads, output_filepath)
|
||||||
|
|
||||||
|
{:recovered, message}
|
||||||
|
else
|
||||||
|
{:error, message}
|
||||||
|
end
|
||||||
|
|
||||||
err ->
|
err ->
|
||||||
err
|
Logger.error("Unknown error downloading media item ##{media_with_preloads.id}: #{inspect(err)}")
|
||||||
|
|
||||||
|
{:error, "Unknown error: #{inspect(err)}"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp attempt_update_media_item(media_with_preloads, output_filepath) do
|
||||||
|
with {:ok, contents} <- File.read(output_filepath),
|
||||||
|
{:ok, parsed_json} <- Phoenix.json_library().decode(contents) do
|
||||||
|
Logger.info("""
|
||||||
|
Recovery from yt-dlp error seems possible. Updating media item ##{media_with_preloads.id}
|
||||||
|
with parsed JSON from partial download attempt. Full download will be re-attemted in future
|
||||||
|
anyway
|
||||||
|
""")
|
||||||
|
|
||||||
|
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
|
||||||
|
else
|
||||||
|
err ->
|
||||||
|
Logger.error("Unable to recover error for media item ##{media_with_preloads.id}: #{inspect(err)}")
|
||||||
|
|
||||||
|
{:error, :retry_failed}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_media_item_from_parsed_json(media_with_preloads, parsed_json) do
|
||||||
|
parsed_attrs =
|
||||||
|
parsed_json
|
||||||
|
|> MetadataParser.parse_for_media_item()
|
||||||
|
|> Map.merge(%{
|
||||||
|
media_downloaded_at: DateTime.utc_now(),
|
||||||
|
nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json),
|
||||||
|
metadata: %{
|
||||||
|
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
||||||
|
# could fail and I want to handle that in isolation
|
||||||
|
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_with_preloads, parsed_json),
|
||||||
|
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads, parsed_json)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Don't forgor to use preloaded associations or updates to
|
||||||
|
# associations won't work!
|
||||||
|
Media.update_media_item(media_with_preloads, parsed_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
defp determine_nfo_filepath(media_item, parsed_json) do
|
defp determine_nfo_filepath(media_item, parsed_json) do
|
||||||
if media_item.source.media_profile.download_nfo do
|
if media_item.source.media_profile.download_nfo do
|
||||||
filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo"
|
filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo"
|
||||||
@@ -64,9 +103,15 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp download_with_options(url, item_with_preloads) do
|
defp download_with_options(url, item_with_preloads, output_filepath) do
|
||||||
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads)
|
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads)
|
||||||
|
|
||||||
YtDlpMedia.download(url, options)
|
YtDlpMedia.download(url, options, output_filepath: output_filepath)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recoverable_errors do
|
||||||
|
[
|
||||||
|
"Unable to communicate with SponsorBlock"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
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.
|
Many of these methods are made to be kickoff or be consumed by workers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.FastIndexing.YoutubeRss
|
alias Pinchflat.FastIndexing.YoutubeRss
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
||||||
@@ -23,11 +25,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
function starts individual indexing tasks for each new media item. I think it does
|
function starts individual indexing tasks for each new media item. I think it does
|
||||||
make sense grammatically, but I could see how that's confusing.
|
make sense grammatically, but I could see how that's confusing.
|
||||||
|
|
||||||
Returns :ok
|
Returns [binary()] where each binary is the media ID of a new media item.
|
||||||
"""
|
"""
|
||||||
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
|
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
|
||||||
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
{: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)
|
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
|
||||||
|
|
||||||
Enum.each(new_media_ids, fn media_id ->
|
Enum.each(new_media_ids, fn media_id ->
|
||||||
@@ -35,6 +37,8 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
|
|
||||||
MediaIndexingWorker.kickoff_with_task(source, url)
|
MediaIndexingWorker.kickoff_with_task(source, url)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
new_media_ids
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -61,6 +65,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
end
|
end
|
||||||
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
|
defp create_media_item_from_url(source, url) do
|
||||||
{:ok, media_attrs} = YtDlpMedia.get_media_attributes(url)
|
{:ok, media_attrs} = YtDlpMedia.get_media_attributes(url)
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
|||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||||
|
alias Pinchflat.Notifications.SourceNotifications
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts the source fast indexing worker and creates a task for the source.
|
Starts the source fast indexing worker and creates a task for the source.
|
||||||
@@ -37,8 +39,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
|||||||
source = Sources.get_source!(source_id)
|
source = Sources.get_source!(source_id)
|
||||||
|
|
||||||
if source.fast_index do
|
if source.fast_index do
|
||||||
FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
perform_indexing_and_notification(source)
|
||||||
|
|
||||||
reschedule_indexing(source)
|
reschedule_indexing(source)
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
@@ -48,6 +49,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
|||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp perform_indexing_and_notification(source) do
|
||||||
|
apprise_server = Settings.get!(:apprise_server)
|
||||||
|
new_media_items = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
||||||
|
|
||||||
|
SourceNotifications.send_new_media_notification(apprise_server, source, length(new_media_items))
|
||||||
|
end
|
||||||
|
|
||||||
defp reschedule_indexing(source) do
|
defp reschedule_indexing(source) do
|
||||||
next_run_in = Source.fast_index_frequency() * 60
|
next_run_in = Source.fast_index_frequency() * 60
|
||||||
|
|
||||||
|
|||||||
+43
-121
@@ -9,6 +9,7 @@ defmodule Pinchflat.Media do
|
|||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
|
|
||||||
@@ -22,66 +23,32 @@ defmodule Pinchflat.Media do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
def list_cullable_media_items do
|
||||||
MediaItem
|
MediaQuery.new()
|
||||||
|> where([mi], mi.source_id == ^source.id)
|
|> MediaQuery.with_media_filepath()
|
||||||
|> Repo.all()
|
|> MediaQuery.with_passed_retention_period()
|
||||||
end
|
|> MediaQuery.with_no_culling_prevention()
|
||||||
|
|
||||||
@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)
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of pending media_items for a given source, where
|
Returns a list of pending media_items for a given source, where
|
||||||
pending means the `media_filepath` is `nil` AND the media_item
|
pending means the `media_filepath` is `nil` AND the media_item
|
||||||
matches the format selection rules of the parent media_profile.
|
matches satisfies `MediaQuery.with_media_pending_download`. You
|
||||||
|
should really check out that function if you need to know more
|
||||||
See `build_format_clauses` but tl;dr is it _may_ filter based
|
because it has a lot going on.
|
||||||
on shorts or livestreams depending on the media_profile settings.
|
|
||||||
|
|
||||||
Returns [%MediaItem{}, ...].
|
Returns [%MediaItem{}, ...].
|
||||||
"""
|
"""
|
||||||
def list_pending_media_items_for(%Source{} = source, opts \\ []) do
|
def list_pending_media_items_for(%Source{} = source) do
|
||||||
limit = Keyword.get(opts, :limit, nil)
|
MediaQuery.new()
|
||||||
media_profile = Repo.preload(source, :media_profile).media_profile
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_pending_download()
|
||||||
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))
|
|
||||||
|> Repo.maybe_limit(limit)
|
|
||||||
|> Repo.all()
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns a list of downloaded media_items for a given source.
|
|
||||||
|
|
||||||
Returns [%MediaItem{}, ...].
|
|
||||||
"""
|
|
||||||
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))
|
|
||||||
|> Repo.maybe_limit(limit)
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -97,11 +64,9 @@ defmodule Pinchflat.Media do
|
|||||||
def pending_download?(%MediaItem{} = media_item) do
|
def pending_download?(%MediaItem{} = media_item) do
|
||||||
media_item = Repo.preload(media_item, source: :media_profile)
|
media_item = Repo.preload(media_item, source: :media_profile)
|
||||||
|
|
||||||
MediaItem
|
MediaQuery.new()
|
||||||
|> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath))
|
|> MediaQuery.with_id(media_item.id)
|
||||||
|> where(^build_format_clauses(media_item.source.media_profile))
|
|> MediaQuery.with_media_pending_download()
|
||||||
|> where(^maybe_apply_cutoff_date(media_item.source))
|
|
||||||
|> where(^maybe_apply_title_regex(media_item.source))
|
|
||||||
|> Repo.exists?()
|
|> Repo.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -120,20 +85,9 @@ defmodule Pinchflat.Media do
|
|||||||
def search(search_term, opts) do
|
def search(search_term, opts) do
|
||||||
limit = Keyword.get(opts, :limit, 50)
|
limit = Keyword.get(opts, :limit, 50)
|
||||||
|
|
||||||
from(mi in MediaItem,
|
MediaQuery.new()
|
||||||
join: mi_search_index in assoc(mi, :media_items_search_index),
|
|> MediaQuery.matching_search_term(search_term)
|
||||||
where: fragment("media_items_search_index MATCH ?", ^search_term),
|
|> Repo.maybe_limit(limit)
|
||||||
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
|
|
||||||
)
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -201,7 +155,7 @@ defmodule Pinchflat.Media do
|
|||||||
Tasks.delete_tasks_for(media_item)
|
Tasks.delete_tasks_for(media_item)
|
||||||
|
|
||||||
if delete_files do
|
if delete_files do
|
||||||
{:ok, _} = delete_media_files(media_item)
|
{:ok, _} = do_delete_media_files(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Should delete these no matter what
|
# Should delete these no matter what
|
||||||
@@ -209,6 +163,25 @@ defmodule Pinchflat.Media do
|
|||||||
Repo.delete(media_item)
|
Repo.delete(media_item)
|
||||||
end
|
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 """
|
@doc """
|
||||||
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
|
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
|
||||||
"""
|
"""
|
||||||
@@ -216,7 +189,7 @@ defmodule Pinchflat.Media do
|
|||||||
MediaItem.changeset(media_item, attrs)
|
MediaItem.changeset(media_item, attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp delete_media_files(media_item) do
|
defp do_delete_media_files(media_item) do
|
||||||
mapped_struct = Map.from_struct(media_item)
|
mapped_struct = Map.from_struct(media_item)
|
||||||
|
|
||||||
MediaItem.filepath_attributes()
|
MediaItem.filepath_attributes()
|
||||||
@@ -240,55 +213,4 @@ defmodule Pinchflat.Media do
|
|||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.filter(&is_binary/1)
|
||||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||||
end
|
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)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
:source_id,
|
:source_id,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:upload_date,
|
:upload_date,
|
||||||
|
:duration_seconds,
|
||||||
# these fields are captured only on download
|
# these fields are captured only on download
|
||||||
:media_downloaded_at,
|
:media_downloaded_at,
|
||||||
:media_filepath,
|
:media_filepath,
|
||||||
@@ -29,7 +30,11 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
:subtitle_filepaths,
|
:subtitle_filepaths,
|
||||||
:thumbnail_filepath,
|
:thumbnail_filepath,
|
||||||
:metadata_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.
|
# Pretty much all the fields captured at index are required.
|
||||||
@required_fields ~w(
|
@required_fields ~w(
|
||||||
@@ -41,7 +46,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
source_id
|
source_id
|
||||||
upload_date
|
upload_date
|
||||||
short_form_content
|
short_form_content
|
||||||
)a
|
)a
|
||||||
|
|
||||||
schema "media_items" do
|
schema "media_items" do
|
||||||
# This is _not_ used as the primary key or internally in the database
|
# 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 :short_form_content, :boolean, default: false
|
||||||
field :media_downloaded_at, :utc_datetime
|
field :media_downloaded_at, :utc_datetime
|
||||||
field :upload_date, :date
|
field :upload_date, :date
|
||||||
|
field :duration_seconds, :integer
|
||||||
|
|
||||||
field :media_filepath, :string
|
field :media_filepath, :string
|
||||||
field :media_size_bytes, :integer
|
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.
|
# Will very likely revisit because I can't leave well-enough alone.
|
||||||
field :subtitle_filepaths, {:array, {:array, :string}}, default: []
|
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
|
field :matching_search_term, :string, virtual: true
|
||||||
|
|
||||||
belongs_to :source, Source
|
belongs_to :source, Source
|
||||||
@@ -94,4 +104,14 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
def filepath_attributes do
|
def filepath_attributes do
|
||||||
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
|
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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. Each method should strive to do
|
||||||
|
_one_ thing. These don't need to be tested as
|
||||||
|
they are just building blocks for other functionality
|
||||||
|
which, itself, will be tested.
|
||||||
|
"""
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
|
alias Pinchflat.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
|
||||||
|
query
|
||||||
|
|> require_assoc(:source)
|
||||||
|
|> where(
|
||||||
|
[mi, source],
|
||||||
|
fragment(
|
||||||
|
"IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
|
||||||
|
source.retention_period_days,
|
||||||
|
source.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_source_cutoff(query) do
|
||||||
|
query
|
||||||
|
|> require_assoc(:source)
|
||||||
|
|> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_no_prevented_download(query) do
|
||||||
|
where(query, [mi], mi.prevent_download == false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def matching_source_title_regex(query) do
|
||||||
|
query
|
||||||
|
|> require_assoc(:source)
|
||||||
|
|> where(
|
||||||
|
[mi, source],
|
||||||
|
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_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
|
||||||
|
|
||||||
|
def with_format_matching_profile_preference(query) do
|
||||||
|
query
|
||||||
|
|> require_assoc(:media_profile)
|
||||||
|
|> where(
|
||||||
|
fragment("""
|
||||||
|
CASE
|
||||||
|
WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
|
||||||
|
livestream = true OR short_form_content = true
|
||||||
|
WHEN shorts_behaviour = 'only' THEN
|
||||||
|
short_form_content = true
|
||||||
|
WHEN livestream_behaviour = 'only' THEN
|
||||||
|
livestream = true
|
||||||
|
WHEN shorts_behaviour = 'exclude' AND livestream_behaviour = 'exclude' THEN
|
||||||
|
short_form_content = false AND livestream = false
|
||||||
|
WHEN shorts_behaviour = 'exclude' THEN
|
||||||
|
short_form_content = false
|
||||||
|
WHEN livestream_behaviour = 'exclude' THEN
|
||||||
|
livestream = false
|
||||||
|
ELSE
|
||||||
|
true
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_media_pending_download(query) do
|
||||||
|
query
|
||||||
|
|> with_no_prevented_download()
|
||||||
|
|> with_no_media_filepath()
|
||||||
|
|> with_upload_date_after_source_cutoff()
|
||||||
|
|> with_format_matching_profile_preference()
|
||||||
|
|> matching_source_title_regex()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp require_assoc(query, identifier) do
|
||||||
|
if has_named_binding?(query, identifier) do
|
||||||
|
query
|
||||||
|
else
|
||||||
|
do_require_assoc(query, identifier)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_require_assoc(query, :source) do
|
||||||
|
from(mi in query, join: s in assoc(mi, :source), as: :source)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_require_assoc(query, :media_profile) do
|
||||||
|
query
|
||||||
|
|> require_assoc(:source)
|
||||||
|
|> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -30,7 +30,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
|||||||
original_url: metadata["original_url"],
|
original_url: metadata["original_url"],
|
||||||
description: metadata["description"],
|
description: metadata["description"],
|
||||||
media_filepath: metadata["filepath"],
|
media_filepath: metadata["filepath"],
|
||||||
livestream: metadata["was_live"]
|
livestream: metadata["was_live"],
|
||||||
|
duration_seconds: metadata["duration"] && round(metadata["duration"])
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
use by Kodi/Jellyfin and other media center software.
|
use by Kodi/Jellyfin and other media center software.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
||||||
|
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
|
|
||||||
@@ -42,12 +44,12 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
"""
|
"""
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
||||||
<episodedetails>
|
<episodedetails>
|
||||||
<title>#{metadata["title"]}</title>
|
<title>#{safe(metadata["title"])}</title>
|
||||||
<showtitle>#{metadata["uploader"]}</showtitle>
|
<showtitle>#{safe(metadata["uploader"])}</showtitle>
|
||||||
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
|
<uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
|
||||||
<plot>#{metadata["description"]}</plot>
|
<plot>#{safe(metadata["description"])}</plot>
|
||||||
<aired>#{upload_date}</aired>
|
<aired>#{safe(upload_date)}</aired>
|
||||||
<season>#{upload_date.year}</season>
|
<season>#{safe(upload_date.year)}</season>
|
||||||
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
|
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
|
||||||
<genre>YouTube</genre>
|
<genre>YouTube</genre>
|
||||||
</episodedetails>
|
</episodedetails>
|
||||||
@@ -58,9 +60,9 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
"""
|
"""
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
||||||
<tvshow>
|
<tvshow>
|
||||||
<title>#{metadata["title"]}</title>
|
<title>#{safe(metadata["title"])}</title>
|
||||||
<plot>#{metadata["description"]}</plot>
|
<plot>#{safe(metadata["description"])}</plot>
|
||||||
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
|
<uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
|
||||||
<genre>YouTube</genre>
|
<genre>YouTube</genre>
|
||||||
</tvshow>
|
</tvshow>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
defmodule Pinchflat.Notifications.AppriseCommandRunner do
|
||||||
|
@moduledoc """
|
||||||
|
A behaviour for running CLI commands against a notification backend (apprise).
|
||||||
|
|
||||||
|
Used so we can implement Mox for testing without actually running the
|
||||||
|
apprise command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@callback run(binary(), keyword()) :: :ok | {:error, binary()}
|
||||||
|
@callback run(List.t(), keyword()) :: :ok | {:error, binary()}
|
||||||
|
@callback version() :: {:ok, binary()} | {:error, binary()}
|
||||||
|
end
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
defmodule Pinchflat.Notifications.CommandRunner do
|
||||||
|
@moduledoc """
|
||||||
|
Runs apprise commands using the `System.cmd/3` function
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Pinchflat.Utils.CliUtils
|
||||||
|
alias Pinchflat.Utils.FunctionUtils
|
||||||
|
alias Pinchflat.Notifications.AppriseCommandRunner
|
||||||
|
|
||||||
|
@behaviour AppriseCommandRunner
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Runs an apprise command and returns the string output.
|
||||||
|
Can take a single server string or a list of servers as well as additional
|
||||||
|
arguments to pass to the command.
|
||||||
|
|
||||||
|
Returns {:ok, binary()} | {:error, :no_servers} | {:error, binary()}
|
||||||
|
"""
|
||||||
|
@impl AppriseCommandRunner
|
||||||
|
def run(nil, _), do: {:error, :no_servers}
|
||||||
|
def run("", _), do: {:error, :no_servers}
|
||||||
|
def run([], _), do: {:error, :no_servers}
|
||||||
|
|
||||||
|
def run(endpoints, command_opts) do
|
||||||
|
endpoints = List.wrap(endpoints)
|
||||||
|
default_opts = [:verbose]
|
||||||
|
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
|
||||||
|
|
||||||
|
Logger.info("[apprise] called with: #{Enum.join(parsed_opts ++ endpoints, " ")}")
|
||||||
|
{output, return_code} = System.cmd(backend_executable(), parsed_opts ++ endpoints)
|
||||||
|
Logger.info("[apprise] response: #{output}")
|
||||||
|
|
||||||
|
case return_code do
|
||||||
|
0 -> {:ok, String.trim(output)}
|
||||||
|
_ -> {:error, String.trim(output)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the version of apprise as a string.
|
||||||
|
|
||||||
|
Returns {:ok, binary()} | {:error, binary()}
|
||||||
|
"""
|
||||||
|
@impl AppriseCommandRunner
|
||||||
|
def version do
|
||||||
|
case System.cmd(backend_executable(), ["--version"]) do
|
||||||
|
{output, 0} ->
|
||||||
|
output
|
||||||
|
|> String.split(~r{\r?\n})
|
||||||
|
|> List.first()
|
||||||
|
|> String.replace("Apprise", "")
|
||||||
|
|> String.trim()
|
||||||
|
|> FunctionUtils.wrap_ok()
|
||||||
|
|
||||||
|
{output, _} ->
|
||||||
|
{:error, output}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp backend_executable do
|
||||||
|
Application.get_env(:pinchflat, :apprise_executable)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
defmodule Pinchflat.Notifications.SourceNotifications do
|
||||||
|
@moduledoc """
|
||||||
|
Contains utilities for sending notifications about sources
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Wraps a function that may change the number of pending or downloaded
|
||||||
|
media items for a source, sending an apprise notification if
|
||||||
|
the count changes.
|
||||||
|
|
||||||
|
Returns the return value of the provided function
|
||||||
|
"""
|
||||||
|
def wrap_new_media_notification(servers, source, func) do
|
||||||
|
before_count = relevant_media_item_count(source)
|
||||||
|
retval = func.()
|
||||||
|
after_count = relevant_media_item_count(source)
|
||||||
|
|
||||||
|
send_new_media_notification(servers, source, after_count - before_count)
|
||||||
|
|
||||||
|
retval
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends a notification if the count of new media items has changed
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
def send_new_media_notification(_, _, count) when count <= 0, do: :ok
|
||||||
|
|
||||||
|
def send_new_media_notification(servers, source, changed_count) do
|
||||||
|
opts = [
|
||||||
|
title: "[Pinchflat] New media found",
|
||||||
|
body: "Found #{changed_count} new media item(s) for #{source.custom_name}. Downloading them now"
|
||||||
|
]
|
||||||
|
|
||||||
|
case backend_runner().run(servers, opts) do
|
||||||
|
{:ok, _} ->
|
||||||
|
Logger.info("Sent new media notification for source #{source.id}")
|
||||||
|
|
||||||
|
{:error, :no_servers} ->
|
||||||
|
Logger.info("No notification servers provided for source #{source.id}")
|
||||||
|
|
||||||
|
{:error, err} ->
|
||||||
|
Logger.error("Failed to send new media notification for source #{source.id}: #{err}")
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp relevant_media_item_count(source) do
|
||||||
|
pending_media_item_count(source) + downloaded_media_item_count(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pending_media_item_count(source) do
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_pending_download()
|
||||||
|
|> Repo.aggregate(:count)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp downloaded_media_item_count(source) do
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_filepath()
|
||||||
|
|> Repo.aggregate(:count)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp backend_runner do
|
||||||
|
# This approach lets us mock the command for testing
|
||||||
|
Application.get_env(:pinchflat, :apprise_runner)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,7 +5,7 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
|
|
||||||
@@ -25,8 +25,11 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
def persisted_media_items_for(source, opts \\ []) do
|
def persisted_media_items_for(source, opts \\ []) do
|
||||||
limit = Keyword.get(opts, :limit, 500)
|
limit = Keyword.get(opts, :limit, 500)
|
||||||
|
|
||||||
source
|
MediaQuery.new()
|
||||||
|> Media.list_downloaded_media_items_for(limit: limit)
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_filepath()
|
||||||
|
|> Repo.maybe_limit(limit)
|
||||||
|
|> Repo.all()
|
||||||
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
|
|
||||||
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
|
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
|
||||||
|
|
||||||
|
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
||||||
|
|
||||||
alias Pinchflat.Utils.DatetimeUtils
|
alias Pinchflat.Utils.DatetimeUtils
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
alias PinchflatWeb.Router.Helpers, as: Routes
|
alias PinchflatWeb.Router.Helpers, as: Routes
|
||||||
@@ -43,25 +45,25 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
xmlns:atom="http://www.w3.org/2005/Atom">
|
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
<channel>
|
<channel>
|
||||||
<title>#{safe(source.custom_name)}</title>
|
<title>#{safe(source.custom_name)}</title>
|
||||||
<link>#{source.original_url}</link>
|
<link>#{safe(source.original_url)}</link>
|
||||||
<description>#{safe(source.description)}</description>
|
<description>#{safe(source.description)}</description>
|
||||||
<category>TV & Film</category>
|
<category>TV & Film</category>
|
||||||
<generator>Generated by Pinchflat</generator>
|
<generator>Generated by Pinchflat</generator>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<lastBuildDate>#{Calendar.strftime(source.updated_at, @datetime_format)}</lastBuildDate>
|
<lastBuildDate>#{Calendar.strftime(source.updated_at, @datetime_format)}</lastBuildDate>
|
||||||
<pubDate>#{Calendar.strftime(source.inserted_at, @datetime_format)}</pubDate>
|
<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:locked>yes</podcast:locked>
|
||||||
<podcast:guid>#{source.uuid}</podcast:guid>
|
<podcast:guid>#{source.uuid}</podcast:guid>
|
||||||
<image>
|
<image>
|
||||||
<url>#{feed_image_path}</url>
|
<url>#{safe(feed_image_path)}</url>
|
||||||
<title>#{safe(source.custom_name)}</title>
|
<title>#{safe(source.custom_name)}</title>
|
||||||
<link>#{source.original_url}</link>
|
<link>#{safe(source.original_url)}</link>
|
||||||
</image>
|
</image>
|
||||||
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
||||||
<itunes:subtitle>#{safe(source.custom_name)}</itunes:subtitle>
|
<itunes:subtitle>#{safe(source.custom_name)}</itunes:subtitle>
|
||||||
<itunes:block>yes</itunes:block>
|
<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:explicit>false</itunes:explicit>
|
||||||
<itunes:category text="TV & Film"></itunes:category>
|
<itunes:category text="TV & Film"></itunes:category>
|
||||||
|
|
||||||
@@ -77,9 +79,10 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
<item>
|
<item>
|
||||||
<guid isPermaLink="false">#{media_item.uuid}</guid>
|
<guid isPermaLink="false">#{media_item.uuid}</guid>
|
||||||
<title>#{safe(media_item.title)}</title>
|
<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>
|
<description>#{safe(media_item.description)}</description>
|
||||||
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
||||||
|
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
||||||
<enclosure
|
<enclosure
|
||||||
url="#{media_stream_path(url_base, media_item)}"
|
url="#{media_stream_path(url_base, media_item)}"
|
||||||
length="#{media_item.media_size_bytes}"
|
length="#{media_item.media_size_bytes}"
|
||||||
@@ -93,14 +96,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp safe(nil), do: ""
|
|
||||||
|
|
||||||
defp safe(value) do
|
|
||||||
value
|
|
||||||
|> Phoenix.HTML.html_escape()
|
|
||||||
|> Phoenix.HTML.safe_to_string()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp generate_self_link(url_base, source) do
|
defp generate_self_link(url_base, source) do
|
||||||
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
|
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
download_metadata
|
download_metadata
|
||||||
embed_metadata
|
embed_metadata
|
||||||
download_nfo
|
download_nfo
|
||||||
|
sponsorblock_behaviour
|
||||||
|
sponsorblock_categories
|
||||||
shorts_behaviour
|
shorts_behaviour
|
||||||
livestream_behaviour
|
livestream_behaviour
|
||||||
preferred_resolution
|
preferred_resolution
|
||||||
@@ -47,6 +49,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
field :embed_metadata, :boolean, default: false
|
field :embed_metadata, :boolean, default: false
|
||||||
|
|
||||||
field :download_nfo, :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
|
# 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
|
# through the entire collection to determine if a media is a short or
|
||||||
# a livestream.
|
# a livestream.
|
||||||
|
|||||||
@@ -25,14 +25,15 @@ defmodule Pinchflat.Release do
|
|||||||
def check_file_permissions do
|
def check_file_permissions do
|
||||||
load_app()
|
load_app()
|
||||||
|
|
||||||
directories = [
|
directories =
|
||||||
"/config",
|
Enum.uniq([
|
||||||
"/downloads",
|
"/config",
|
||||||
Application.get_env(:pinchflat, :media_directory),
|
"/downloads",
|
||||||
Application.get_env(:pinchflat, :tmpfile_directory),
|
Application.get_env(:pinchflat, :media_directory),
|
||||||
Application.get_env(:pinchflat, :extras_directory),
|
Application.get_env(:pinchflat, :tmpfile_directory),
|
||||||
Application.get_env(:pinchflat, :metadata_directory)
|
Application.get_env(:pinchflat, :extras_directory),
|
||||||
]
|
Application.get_env(:pinchflat, :metadata_directory)
|
||||||
|
])
|
||||||
|
|
||||||
Enum.each(directories, fn dir ->
|
Enum.each(directories, fn dir ->
|
||||||
Logger.info("Checking permissions for #{dir}")
|
Logger.info("Checking permissions for #{dir}")
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
defmodule Pinchflat.Settings.Setting do
|
defmodule Pinchflat.Settings.Setting do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
A Setting is a key-value pair with a datatype used to track user-level settings.
|
The Setting schema.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
schema "settings" do
|
@allowed_fields [
|
||||||
field :name, :string
|
:onboarding,
|
||||||
field :value, :string
|
:pro_enabled,
|
||||||
field :datatype, Ecto.Enum, values: ~w(boolean string integer float)a
|
:yt_dlp_version,
|
||||||
|
:apprise_version,
|
||||||
|
:apprise_server
|
||||||
|
]
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
@required_fields ~w(
|
||||||
|
onboarding
|
||||||
|
pro_enabled
|
||||||
|
)a
|
||||||
|
|
||||||
|
schema "settings" do
|
||||||
|
field :onboarding, :boolean, default: true
|
||||||
|
field :pro_enabled, :boolean, default: false
|
||||||
|
field :yt_dlp_version, :string
|
||||||
|
field :apprise_version, :string
|
||||||
|
field :apprise_server, :string
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def changeset(setting, attrs) do
|
def changeset(setting, attrs) do
|
||||||
setting
|
setting
|
||||||
|> cast(attrs, [:name, :value, :datatype])
|
|> cast(attrs, @allowed_fields)
|
||||||
|> validate_required([:name, :value, :datatype])
|
|> validate_required(@required_fields)
|
||||||
|> unique_constraint([:name])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,94 +2,80 @@ defmodule Pinchflat.Settings do
|
|||||||
@moduledoc """
|
@moduledoc """
|
||||||
The Settings context.
|
The Settings context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
alias Pinchflat.Repo
|
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Settings.Setting
|
alias Pinchflat.Settings.Setting
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of settings.
|
Returns the only setting record. It _should_ be impossible
|
||||||
|
to create or delete this record, so it's assertive about
|
||||||
|
assuming it's the only one.
|
||||||
|
|
||||||
Returns [%Setting{}, ...]
|
Returns %Setting{}
|
||||||
"""
|
"""
|
||||||
def list_settings do
|
def record do
|
||||||
Repo.all(Setting)
|
Setting
|
||||||
|
|> limit(1)
|
||||||
|
|> Repo.one()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates or updates a setting, returning the parsed value.
|
Updates the setting record.
|
||||||
Raises if an unsupported datatype is used. Optionally allows
|
|
||||||
specifying the datatype.
|
|
||||||
|
|
||||||
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)`
|
Returns {:ok, %Setting{}} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def set!(name, value) do
|
def update_setting(%Setting{} = setting, attrs) do
|
||||||
set!(name, value, infer_datatype(value))
|
setting
|
||||||
|
|> Setting.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
def set!(name, value, datatype) do
|
@doc """
|
||||||
# Only create if doesn't exist
|
Updates a setting, returning the new value.
|
||||||
case Repo.get_by(Setting, name: to_string(name)) do
|
Is setup to take a keyword list argument so you
|
||||||
nil -> create_setting!(name, value, datatype)
|
can call it like `Settings.set(onboarding: true)`
|
||||||
setting -> update_setting!(setting, value, datatype)
|
|
||||||
|
Returns {:ok, value} | {:error, :invalid_key} | {:error, %Ecto.Changeset{}}
|
||||||
|
"""
|
||||||
|
def set([{attr, value}]) do
|
||||||
|
record()
|
||||||
|
|> update_setting(%{attr => value})
|
||||||
|
|> case do
|
||||||
|
{:ok, %{^attr => _}} -> {:ok, value}
|
||||||
|
{:ok, _} -> {:error, :invalid_key}
|
||||||
|
{:error, changeset} -> {:error, changeset}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets the parsed value of a setting. Raises if the setting does not exist.
|
Gets the value of a setting.
|
||||||
|
|
||||||
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)`
|
Returns {:ok, value} | {:error, :invalid_key}
|
||||||
|
"""
|
||||||
|
def get(name) do
|
||||||
|
case Map.fetch(record(), name) do
|
||||||
|
{:ok, value} -> {:ok, value}
|
||||||
|
:error -> {:error, :invalid_key}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the value of a setting, raising if it doesn't exist.
|
||||||
|
|
||||||
|
Returns value
|
||||||
"""
|
"""
|
||||||
def get!(name) do
|
def get!(name) do
|
||||||
Setting
|
case get(name) do
|
||||||
|> Repo.get_by!(name: to_string(name))
|
{:ok, value} -> value
|
||||||
|> read_setting()
|
{:error, _} -> raise "Setting `#{name}` not found"
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Attempts to find a setting by name or creates a setting with value
|
|
||||||
if one doesn't exist, returning the parsed value. Optionally allows
|
|
||||||
specifying the datatype.
|
|
||||||
|
|
||||||
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)`
|
|
||||||
"""
|
|
||||||
def fetch!(name, value) do
|
|
||||||
fetch!(name, value, infer_datatype(value))
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch!(name, value, datatype) do
|
|
||||||
case Repo.get_by(Setting, name: to_string(name)) do
|
|
||||||
nil -> create_setting!(name, value, datatype)
|
|
||||||
setting -> read_setting(setting)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp change_setting(setting, attrs) do
|
@doc """
|
||||||
|
Returns `%Ecto.Changeset{}`
|
||||||
|
"""
|
||||||
|
def change_setting(%Setting{} = setting, attrs \\ %{}) do
|
||||||
Setting.changeset(setting, attrs)
|
Setting.changeset(setting, attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_setting!(name, value, datatype) do
|
|
||||||
%Setting{}
|
|
||||||
|> change_setting(%{name: to_string(name), value: to_string(value), datatype: datatype})
|
|
||||||
|> Repo.insert!()
|
|
||||||
|> read_setting()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp update_setting!(setting, value, datatype) do
|
|
||||||
setting
|
|
||||||
|> change_setting(%{value: to_string(value), datatype: datatype})
|
|
||||||
|> Repo.update!()
|
|
||||||
|> read_setting()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp read_setting(%{value: value, datatype: :string}), do: value
|
|
||||||
defp read_setting(%{value: value, datatype: :boolean}), do: value in ["true", "t", "1"]
|
|
||||||
defp read_setting(%{value: value, datatype: :integer}), do: String.to_integer(value)
|
|
||||||
defp read_setting(%{value: value, datatype: :float}), do: String.to_float(value)
|
|
||||||
|
|
||||||
defp infer_datatype(value) when is_boolean(value), do: :boolean
|
|
||||||
defp infer_datatype(value) when is_integer(value), do: :integer
|
|
||||||
defp infer_datatype(value) when is_float(value), do: :float
|
|
||||||
defp infer_datatype(value) when is_binary(value), do: :string
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,18 +11,21 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
|
alias Pinchflat.Notifications.SourceNotifications
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts the source slow indexing worker and creates a task for the source.
|
Starts the source slow indexing worker and creates a task for the source.
|
||||||
|
|
||||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def kickoff_with_task(source, opts \\ []) do
|
def kickoff_with_task(source, job_args \\ %{}, job_opts \\ []) do
|
||||||
%{id: source.id}
|
%{id: source.id}
|
||||||
|> MediaCollectionIndexingWorker.new(opts)
|
|> Map.merge(job_args)
|
||||||
|
|> MediaCollectionIndexingWorker.new(job_opts)
|
||||||
|> Tasks.create_job_with_task(source)
|
|> Tasks.create_job_with_task(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -30,8 +33,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
|
The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
|
||||||
the provided source, kicks off downloads for each new MediaItem, and
|
the provided source, kicks off downloads for each new MediaItem, and
|
||||||
reschedules the job to run again in the future. It will ALWAYS index a source
|
reschedules the job to run again in the future. It will ALWAYS index a source
|
||||||
if it's never been indexed before, but rescheduling is determined by the
|
if it's never been indexed before or if `force` is set to `true`, but rescheduling
|
||||||
`index_frequency_minutes` field.
|
is determined by the `index_frequency_minutes` field.
|
||||||
|
|
||||||
README: Re-scheduling here works a little different than you may expect.
|
README: Re-scheduling here works a little different than you may expect.
|
||||||
The reschedule time is relative to the time the job has actually _completed_.
|
The reschedule time is relative to the time the job has actually _completed_.
|
||||||
@@ -71,25 +74,29 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
Returns :ok | {:ok, %Task{}}
|
Returns :ok | {:ok, %Task{}}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
def perform(%Oban.Job{args: %{"id" => source_id} = args}) do
|
||||||
source = Sources.get_source!(source_id)
|
source = Sources.get_source!(source_id)
|
||||||
|
|
||||||
case {source.index_frequency_minutes, source.last_indexed_at} do
|
case {source.index_frequency_minutes, source.last_indexed_at} do
|
||||||
{index_freq, _} when index_freq > 0 ->
|
{index_freq, _} when index_freq > 0 ->
|
||||||
# If the indexing is on a schedule simply run indexing and reschedule
|
# If the indexing is on a schedule simply run indexing and reschedule
|
||||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
perform_indexing_and_notification(source)
|
||||||
maybe_enqueue_fast_indexing_task(source)
|
maybe_enqueue_fast_indexing_task(source)
|
||||||
reschedule_indexing(source)
|
reschedule_indexing(source)
|
||||||
|
|
||||||
{_, nil} ->
|
{_, nil} ->
|
||||||
# If the source has never been indexed, index it once
|
# If the source has never been indexed, index it once
|
||||||
# even if it's not meant to reschedule
|
# even if it's not meant to reschedule
|
||||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
perform_indexing_and_notification(source)
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# If the source HAS been indexed and is not meant to reschedule,
|
# If the source HAS been indexed and is not meant to reschedule,
|
||||||
# perform a no-op
|
# perform a no-op (unless forced)
|
||||||
|
if args["force"] do
|
||||||
|
perform_indexing_and_notification(source)
|
||||||
|
end
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
@@ -97,6 +104,14 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp perform_indexing_and_notification(source) do
|
||||||
|
apprise_server = Settings.get!(:apprise_server)
|
||||||
|
|
||||||
|
SourceNotifications.wrap_new_media_notification(apprise_server, source, fn ->
|
||||||
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp reschedule_indexing(source) do
|
defp reschedule_indexing(source) do
|
||||||
next_run_in = source.index_frequency_minutes * 60
|
next_run_in = source.index_frequency_minutes * 60
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
|
|
||||||
Returns {:ok, %Task{}}.
|
Returns {:ok, %Task{}}.
|
||||||
"""
|
"""
|
||||||
def kickoff_indexing_task(%Source{} = source) do
|
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
|
||||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
||||||
|
|
||||||
MediaCollectionIndexingWorker.kickoff_with_task(source)
|
MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -60,7 +60,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
def index_and_enqueue_download_for_media_items(%Source{} = source) do
|
def index_and_enqueue_download_for_media_items(%Source{} = source) do
|
||||||
# See the method definition below for more info on how file watchers work
|
# See the method definition below for more info on how file watchers work
|
||||||
# (important reading if you're not familiar with it)
|
# (important reading if you're not familiar with it)
|
||||||
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source)
|
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
|
||||||
# Reload because the source may have been updated during the (long-running) indexing process
|
# Reload because the source may have been updated during the (long-running) indexing process
|
||||||
# and important settings like `download_media` may have changed.
|
# and important settings like `download_media` may have changed.
|
||||||
source = Repo.reload!(source)
|
source = Repo.reload!(source)
|
||||||
@@ -84,15 +84,15 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
# lines (ie: you should gracefully fail if you can't parse a line).
|
# lines (ie: you should gracefully fail if you can't parse a line).
|
||||||
#
|
#
|
||||||
# This works in-tandem with the normal (blocking) media indexing behaviour. When
|
# This works in-tandem with the normal (blocking) media indexing behaviour. When
|
||||||
# the `get_media_attributes_for_collection` method completes it'll return the FULL result to
|
# the `setup_file_watcher_and_kickoff_indexing` method completes it'll return the
|
||||||
# the caller for parsing. Ideally, every item in the list will have already
|
# FULL result to the caller for parsing. Ideally, every item in the list will have already
|
||||||
# been processed by the file follower, but if not, the caller handles creation
|
# been processed by the file follower, but if not, the caller handles creation
|
||||||
# of any media items that were missed/initially failed.
|
# of any media items that were missed/initially failed.
|
||||||
#
|
#
|
||||||
# It attempts a graceful shutdown of the file follower after the indexing is done,
|
# It attempts a graceful shutdown of the file follower after the indexing is done,
|
||||||
# but the FileFollowerServer will also stop itself if it doesn't see any activity
|
# but the FileFollowerServer will also stop itself if it doesn't see any activity
|
||||||
# for a sufficiently long time.
|
# for a sufficiently long time.
|
||||||
defp get_media_attributes_for_collection_and_setup_file_watcher(source) do
|
defp setup_file_watcher_and_kickoff_indexing(source) do
|
||||||
{:ok, pid} = FileFollowerServer.start_link()
|
{:ok, pid} = FileFollowerServer.start_link()
|
||||||
|
|
||||||
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
|
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
last_indexed_at
|
last_indexed_at
|
||||||
original_url
|
original_url
|
||||||
download_cutoff_date
|
download_cutoff_date
|
||||||
|
retention_period_days
|
||||||
title_filter_regex
|
title_filter_regex
|
||||||
media_profile_id
|
media_profile_id
|
||||||
)a
|
)a
|
||||||
@@ -72,6 +73,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
field :last_indexed_at, :utc_datetime
|
field :last_indexed_at, :utc_datetime
|
||||||
# Only download media items that were published after this date
|
# Only download media items that were published after this date
|
||||||
field :download_cutoff_date, :date
|
field :download_cutoff_date, :date
|
||||||
|
field :retention_period_days, :integer
|
||||||
field :original_url, :string
|
field :original_url, :string
|
||||||
field :title_filter_regex, :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(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|
||||||
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
||||||
|> validate_required(required_fields)
|
|> validate_required(required_fields)
|
||||||
|
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|
||||||
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|
||||||
|
|> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ defmodule Pinchflat.Sources do
|
|||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
alias Pinchflat.YtDlp.MediaCollection
|
alias Pinchflat.YtDlp.MediaCollection
|
||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
@@ -112,8 +113,9 @@ defmodule Pinchflat.Sources do
|
|||||||
delete_files = Keyword.get(opts, :delete_files, false)
|
delete_files = Keyword.get(opts, :delete_files, false)
|
||||||
Tasks.delete_tasks_for(source)
|
Tasks.delete_tasks_for(source)
|
||||||
|
|
||||||
source
|
MediaQuery.new()
|
||||||
|> Media.list_media_items_for()
|
|> MediaQuery.for_source(source)
|
||||||
|
|> Repo.all()
|
||||||
|> Enum.each(fn media_item ->
|
|> Enum.each(fn media_item ->
|
||||||
Media.delete_media_item(media_item, delete_files: delete_files)
|
Media.delete_media_item(media_item, delete_files: delete_files)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
defmodule Pinchflat.Sources.SourcesQuery do
|
||||||
|
@moduledoc """
|
||||||
|
Query helpers for the Sources context.
|
||||||
|
|
||||||
|
These methods are made to be one-ish liners used
|
||||||
|
to compose queries. Each method should strive to do
|
||||||
|
_one_ thing. These don't need to be tested as
|
||||||
|
they are just building blocks for other functionality
|
||||||
|
which, itself, will be tested.
|
||||||
|
"""
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
|
alias Pinchflat.Sources.Source
|
||||||
|
|
||||||
|
# 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
|
||||||
|
Source
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_media_profile(query, media_profile) do
|
||||||
|
where(query, [s], s.media_profile_id == ^media_profile.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
defmodule Pinchflat.Utils.CliUtils do
|
||||||
|
@moduledoc """
|
||||||
|
Utility methods for working with CLI executables
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.Utils.StringUtils
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses a list of command options into a list of strings suitable for passing to
|
||||||
|
`System.cmd/3`.
|
||||||
|
|
||||||
|
We want to satisfy the following behaviours:
|
||||||
|
1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
|
||||||
|
2. If the key is a string, assume we want it as-is and don't convert it
|
||||||
|
3. If the key is accompanied by a value, append the value to the list
|
||||||
|
4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
|
||||||
|
|
||||||
|
Returns [binary()]
|
||||||
|
"""
|
||||||
|
def parse_options(command_opts) do
|
||||||
|
command_opts
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.reduce([], &parse_option/2)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_option({k, v}, acc) when is_atom(k) do
|
||||||
|
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
|
||||||
|
|
||||||
|
parse_option({"--#{stringified_key}", v}, acc)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_option({k, v}, acc) when is_binary(k) do
|
||||||
|
acc ++ [k, to_string(v)]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_option(arg, acc) when is_atom(arg) do
|
||||||
|
stringified_arg =
|
||||||
|
arg
|
||||||
|
|> Atom.to_string()
|
||||||
|
|> StringUtils.to_kebab_case()
|
||||||
|
|
||||||
|
parse_option("--#{stringified_arg}", acc)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_option(arg, acc) when is_binary(arg) do
|
||||||
|
acc ++ [arg]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
defmodule Pinchflat.Utils.XmlUtils do
|
||||||
|
@moduledoc """
|
||||||
|
Utility methods for working with XML documents
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Escapes invalid XML characters in a string
|
||||||
|
|
||||||
|
Returns binary()
|
||||||
|
"""
|
||||||
|
def safe(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> Phoenix.HTML.html_escape()
|
||||||
|
|> Phoenix.HTML.safe_to_string()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,11 +5,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Utils.StringUtils
|
alias Pinchflat.Utils.CliUtils
|
||||||
|
alias Pinchflat.YtDlp.YtDlpCommandRunner
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils
|
alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils
|
||||||
alias Pinchflat.YtDlp.BackendCommandRunner
|
|
||||||
|
|
||||||
@behaviour BackendCommandRunner
|
@behaviour YtDlpCommandRunner
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Runs a yt-dlp command and returns the string output. Saves the output to
|
Runs a yt-dlp command and returns the string output. Saves the output to
|
||||||
@@ -23,16 +23,16 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
|
|
||||||
Returns {:ok, binary()} | {:error, output, status}.
|
Returns {:ok, binary()} | {:error, output, status}.
|
||||||
"""
|
"""
|
||||||
@impl BackendCommandRunner
|
@impl YtDlpCommandRunner
|
||||||
def run(url, command_opts, output_template, addl_opts \\ []) do
|
def run(url, command_opts, output_template, addl_opts \\ []) do
|
||||||
# This approach lets us mock the command for testing
|
# This approach lets us mock the command for testing
|
||||||
command = backend_executable()
|
command = backend_executable()
|
||||||
# These must stay in exactly this order, hence why I'm giving it its own variable.
|
# These must stay in exactly this order, hence why I'm giving it its own variable.
|
||||||
# Also, can't use RAM file since yt-dlp needs a concrete filepath.
|
# Also, can't use RAM file since yt-dlp needs a concrete filepath.
|
||||||
output_filepath = Keyword.get(addl_opts, :output_filepath, FSUtils.generate_metadata_tmpfile(:json))
|
output_filepath = generate_output_filepath(addl_opts)
|
||||||
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
|
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
|
||||||
cookie_opts = build_cookie_options()
|
cookie_opts = build_cookie_options()
|
||||||
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts ++ cookie_opts)
|
formatted_command_opts = [url] ++ CliUtils.parse_options(command_opts ++ print_to_file_opts ++ cookie_opts)
|
||||||
|
|
||||||
Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
|
Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
|
||||||
|
|
||||||
@@ -48,6 +48,31 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the version of yt-dlp as a string
|
||||||
|
|
||||||
|
Returns {:ok, binary()} | {:error, binary()}
|
||||||
|
"""
|
||||||
|
@impl YtDlpCommandRunner
|
||||||
|
def version do
|
||||||
|
command = backend_executable()
|
||||||
|
|
||||||
|
case System.cmd(command, ["--version"]) do
|
||||||
|
{output, 0} ->
|
||||||
|
{:ok, String.trim(output)}
|
||||||
|
|
||||||
|
{output, _} ->
|
||||||
|
{:error, output}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_output_filepath(addl_opts) do
|
||||||
|
case Keyword.get(addl_opts, :output_filepath) do
|
||||||
|
nil -> FSUtils.generate_metadata_tmpfile(:json)
|
||||||
|
path -> path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp build_cookie_options do
|
defp build_cookie_options do
|
||||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
cookie_file = Path.join(base_dir, "cookies.txt")
|
cookie_file = Path.join(base_dir, "cookies.txt")
|
||||||
@@ -61,36 +86,6 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# We want to satisfy the following behaviours:
|
|
||||||
#
|
|
||||||
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
|
|
||||||
# 2. If the key is a string, assume we want it as-is and don't convert it
|
|
||||||
# 3. If the key is accompanied by a value, append the value to the list
|
|
||||||
# 4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
|
|
||||||
defp parse_options(command_opts) do
|
|
||||||
Enum.reduce(command_opts, [], &parse_option/2)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp parse_option({k, v}, acc) when is_atom(k) do
|
|
||||||
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
|
|
||||||
|
|
||||||
parse_option({"--#{stringified_key}", v}, acc)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp parse_option({k, v}, acc) when is_binary(k) do
|
|
||||||
acc ++ [k, to_string(v)]
|
|
||||||
end
|
|
||||||
|
|
||||||
defp parse_option(arg, acc) when is_atom(arg) do
|
|
||||||
stringified_arg = StringUtils.to_kebab_case(Atom.to_string(arg))
|
|
||||||
|
|
||||||
parse_option("--#{stringified_arg}", acc)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp parse_option(arg, acc) when is_binary(arg) do
|
|
||||||
acc ++ [arg]
|
|
||||||
end
|
|
||||||
|
|
||||||
defp backend_executable do
|
defp backend_executable do
|
||||||
Application.get_env(:pinchflat, :yt_dlp_executable)
|
Application.get_env(:pinchflat, :yt_dlp_executable)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:original_url,
|
:original_url,
|
||||||
:livestream,
|
:livestream,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:upload_date
|
:upload_date,
|
||||||
|
:duration_seconds
|
||||||
]
|
]
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
@@ -20,7 +21,8 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:original_url,
|
:original_url,
|
||||||
:livestream,
|
:livestream,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:upload_date
|
:upload_date,
|
||||||
|
:duration_seconds
|
||||||
]
|
]
|
||||||
|
|
||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
@@ -33,10 +35,10 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
|
|
||||||
Returns {:ok, map()} | {:error, any, ...}.
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def download(url, command_opts \\ []) do
|
def download(url, command_opts \\ [], addl_opts \\ []) do
|
||||||
opts = [:no_simulate] ++ command_opts
|
opts = [:no_simulate] ++ command_opts
|
||||||
|
|
||||||
with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j"),
|
with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j", addl_opts),
|
||||||
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
{:ok, parsed_json}
|
{:ok, parsed_json}
|
||||||
else
|
else
|
||||||
@@ -86,6 +88,7 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
description: response["description"],
|
description: response["description"],
|
||||||
original_url: response["webpage_url"],
|
original_url: response["webpage_url"],
|
||||||
livestream: response["was_live"],
|
livestream: response["was_live"],
|
||||||
|
duration_seconds: response["duration"] && round(response["duration"]),
|
||||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
short_form_content: response["webpage_url"] && short_form_content?(response),
|
||||||
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
defmodule Pinchflat.YtDlp.BackendCommandRunner do
|
defmodule Pinchflat.YtDlp.YtDlpCommandRunner do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
A behaviour for running CLI commands against a downloader backend (yt-dlp).
|
A behaviour for running CLI commands against a downloader backend (yt-dlp).
|
||||||
|
|
||||||
@@ -8,4 +8,5 @@ defmodule Pinchflat.YtDlp.BackendCommandRunner do
|
|||||||
|
|
||||||
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
|
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
|
||||||
@callback run(binary(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
|
@callback run(binary(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
|
||||||
|
@callback version() :: {:ok, binary()} | {:error, binary()}
|
||||||
end
|
end
|
||||||
@@ -247,11 +247,12 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
attr :label_suffix, :string, default: nil
|
attr :label_suffix, :string, default: nil
|
||||||
attr :value, :any
|
attr :value, :any
|
||||||
attr :help, :string, default: nil
|
attr :help, :string, default: nil
|
||||||
|
attr :html_help, :boolean, default: false
|
||||||
|
|
||||||
attr :type, :string,
|
attr :type, :string,
|
||||||
default: "text",
|
default: "text",
|
||||||
values: ~w(checkbox color date datetime-local email file hidden month number password
|
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]"
|
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||||
|
|
||||||
@@ -298,7 +299,34 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
<%= @label %>
|
<%= @label %>
|
||||||
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
||||||
</label>
|
</label>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -329,7 +357,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,7 +388,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
</select>
|
</select>
|
||||||
<%= render_slot(@inner_block) %>
|
<%= render_slot(@inner_block) %>
|
||||||
</div>
|
</div>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -384,7 +412,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
]}
|
]}
|
||||||
{@rest}
|
{@rest}
|
||||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -411,7 +439,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
]}
|
]}
|
||||||
{@rest}
|
{@rest}
|
||||||
/>
|
/>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -601,6 +629,10 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
_ ->
|
_ ->
|
||||||
true
|
true
|
||||||
end)
|
end)
|
||||||
|
|> Enum.map(fn
|
||||||
|
{k, v} when is_list(v) -> {k, Enum.join(v, ", ")}
|
||||||
|
rest -> rest
|
||||||
|
end)
|
||||||
|
|
||||||
assigns = assign(assigns, iterable_attributes: attrs)
|
assigns = assign(assigns, iterable_attributes: attrs)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
def tabbed_layout(assigns) do
|
def tabbed_layout(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<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"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
<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
|
<a
|
||||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
:for={{tab, idx} <- Enum.with_index(@tab)}
|
||||||
href="#"
|
href="#"
|
||||||
@click.prevent={"openTab = #{idx}"}
|
@click.prevent={"openTab = setTabIndex(#{idx})"}
|
||||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
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"
|
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
|
||||||
>
|
>
|
||||||
@@ -33,7 +34,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
<%= render_slot(@tab_append) %>
|
<%= render_slot(@tab_append) %>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="mt-4">
|
<div class="mt-4 min-h-60">
|
||||||
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
||||||
<%= render_slot(tab) %>
|
<%= render_slot(tab) %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
|
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
|
||||||
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
|
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
|
||||||
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
|
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
|
||||||
|
<.sidebar_item icon="hero-cog-6-tooth" text="Settings" href={~p"/settings"} />
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -38,12 +39,7 @@
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://github.com/kieraneglin/pinchflat/wiki"
|
href="https://github.com/kieraneglin/pinchflat/wiki"
|
||||||
/>
|
/>
|
||||||
<.sidebar_item
|
<.sidebar_item icon="hero-cog" text="Github" target="_blank" href="https://github.com/kieraneglin/pinchflat" />
|
||||||
icon="hero-code-bracket"
|
|
||||||
text="Github"
|
|
||||||
target="_blank"
|
|
||||||
href="https://github.com/kieraneglin/pinchflat"
|
|
||||||
/>
|
|
||||||
<li>
|
<li>
|
||||||
<span
|
<span
|
||||||
class={[
|
class={[
|
||||||
@@ -58,8 +54,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="group relative flex items-center gap-2.5 px-4 py-2 text-sm">
|
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
||||||
v<%= Application.spec(:pinchflat)[:vsn] %>
|
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>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ defmodule Pinchflat.UpgradeButtonLive do
|
|||||||
|> String.downcase()
|
|> String.downcase()
|
||||||
|
|
||||||
if normalized_text == "got it!" do
|
if normalized_text == "got it!" do
|
||||||
Settings.set!(:pro_enabled, true)
|
Settings.set(pro_enabled: true)
|
||||||
|
|
||||||
{:noreply, update(socket, :button_disabled, fn _ -> false end)}
|
{:noreply, update(socket, :button_disabled, fn _ -> false end)}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
media_item =
|
media_item =
|
||||||
@@ -16,23 +17,46 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||||||
render(conn, :show, media_item: media_item)
|
render(conn, :show, media_item: media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete(conn, %{"id" => id} = params) do
|
def edit(conn, %{"id" => id}) do
|
||||||
delete_files = Map.get(params, "delete_files", false)
|
|
||||||
media_item = Media.get_media_item!(id)
|
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 =
|
render(conn, :edit, media_item: media_item, changeset: changeset)
|
||||||
if delete_files do
|
end
|
||||||
"Record and files deleted successfully."
|
|
||||||
else
|
def update(conn, %{"id" => id, "media_item" => params}) do
|
||||||
"Record deleted successfully. Files were not deleted."
|
media_item = Media.get_media_item!(id)
|
||||||
end
|
|
||||||
|
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
|
conn
|
||||||
|> put_flash(:info, flash_message)
|
|> put_flash(:info, "Files deleted successfully.")
|
||||||
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def force_download(conn, %{"media_item_id" => id}) do
|
||||||
|
media_item = Media.get_media_item!(id)
|
||||||
|
{:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, %{force: true})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Download task enqueued.")
|
||||||
|
|> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
||||||
|
end
|
||||||
|
|
||||||
# See here for details on streaming files and range requests:
|
# See here for details on streaming files and range requests:
|
||||||
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
|
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
|
|||||||
|
|
||||||
embed_templates "media_item_html/*"
|
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
|
def media_file_exists?(media_item) do
|
||||||
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
|
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}/force_download"}
|
||||||
|
method="post"
|
||||||
|
data-confirm="Are you sure you force a download of this media?"
|
||||||
|
>
|
||||||
|
Force Download
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
|
||||||
|
method="delete"
|
||||||
|
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>
|
||||||
@@ -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 automatic download. Download can still be manually forced"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.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,25 +4,23 @@
|
|||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
<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>
|
</h2>
|
||||||
</div>
|
</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>
|
||||||
<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="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">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
<:tab_append>
|
<:tab_append>
|
||||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
<.actions_dropdown media_item={@media_item} />
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
|
|
||||||
>
|
|
||||||
Delete Files
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
</.button_dropdown>
|
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
@@ -32,12 +30,13 @@
|
|||||||
<.media_preview media_item={@media_item} />
|
<.media_preview media_item={@media_item} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<h2 class="font-bold text-2xl"><%= @media_item.title %></h2>
|
||||||
<h3 class="font-bold text-xl">Attributes</h3>
|
<h3 class="font-bold text-xl">Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Source:</strong>
|
<strong>Source:</strong>
|
||||||
<.inline_link href={~p"/sources/#{@media_item.source_id}"}>
|
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
||||||
<%= @media_item.source.custom_name %>
|
<%= @media_item.source.custom_name %>
|
||||||
</.inline_link>
|
</.subtle_link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.Profiles
|
||||||
|
alias Pinchflat.Sources.SourcesQuery
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
media_profiles = Profiles.list_media_profiles()
|
media_profiles =
|
||||||
|
MediaProfile
|
||||||
|
|> order_by(asc: :name)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
render(conn, :index, media_profiles: media_profiles)
|
render(conn, :index, media_profiles: media_profiles)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,12 +39,15 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
media_profile =
|
media_profile = Profiles.get_media_profile!(id)
|
||||||
id
|
|
||||||
|> Profiles.get_media_profile!()
|
|
||||||
|> Repo.preload(:sources)
|
|
||||||
|
|
||||||
render(conn, :show, media_profile: media_profile)
|
sources =
|
||||||
|
SourcesQuery.new()
|
||||||
|
|> SourcesQuery.for_media_profile(media_profile)
|
||||||
|
|> order_by(asc: :custom_name)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
render(conn, :show, media_profile: media_profile, sources: sources)
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit(conn, %{"id" => id}) do
|
def edit(conn, %{"id" => id}) do
|
||||||
|
|||||||
@@ -32,6 +32,26 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
]
|
]
|
||||||
end
|
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
|
def custom_output_template_options do
|
||||||
%{
|
%{
|
||||||
upload_day: nil,
|
upload_day: nil,
|
||||||
@@ -39,6 +59,9 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
upload_year: nil,
|
upload_year: nil,
|
||||||
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD",
|
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD",
|
||||||
source_custom_name: "the name of the sources that use this profile",
|
source_custom_name: "the name of the sources that use this profile",
|
||||||
|
source_collection_id: "the YouTube ID of the sources that use this profile",
|
||||||
|
source_collection_name:
|
||||||
|
"the YouTube name of the sources that use this profile (often the same as source_custom_name)",
|
||||||
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
|
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
|
||||||
artist_name: "the name of the artist with fallbacks to other uploader fields"
|
artist_name: "the name of the artist with fallbacks to other uploader fields"
|
||||||
}
|
}
|
||||||
|
|||||||
+30
@@ -238,6 +238,36 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -50,9 +50,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Sources">
|
<:tab title="Sources">
|
||||||
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
|
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||||
<:col :let={source} label="Name">
|
<: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>
|
||||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||||
<:col :let={source} label="Should Download?">
|
<:col :let={source} label="Should Download?">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ defmodule PinchflatWeb.Pages.PageController do
|
|||||||
done_onboarding = params["onboarding"] == "0"
|
done_onboarding = params["onboarding"] == "0"
|
||||||
force_onboarding = params["onboarding"] == "1"
|
force_onboarding = params["onboarding"] == "1"
|
||||||
|
|
||||||
if done_onboarding, do: Settings.set!(:onboarding, false)
|
if done_onboarding, do: Settings.set(onboarding: false)
|
||||||
|
|
||||||
if force_onboarding || Settings.get!(:onboarding) do
|
if force_onboarding || Settings.get!(:onboarding) do
|
||||||
render_onboarding_page(conn)
|
render_onboarding_page(conn)
|
||||||
@@ -30,7 +30,7 @@ defmodule PinchflatWeb.Pages.PageController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp render_onboarding_page(conn) do
|
defp render_onboarding_page(conn) do
|
||||||
Settings.set!(:onboarding, true)
|
Settings.set(onboarding: true)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> render(:onboarding_checklist,
|
|> render(:onboarding_checklist,
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<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="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>
|
<span class="text-md font-medium">Media Profile(s)</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @media_profile_count %>
|
<%= @media_profile_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<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>
|
<span class="text-md font-medium">Source(s)</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @source_count %>
|
<%= @source_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<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>
|
<span class="text-md font-medium">Media Item(s)</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @media_item_count %>
|
<%= @media_item_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-strokedark">I know this page isn't super useful yet, but give it time :)</span>
|
<span class="text-strokedark">I know this page isn't super useful yet, but give it time :)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
|||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Podcasts.RssFeedBuilder
|
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
@@ -20,10 +20,15 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
|||||||
|
|
||||||
def feed_image(conn, %{"uuid" => uuid}) do
|
def feed_image(conn, %{"uuid" => uuid}) do
|
||||||
source = Repo.get_by!(Source, uuid: uuid)
|
source = Repo.get_by!(Source, uuid: uuid)
|
||||||
# This provides a fallback image if the source has none.
|
|
||||||
# We only need one since we're using the internal metadata image which
|
# This is used to fetch a fallback cover image
|
||||||
# we know exists.
|
# if the source doesn't have any usable images
|
||||||
media_items = Media.list_downloaded_media_items_for(source, limit: 1)
|
media_items =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_filepath()
|
||||||
|
|> Repo.maybe_limit(1)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
case PodcastHelpers.select_cover_image(source, media_items) do
|
case PodcastHelpers.select_cover_image(source, media_items) do
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
defmodule PinchflatWeb.Settings.SettingController do
|
||||||
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
|
alias Pinchflat.Settings
|
||||||
|
|
||||||
|
def show(conn, _params) do
|
||||||
|
setting = Settings.record()
|
||||||
|
changeset = Settings.change_setting(setting)
|
||||||
|
|
||||||
|
render(conn, "show.html", changeset: changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update(conn, %{"setting" => setting_params}) do
|
||||||
|
setting = Settings.record()
|
||||||
|
|
||||||
|
case Settings.update_setting(setting, setting_params) do
|
||||||
|
{:ok, _} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Settings updated successfully.")
|
||||||
|
|> redirect(to: ~p"/settings")
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
render(conn, "show.html", changeset: changeset)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
defmodule PinchflatWeb.Settings.SettingHTML do
|
||||||
|
use PinchflatWeb, :html
|
||||||
|
|
||||||
|
embed_templates "setting_html/*"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a setting form.
|
||||||
|
"""
|
||||||
|
attr :changeset, Ecto.Changeset, required: true
|
||||||
|
attr :action, :string, required: true
|
||||||
|
|
||||||
|
def setting_form(assigns)
|
||||||
|
|
||||||
|
def apprise_server_help do
|
||||||
|
url = "https://github.com/caronc/apprise/wiki/URLBasics"
|
||||||
|
classes = "underline decoration-bodydark decoration-1 hover:decoration-white"
|
||||||
|
|
||||||
|
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{classes}" target="_blank">Apprise docs</a> for more information)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<.simple_form :let={f} for={@changeset} action={@action}>
|
||||||
|
<.error :if={@changeset.action}>
|
||||||
|
Oops, something went wrong! Please check the errors below.
|
||||||
|
</.error>
|
||||||
|
|
||||||
|
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||||
|
Notification Settings
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={f[:apprise_server]}
|
||||||
|
type="text"
|
||||||
|
label="Apprise Server"
|
||||||
|
help={apprise_server_help()}
|
||||||
|
html_help={true}
|
||||||
|
inputclass="font-mono text-sm"
|
||||||
|
placeholder="https://discordapp.com/api/webhooks/{WebhookID}/{WebhookToken}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Settings</.button>
|
||||||
|
</.simple_form>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||||
|
Settings
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||||
|
<div class="max-w-full overflow-x-auto">
|
||||||
|
<.setting_form changeset={@changeset} action={~p"/settings"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,15 +4,21 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.MediaQuery
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaQuery
|
||||||
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
sources = Repo.preload(Sources.list_sources(), :media_profile)
|
sources =
|
||||||
|
Source
|
||||||
|
|> order_by(asc: :custom_name)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Repo.preload(:media_profile)
|
||||||
|
|
||||||
render(conn, :index, sources: sources)
|
render(conn, :index, sources: sources)
|
||||||
end
|
end
|
||||||
@@ -49,9 +55,26 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
source = Repo.preload(Sources.get_source!(id), :media_profile)
|
source = Repo.preload(Sources.get_source!(id), :media_profile)
|
||||||
|
|
||||||
pending_tasks = Repo.preload(Tasks.list_pending_tasks_for(source), :job)
|
pending_tasks =
|
||||||
pending_media = Media.list_pending_media_items_for(source, limit: 100)
|
source
|
||||||
downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100)
|
|> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable])
|
||||||
|
|> Repo.preload(:job)
|
||||||
|
|
||||||
|
pending_media =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_pending_download()
|
||||||
|
|> order_by(desc: :id)
|
||||||
|
|> limit(100)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
downloaded_media =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_filepath()
|
||||||
|
|> order_by(desc: :id)
|
||||||
|
|> limit(100)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
render(conn, :show,
|
render(conn, :show,
|
||||||
source: source,
|
source: source,
|
||||||
@@ -104,18 +127,34 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
|> redirect(to: ~p"/sources")
|
|> redirect(to: ~p"/sources")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def force_download(conn, %{"source_id" => id}) do
|
||||||
|
source = Sources.get_source!(id)
|
||||||
|
DownloadingHelpers.enqueue_pending_download_tasks(source)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Forced download of pending media items.")
|
||||||
|
|> redirect(to: ~p"/sources/#{source}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def force_index(conn, %{"source_id" => id}) do
|
||||||
|
source = Sources.get_source!(id)
|
||||||
|
SlowIndexingHelpers.kickoff_indexing_task(source, %{force: true})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Index enqueued.")
|
||||||
|
|> redirect(to: ~p"/sources/#{source}")
|
||||||
|
end
|
||||||
|
|
||||||
defp media_profiles do
|
defp media_profiles do
|
||||||
Profiles.list_media_profiles()
|
MediaProfile
|
||||||
|
|> order_by(asc: :name)
|
||||||
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
# NOTE: should move this out of the controller
|
|
||||||
# once I finally add some query fragment layer
|
|
||||||
defp total_downloaded_for(source) do
|
defp total_downloaded_for(source) do
|
||||||
from(
|
MediaQuery.new()
|
||||||
m in MediaItem,
|
|> MediaQuery.for_source(source)
|
||||||
where: m.source_id == ^source.id,
|
|> MediaQuery.with_media_filepath()
|
||||||
where: not is_nil(m.media_filepath)
|
|
||||||
)
|
|
||||||
|> Repo.aggregate(:count, :id)
|
|> Repo.aggregate(:count, :id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<span
|
||||||
|
x-data="{ copied: false }"
|
||||||
|
x-on:click={"
|
||||||
|
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
|
||||||
|
copied = true
|
||||||
|
setTimeout(() => copied = false, 4000)
|
||||||
|
"}
|
||||||
|
>
|
||||||
|
Copy RSS Feed
|
||||||
|
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
|
</span>
|
||||||
|
</:option>
|
||||||
|
<:option :if={@source.download_media}>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}/force_download"}
|
||||||
|
method="post"
|
||||||
|
data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed."
|
||||||
|
>
|
||||||
|
Force Download
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}/force_index"}
|
||||||
|
method="post"
|
||||||
|
data-confirm="Are you sure you want to force an index of this source? This isn't normally needed."
|
||||||
|
>
|
||||||
|
Force Index
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
||||||
|
>
|
||||||
|
Delete Source
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}?delete_files=true"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
||||||
|
class="mt-5 md:mt-0"
|
||||||
|
>
|
||||||
|
Delete Source + Files
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
</.button_dropdown>
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
<:col :let={source} label="Should Download?">
|
<:col :let={source} label="Should Download?">
|
||||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||||
</:col>
|
</: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">
|
<:col :let={source} label="Media Profile">
|
||||||
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
||||||
<%= source.media_profile.name %>
|
<%= source.media_profile.name %>
|
||||||
|
|||||||
@@ -20,43 +20,7 @@
|
|||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
<:tab_append>
|
<:tab_append>
|
||||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
<.actions_dropdown source={@source} conn={@conn} />
|
||||||
<:option>
|
|
||||||
<span
|
|
||||||
x-data="{ copied: false }"
|
|
||||||
x-on:click={"
|
|
||||||
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
|
|
||||||
copied = true
|
|
||||||
setTimeout(() => copied = false, 4000)
|
|
||||||
"}
|
|
||||||
>
|
|
||||||
Copy RSS Feed
|
|
||||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
|
||||||
</span>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<div class="h-px w-full bg-bodydark2"></div>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@source}"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
|
||||||
>
|
|
||||||
Delete Source
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@source}?delete_files=true"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
|
||||||
class="mt-5 md:mt-0"
|
|
||||||
>
|
|
||||||
Delete Source + Files
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
</.button_dropdown>
|
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
@@ -64,9 +28,9 @@
|
|||||||
<h3 class="font-bold text-lg">Attributes</h3>
|
<h3 class="font-bold text-lg">Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Media Profile:</strong>
|
<strong>Media Profile:</strong>
|
||||||
<.inline_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
||||||
<%= @source.media_profile.name %>
|
<%= @source.media_profile.name %>
|
||||||
</.inline_link>
|
</.subtle_link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||||
@@ -77,10 +41,17 @@
|
|||||||
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4>
|
<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">
|
<.table rows={@pending_media} table_class="text-black dark:text-white">
|
||||||
<:col :let={media_item} label="Title">
|
<: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>
|
||||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
<: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>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -92,10 +63,17 @@
|
|||||||
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items (<%= @total_downloaded %> total)</h4>
|
<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">
|
<.table rows={@downloaded_media} table_class="text-black dark:text-white">
|
||||||
<:col :let={media_item} label="Title">
|
<: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>
|
||||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
<: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>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
type="toggle"
|
type="toggle"
|
||||||
label="Use Fast Indexing"
|
label="Use Fast Indexing"
|
||||||
label_suffix="(pro)"
|
label_suffix="(pro)"
|
||||||
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
|
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. Does not work with private playlists. See below for more info"
|
||||||
x-init="
|
x-init="
|
||||||
// `enabled` is the data attribute that the toggle uses internally
|
// `enabled` is the data attribute that the toggle uses internally
|
||||||
fastIndexingEnabled = enabled
|
fastIndexingEnabled = enabled
|
||||||
@@ -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"
|
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">
|
<section x-show="advancedMode">
|
||||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||||
Advanced Options
|
Advanced Options
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ defmodule PinchflatWeb.Endpoint do
|
|||||||
plug Plug.Static,
|
plug Plug.Static,
|
||||||
at: "/",
|
at: "/",
|
||||||
from: :pinchflat,
|
from: :pinchflat,
|
||||||
gzip: false,
|
gzip: Mix.env() == :prod,
|
||||||
only: PinchflatWeb.static_paths()
|
only: PinchflatWeb.static_paths()
|
||||||
|
|
||||||
# Code reloading can be explicitly enabled under the
|
# Code reloading can be explicitly enabled under the
|
||||||
|
|||||||
+24
-14
@@ -1,5 +1,6 @@
|
|||||||
defmodule PinchflatWeb.Router do
|
defmodule PinchflatWeb.Router do
|
||||||
use PinchflatWeb, :router
|
use PinchflatWeb, :router
|
||||||
|
import Phoenix.LiveDashboard.Router
|
||||||
|
|
||||||
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
||||||
# the extension from the path
|
# the extension from the path
|
||||||
@@ -11,6 +12,7 @@ defmodule PinchflatWeb.Router do
|
|||||||
plug :put_root_layout, html: {PinchflatWeb.Layouts, :root}
|
plug :put_root_layout, html: {PinchflatWeb.Layouts, :root}
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
|
plug :allow_iframe_embed
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
@@ -28,9 +30,15 @@ defmodule PinchflatWeb.Router do
|
|||||||
|
|
||||||
resources "/media_profiles", MediaProfiles.MediaProfileController
|
resources "/media_profiles", MediaProfiles.MediaProfileController
|
||||||
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
||||||
|
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
|
||||||
|
|
||||||
resources "/sources", Sources.SourceController do
|
resources "/sources", Sources.SourceController do
|
||||||
resources "/media", MediaItems.MediaItemController, only: [:show, :delete]
|
post "/force_download", Sources.SourceController, :force_download
|
||||||
|
post "/force_index", Sources.SourceController, :force_index
|
||||||
|
|
||||||
|
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] do
|
||||||
|
post "/force_download", MediaItems.MediaItemController, :force_download
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -45,21 +53,19 @@ defmodule PinchflatWeb.Router do
|
|||||||
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
||||||
end
|
end
|
||||||
|
|
||||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
# No auth or CSRF protection for the health check endpoint
|
||||||
if Application.compile_env(:pinchflat, :dev_routes) do
|
scope "/", PinchflatWeb do
|
||||||
# If you want to use the LiveDashboard in production, you should put
|
pipe_through :api
|
||||||
# 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
|
|
||||||
|
|
||||||
scope "/dev" do
|
get "/healthcheck", HealthController, :check
|
||||||
pipe_through :browser
|
end
|
||||||
|
|
||||||
live_dashboard "/dashboard", metrics: PinchflatWeb.Telemetry
|
scope "/dev" do
|
||||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
pipe_through :browser
|
||||||
end
|
|
||||||
|
live_dashboard "/dashboard",
|
||||||
|
metrics: PinchflatWeb.Telemetry,
|
||||||
|
ecto_repos: [Pinchflat.Repo]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_basic_auth(conn, opts) do
|
defp maybe_basic_auth(conn, opts) do
|
||||||
@@ -84,4 +90,8 @@ defmodule PinchflatWeb.Router do
|
|||||||
defp credential_set?(credential) do
|
defp credential_set?(credential) do
|
||||||
credential && credential != ""
|
credential && credential != ""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp allow_iframe_embed(conn, _opts) do
|
||||||
|
delete_resp_header(conn, "x-frame-options")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "0.1.4",
|
version: "0.1.10",
|
||||||
elixir: "~> 1.16",
|
elixir: "~> 1.16",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
@@ -33,7 +33,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def application do
|
def application do
|
||||||
[
|
[
|
||||||
mod: {Pinchflat.Application, []},
|
mod: {Pinchflat.Application, []},
|
||||||
extra_applications: [:logger, :runtime_tools, :inets]
|
extra_applications: [:logger, :runtime_tools, :inets, :os_mon]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
{:phoenix_ecto, "~> 4.4"},
|
{:phoenix_ecto, "~> 4.4"},
|
||||||
{:ecto_sql, "~> 3.10"},
|
{:ecto_sql, "~> 3.10"},
|
||||||
{:ecto_sqlite3, ">= 0.0.0"},
|
{:ecto_sqlite3, ">= 0.0.0"},
|
||||||
|
{:ecto_sqlite3_extras, "~> 1.2.0"},
|
||||||
{:phoenix_html, "~> 3.3"},
|
{:phoenix_html, "~> 3.3"},
|
||||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||||
{:phoenix_live_view, "~> 0.20.1"},
|
{: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": {: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_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": {: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"},
|
"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"},
|
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
|
||||||
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||||
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
|
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
|
||||||
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
|
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
|
||||||
|
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"},
|
||||||
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
|
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
|
||||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
"telemetry": {:hex, :telemetry, "1.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"},
|
"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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.RenameSettingsTable do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
rename table(:settings), to: table(:settings_backup)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.CreateNewSettings do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
create table(:settings) do
|
||||||
|
add :onboarding, :boolean, default: true, null: false
|
||||||
|
add :pro_enabled, :boolean, default: false, null: false
|
||||||
|
add :yt_dlp_version, :string
|
||||||
|
end
|
||||||
|
|
||||||
|
# Make an initial record because this will be the only one ever inserted
|
||||||
|
execute "INSERT INTO settings (onboarding, pro_enabled, yt_dlp_version) VALUES (true, false, NULL)"
|
||||||
|
|
||||||
|
# Set the value of onboarding to the previous version set in `settings_backup`
|
||||||
|
execute """
|
||||||
|
UPDATE settings
|
||||||
|
SET onboarding = COALESCE((SELECT value = 'true' FROM settings_backup WHERE name = 'onboarding'), true)
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute """
|
||||||
|
UPDATE settings
|
||||||
|
SET pro_enabled = COALESCE((SELECT value = 'true' FROM settings_backup WHERE name = 'pro_enabled'), false)
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop table(:settings)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddAppriseServersToSettings do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :apprise_server, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddAppriseVersionToSettings do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :apprise_version, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -79,7 +79,7 @@ ARG PORT=8945
|
|||||||
|
|
||||||
RUN apt-get update -y
|
RUN apt-get update -y
|
||||||
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
||||||
ffmpeg curl git openssh-client nano
|
ffmpeg curl git openssh-client nano python3 python3-pip
|
||||||
RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
|
RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||||
|
|
||||||
# Download and update YT-DLP
|
# Download and update YT-DLP
|
||||||
@@ -87,19 +87,19 @@ RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o
|
|||||||
RUN chmod a+rx /usr/local/bin/yt-dlp
|
RUN chmod a+rx /usr/local/bin/yt-dlp
|
||||||
RUN yt-dlp -U
|
RUN yt-dlp -U
|
||||||
|
|
||||||
|
# Download Apprise
|
||||||
|
RUN python3 -m pip install -U apprise --break-system-packages
|
||||||
|
|
||||||
# Set the locale
|
# Set the locale
|
||||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||||
|
|
||||||
ENV LANG en_US.UTF-8
|
ENV LANG en_US.UTF-8
|
||||||
ENV LANGUAGE en_US:en
|
ENV LANGUAGE en_US:en
|
||||||
ENV LC_ALL en_US.UTF-8
|
ENV LC_ALL en_US.UTF-8
|
||||||
|
|
||||||
WORKDIR "/app"
|
WORKDIR "/app"
|
||||||
RUN chown nobody /app
|
|
||||||
|
|
||||||
# Set up data volumes
|
# Set up data volumes
|
||||||
RUN mkdir /config /downloads
|
RUN mkdir /config /downloads
|
||||||
RUN chown nobody /config /downloads
|
|
||||||
|
|
||||||
# set runner ENV
|
# set runner ENV
|
||||||
ENV MIX_ENV="prod"
|
ENV MIX_ENV="prod"
|
||||||
@@ -108,7 +108,7 @@ ENV RUN_CONTEXT="selfhosted"
|
|||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
# Only copy the final release from the build stage
|
# Only copy the final release from the build stage
|
||||||
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/pinchflat ./
|
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
||||||
|
|
||||||
# NEVER do this if you're running in an environment where you don't trust the user
|
# 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.
|
# (ie: most environments). This is only acceptable in a self-hosted environment.
|
||||||
@@ -121,12 +121,13 @@ COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/pinchflat ./
|
|||||||
# root, use --user 0:0 or something.
|
# root, use --user 0:0 or something.
|
||||||
RUN passwd -d root
|
RUN passwd -d root
|
||||||
|
|
||||||
USER nobody
|
|
||||||
|
|
||||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
# 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`
|
# 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
|
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||||
# ENTRYPOINT ["/tini", "--"]
|
# ENTRYPOINT ["/tini", "--"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=120s --start-period=10s \
|
||||||
|
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
|
||||||
|
|
||||||
# Start the app
|
# Start the app
|
||||||
CMD ["/app/bin/docker_start"]
|
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
|
|
||||||
@@ -1,25 +1,65 @@
|
|||||||
defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
||||||
use Pinchflat.DataCase
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Mox
|
||||||
|
import Pinchflat.JobFixtures
|
||||||
|
|
||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Settings.Setting
|
|
||||||
alias Pinchflat.Boot.PreJobStartupTasks
|
alias Pinchflat.Boot.PreJobStartupTasks
|
||||||
|
|
||||||
describe "apply_default_settings" do
|
setup do
|
||||||
setup do
|
stub(YtDlpRunnerMock, :version, fn -> {:ok, "1"} end)
|
||||||
Repo.delete_all(Setting)
|
stub(AppriseRunnerMock, :version, fn -> {:ok, "2"} end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "reset_executing_jobs" do
|
||||||
|
test "resets executing jobs" do
|
||||||
|
job = job_fixture()
|
||||||
|
Repo.update_all(Oban.Job, set: [state: "executing"])
|
||||||
|
|
||||||
|
assert Repo.reload!(job).state == "executing"
|
||||||
|
|
||||||
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
|
assert Repo.reload!(job).state == "retryable"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create_blank_cookie_file" do
|
||||||
|
test "creates a blank cookie file" do
|
||||||
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
|
filepath = Path.join(base_dir, "cookies.txt")
|
||||||
|
File.rm(filepath)
|
||||||
|
|
||||||
|
refute File.exists?(filepath)
|
||||||
|
|
||||||
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
|
assert File.exists?(filepath)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "apply_default_settings" do
|
||||||
|
test "sets yt_dlp version" do
|
||||||
|
Settings.set(yt_dlp_version: nil)
|
||||||
|
|
||||||
|
refute Settings.get!(:yt_dlp_version)
|
||||||
|
|
||||||
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
|
assert Settings.get!(:yt_dlp_version)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets default settings" do
|
test "sets apprise version" do
|
||||||
assert_raise Ecto.NoResultsError, fn -> Settings.get!(:onboarding) end
|
Settings.set(apprise_version: nil)
|
||||||
assert_raise Ecto.NoResultsError, fn -> Settings.get!(:pro_enabled) end
|
|
||||||
|
|
||||||
PreJobStartupTasks.start_link()
|
refute Settings.get!(:apprise_version)
|
||||||
|
|
||||||
assert Settings.get!(:onboarding)
|
PreJobStartupTasks.init(%{})
|
||||||
refute Settings.get!(:pro_enabled)
|
|
||||||
|
assert Settings.get!(:apprise_version)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -222,6 +222,45 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "build_output_path_for/1" do
|
||||||
test "builds an output path for a source", %{media_item: media_item} do
|
test "builds an output path for a source", %{media_item: media_item} do
|
||||||
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
|
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
import Mox
|
import Mox
|
||||||
import Pinchflat.MediaFixtures
|
import Pinchflat.MediaFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
@@ -34,11 +35,28 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item)
|
assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item)
|
||||||
assert task.media_item_id == media_item.id
|
assert task.media_item_id == media_item.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job arguments", %{media_item: media_item} do
|
||||||
|
job_args = %{"force" => true}
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, job_args)
|
||||||
|
|
||||||
|
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id, "force" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job options", %{media_item: media_item} do
|
||||||
|
job_opts = [max_attempts: 5]
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, %{}, job_opts)
|
||||||
|
|
||||||
|
[job] = all_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
||||||
|
assert job.max_attempts == 5
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
test "it saves attributes to the media_item", %{media_item: media_item} do
|
test "it saves attributes to the media_item", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -48,7 +66,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "it saves the metadata to the media_item", %{media_item: media_item} do
|
test "it saves the metadata to the media_item", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -65,7 +83,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "it sets the job to retryable if the download fails", %{media_item: media_item} do
|
test "it sets the job to retryable if the download fails", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error"} end)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error"} end)
|
||||||
|
|
||||||
Oban.Testing.with_testing_mode(:inline, fn ->
|
Oban.Testing.with_testing_mode(:inline, fn ->
|
||||||
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
|
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
|
||||||
@@ -74,16 +92,51 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sets the job to retryable if the download failed and was retried", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
|
{:error, "Unable to communicate with SponsorBlock", 1}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Oban.Testing.with_testing_mode(:inline, fn ->
|
||||||
|
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
|
||||||
|
|
||||||
|
assert job.state == "retryable"
|
||||||
|
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, _addl -> {: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
|
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)
|
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl -> :ok end)
|
||||||
|
|
||||||
Sources.update_source(media_item.source, %{download_media: false})
|
Sources.update_source(media_item.source, %{download_media: false})
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "does not download if the media item is set to not download", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl -> :ok end)
|
||||||
|
|
||||||
|
Media.update_media_item(media_item, %{prevent_download: true})
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "downloads anyway if forced", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
|
||||||
|
|
||||||
|
Sources.update_source(media_item.source, %{download_media: false})
|
||||||
|
Media.update_media_item(media_item, %{prevent_download: true})
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
||||||
|
end
|
||||||
|
|
||||||
test "it saves the file's size to the database", %{media_item: media_item} do
|
test "it saves the file's size to the database", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
metadata = render_parsed_metadata(:media_metadata)
|
metadata = render_parsed_metadata(:media_metadata)
|
||||||
FilesystemHelpers.write_p!(metadata["filepath"], "test")
|
FilesystemHelpers.write_p!(metadata["filepath"], "test")
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
|
|
||||||
describe "download_for_media_item/3" do
|
describe "download_for_media_item/3" do
|
||||||
test "it calls the backend runner", %{media_item: media_item} do
|
test "it calls the backend runner", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn url, _opts, ot ->
|
expect(YtDlpRunnerMock, :run, fn url, _opts, ot, addl ->
|
||||||
assert url == media_item.original_url
|
assert url == media_item.original_url
|
||||||
assert ot == "after_move:%()j"
|
assert ot == "after_move:%()j"
|
||||||
|
assert [{:output_filepath, filepath}] = addl
|
||||||
|
assert is_binary(filepath)
|
||||||
|
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
@@ -35,8 +37,8 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
|
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
|
||||||
end
|
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 ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -47,18 +49,56 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg"
|
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "errors are passed through", %{media_item: media_item} do
|
test "non-recoverable errors are passed through", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:error, :some_error}
|
{:error, :some_error, 1}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:error, :some_error} = MediaDownloader.download_for_media_item(media_item)
|
assert {:error, :some_error} = MediaDownloader.download_for_media_item(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "unknown errors are passed through", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
|
{:error, :some_error}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:error, message} = MediaDownloader.download_for_media_item(media_item)
|
||||||
|
assert message == "Unknown error: {:error, :some_error}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "download_for_media_item/3 when testing retries" do
|
||||||
|
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
|
||||||
|
message = "Unable to communicate with SponsorBlock"
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
|
{:error, message, 1}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
|
||||||
|
message = "Unable to communicate with SponsorBlock"
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
|
||||||
|
[{:output_filepath, filepath}] = addl
|
||||||
|
File.write(filepath, render_metadata(:media_metadata))
|
||||||
|
|
||||||
|
{:error, message, 1}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
|
||||||
|
media_item = Repo.reload(media_item)
|
||||||
|
|
||||||
|
assert DateTime.diff(DateTime.utc_now(), media_item.media_downloaded_at) < 2
|
||||||
|
assert String.ends_with?(media_item.media_filepath, ".mkv")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "download_for_media_item/3 when testing media_item attributes" do
|
describe "download_for_media_item/3 when testing media_item attributes" do
|
||||||
setup do
|
setup do
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -93,8 +133,14 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths
|
assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths
|
||||||
end
|
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
|
test "it extracts the thumbnail_filepath", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
metadata = render_parsed_metadata(:media_metadata)
|
metadata = render_parsed_metadata(:media_metadata)
|
||||||
|
|
||||||
thumbnail_filepath =
|
thumbnail_filepath =
|
||||||
@@ -118,7 +164,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "it extracts the metadata_filepath", %{media_item: media_item} do
|
test "it extracts the metadata_filepath", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
metadata = render_parsed_metadata(:media_metadata)
|
metadata = render_parsed_metadata(:media_metadata)
|
||||||
|
|
||||||
infojson_filepath = metadata["infojson_filename"]
|
infojson_filepath = metadata["infojson_filename"]
|
||||||
@@ -137,7 +183,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
|
|
||||||
describe "download_for_media_item/3 when testing NFO generation" do
|
describe "download_for_media_item/3 when testing NFO generation" do
|
||||||
setup do
|
setup do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -24,7 +24,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do
|
test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do
|
||||||
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||||
|
|
||||||
assert :ok = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
assert [_] = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
||||||
|
|
||||||
assert [worker] = all_enqueued(worker: MediaIndexingWorker)
|
assert [worker] = all_enqueued(worker: MediaIndexingWorker)
|
||||||
assert worker.args["id"] == source.id
|
assert worker.args["id"] == source.id
|
||||||
@@ -35,10 +35,16 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||||
media_item_fixture(source_id: source.id, media_id: "test_1")
|
media_item_fixture(source_id: source.id, media_id: "test_1")
|
||||||
|
|
||||||
assert :ok = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
assert [] = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
||||||
|
|
||||||
refute_enqueued(worker: MediaIndexingWorker)
|
refute_enqueued(worker: MediaIndexingWorker)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns the IDs of the found media items", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||||
|
|
||||||
|
assert ["test_1"] = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "index_and_enqueue_download_for_media_item/2" do
|
describe "index_and_enqueue_download_for_media_item/2" do
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
|
|||||||
import Mox
|
import Mox
|
||||||
import Pinchflat.SourcesFixtures
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
|
|
||||||
@@ -74,4 +75,28 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
|
|||||||
assert :ok = perform_job(FastIndexingWorker, %{id: 0})
|
assert :ok = perform_job(FastIndexingWorker, %{id: 0})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "perform/1 when testing notifications" do
|
||||||
|
setup do
|
||||||
|
Settings.set(apprise_server: "server_1")
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sends a notification if new media was found" do
|
||||||
|
source = source_fixture(fast_index: true)
|
||||||
|
|
||||||
|
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||||
|
|
||||||
|
expect(AppriseRunnerMock, :run, fn servers, opts ->
|
||||||
|
assert "server_1" = servers
|
||||||
|
assert is_binary(Keyword.get(opts, :title))
|
||||||
|
assert is_binary(Keyword.get(opts, :body))
|
||||||
|
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(FastIndexingWorker, %{id: source.id})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+157
-39
@@ -38,29 +38,95 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_media_items_for/1" do
|
describe "list_cullable_media_items/0" do
|
||||||
test "it returns media_items for a given source" do
|
test "returns media items where the source has a retention period" do
|
||||||
source = source_fixture()
|
source_one = source_fixture(%{retention_period_days: 2})
|
||||||
media_item = media_item_fixture(%{source_id: source.id})
|
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]
|
_media_item =
|
||||||
end
|
media_item_fixture(%{
|
||||||
end
|
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
|
_media_item =
|
||||||
test "returns media_items for a given source and media_ids" do
|
media_item_fixture(%{
|
||||||
source = source_fixture()
|
source_id: source_three.id,
|
||||||
media_item = media_item_fixture(%{source_id: source.id, media_id: "123"})
|
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
|
end
|
||||||
|
|
||||||
test "does not return matching media_ids for a different source" do
|
test "returns media_items with a media_filepath" do
|
||||||
source = source_fixture()
|
source = source_fixture(%{retention_period_days: 2})
|
||||||
other_source = source_fixture()
|
|
||||||
_media_item = media_item_fixture(%{source_id: other_source.id, media_id: "123"})
|
|
||||||
|
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -83,14 +149,6 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
assert Media.list_pending_media_items_for(source) == []
|
assert Media.list_pending_media_items_for(source) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "optionally accepts a limit" do
|
|
||||||
source = source_fixture()
|
|
||||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
|
||||||
|
|
||||||
assert Media.list_pending_media_items_for(source, limit: 1) == [media_item]
|
|
||||||
assert Media.list_pending_media_items_for(source, limit: 0) == []
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_pending_media_items_for/1 when testing shorts" do
|
describe "list_pending_media_items_for/1 when testing shorts" do
|
||||||
@@ -259,22 +317,13 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_downloaded_media_items_for/1" do
|
describe "list_pending_media_items_for/1 when testing download prevention" do
|
||||||
test "returns only media items with a media_filepath" do
|
test "returns only media items that are not prevented from downloading" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
_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: "/video/#{Faker.File.file_name(:video)}"})
|
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, prevent_download: false})
|
||||||
|
|
||||||
assert Media.list_downloaded_media_items_for(source) == [media_item]
|
assert Media.list_pending_media_items_for(source) == [media_item]
|
||||||
end
|
|
||||||
|
|
||||||
test "optionally accepts a limit" do
|
|
||||||
source = source_fixture()
|
|
||||||
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
|
||||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: "/video/#{Faker.File.file_name(:video)}"})
|
|
||||||
|
|
||||||
assert Media.list_downloaded_media_items_for(source, limit: 1) == [media_item]
|
|
||||||
assert Media.list_downloaded_media_items_for(source, limit: 0) == []
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -346,6 +395,18 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
assert Media.pending_download?(media_item)
|
assert Media.pending_download?(media_item)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "search/1" do
|
describe "search/1" do
|
||||||
@@ -613,6 +674,63 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "change_media_item/1" do
|
||||||
test "change_media_item/1 returns a media_item changeset" do
|
test "change_media_item/1 returns a media_item changeset" do
|
||||||
media_item = media_item_fixture()
|
media_item = media_item_fixture()
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
|||||||
|
|
||||||
assert result.livestream == metadata["was_live"]
|
assert result.livestream == metadata["was_live"]
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "parse_for_media_item/1 when testing subtitle metadata" do
|
describe "parse_for_media_item/1 when testing subtitle metadata" do
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
|
|||||||
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
|
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
|
||||||
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
|
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "escapes invalid characters", %{filepath: filepath} do
|
||||||
|
metadata = %{
|
||||||
|
"title" => "hello' & <world>",
|
||||||
|
"uploader" => "uploader",
|
||||||
|
"id" => "id",
|
||||||
|
"description" => "description",
|
||||||
|
"upload_date" => "20210101"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
|
||||||
|
nfo = File.read!(result)
|
||||||
|
|
||||||
|
assert String.contains?(nfo, "hello' & <world>")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "build_and_store_for_source/2" do
|
describe "build_and_store_for_source/2" do
|
||||||
@@ -46,5 +61,18 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
|
|||||||
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
|
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
|
||||||
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
|
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "escapes invalid characters", %{filepath: filepath} do
|
||||||
|
metadata = %{
|
||||||
|
"title" => "hello' & <world>",
|
||||||
|
"description" => "description",
|
||||||
|
"id" => "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = NfoBuilder.build_and_store_for_source(filepath, metadata)
|
||||||
|
nfo = File.read!(result)
|
||||||
|
|
||||||
|
assert String.contains?(nfo, "hello' & <world>")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
defmodule Pinchflat.Notifications.CommandRunnerTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Pinchflat.Notifications.CommandRunner, as: Runner
|
||||||
|
|
||||||
|
@original_executable Application.compile_env(:pinchflat, :apprise_executable)
|
||||||
|
|
||||||
|
setup do
|
||||||
|
on_exit(&reset_executable/0)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "run/2" do
|
||||||
|
test "returns :ok when the command succeeds" do
|
||||||
|
assert {:ok, _} = Runner.run("server_1", [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes the servers as the first argument" do
|
||||||
|
assert {:ok, output} = Runner.run(["server_1", "server_2"], [])
|
||||||
|
|
||||||
|
assert String.contains?(output, "server_1 server_2")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "lets you pass a single server as a string" do
|
||||||
|
assert {:ok, output} = Runner.run("server_1", [])
|
||||||
|
|
||||||
|
assert String.contains?(output, "server_1")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "passes all arguments to the command" do
|
||||||
|
assert {:ok, output} = Runner.run("server_1", ["--dry-run"])
|
||||||
|
|
||||||
|
assert String.contains?(output, "--dry-run")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns the output when the command fails" do
|
||||||
|
wrap_executable("/bin/false", fn ->
|
||||||
|
assert {:error, ""} = Runner.run("server_1", [])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns a relevant error if no servers are provided" do
|
||||||
|
assert {:error, :no_servers} = Runner.run(nil, [])
|
||||||
|
assert {:error, :no_servers} = Runner.run("", [])
|
||||||
|
assert {:error, :no_servers} = Runner.run([], [])
|
||||||
|
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, :apprise_executable, new_executable)
|
||||||
|
fun.()
|
||||||
|
reset_executable()
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset_executable do
|
||||||
|
Application.put_env(:pinchflat, :apprise_executable, @original_executable)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
defmodule Pinchflat.Notifications.SourceNotificationsTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Mox
|
||||||
|
import Pinchflat.MediaFixtures
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Notifications.SourceNotifications
|
||||||
|
|
||||||
|
@apprise_servers ["server_1", "server_2"]
|
||||||
|
|
||||||
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
describe "wrap_new_media_notification/3" do
|
||||||
|
test "sends a notification when the pending count changes" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
expect(AppriseRunnerMock, :run, fn servers, opts ->
|
||||||
|
assert servers == @apprise_servers
|
||||||
|
|
||||||
|
assert opts == [
|
||||||
|
title: "[Pinchflat] New media found",
|
||||||
|
body: "Found 1 new media item(s) for #{source.custom_name}. Downloading them now"
|
||||||
|
]
|
||||||
|
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
|
SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn ->
|
||||||
|
media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sends a notification when the downloaded count changes" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
expect(AppriseRunnerMock, :run, fn servers, opts ->
|
||||||
|
assert servers == @apprise_servers
|
||||||
|
|
||||||
|
assert opts == [
|
||||||
|
title: "[Pinchflat] New media found",
|
||||||
|
body: "Found 1 new media item(s) for #{source.custom_name}. Downloading them now"
|
||||||
|
]
|
||||||
|
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
|
SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn ->
|
||||||
|
media_item_fixture(%{source_id: source.id, media_filepath: "file.mp4"})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not send a notification when the count does not change" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
expect(AppriseRunnerMock, :run, 0, fn _, _ -> {:ok, ""} end)
|
||||||
|
|
||||||
|
SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn ->
|
||||||
|
media_item_fixture(%{source_id: source.id, prevent_download: true, media_filepath: nil})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns the value of the function" do
|
||||||
|
source = source_fixture()
|
||||||
|
expect(AppriseRunnerMock, :run, 0, fn _, _ -> {:ok, ""} end)
|
||||||
|
|
||||||
|
retval = SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn -> "value" end)
|
||||||
|
|
||||||
|
assert retval == "value"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "send_new_media_notification/3" do
|
||||||
|
test "sends a notification when count is positive" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
expect(AppriseRunnerMock, :run, fn servers, opts ->
|
||||||
|
assert servers == @apprise_servers
|
||||||
|
|
||||||
|
assert opts == [
|
||||||
|
title: "[Pinchflat] New media found",
|
||||||
|
body: "Found 1 new media item(s) for #{source.custom_name}. Downloading them now"
|
||||||
|
]
|
||||||
|
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok = SourceNotifications.send_new_media_notification(@apprise_servers, source, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not send a notification when count not positive" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
expect(AppriseRunnerMock, :run, 0, fn _, _ -> {:ok, ""} end)
|
||||||
|
|
||||||
|
:ok = SourceNotifications.send_new_media_notification(@apprise_servers, source, 0)
|
||||||
|
:ok = SourceNotifications.send_new_media_notification(@apprise_servers, source, -1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,100 +9,73 @@ defmodule Pinchflat.SettingsTest do
|
|||||||
# are always created on app boot (including in the test env),
|
# are always created on app boot (including in the test env),
|
||||||
# so we can't treat these like a clean slate.
|
# so we can't treat these like a clean slate.
|
||||||
|
|
||||||
describe "list_settings/0" do
|
setup do
|
||||||
test "returns all settings" do
|
# Ensure we have a clean slate
|
||||||
Settings.set!("foo", "bar")
|
Settings.set(onboarding: false)
|
||||||
results = Settings.list_settings()
|
Settings.set(pro_enabled: false)
|
||||||
|
Settings.set(yt_dlp_version: nil)
|
||||||
|
|
||||||
assert Enum.all?(results, fn setting -> match?(%Setting{}, setting) end)
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "record/0" do
|
||||||
|
test "returns the only setting" do
|
||||||
|
assert %Setting{} = Settings.record()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "set/2" do
|
describe "update_setting/2" do
|
||||||
test "creates a new setting if one does not exist" do
|
test "updates the setting" do
|
||||||
original = Repo.aggregate(Setting, :count, :id)
|
setting = Settings.record()
|
||||||
Settings.set!("foo", "bar")
|
|
||||||
assert Repo.aggregate(Setting, :count, :id) == original + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
test "updates an existing setting if one exists" do
|
assert {:ok, false} = Settings.get(:onboarding)
|
||||||
Settings.set!("foo", "bar")
|
assert {:ok, %Setting{}} = Settings.update_setting(setting, %{onboarding: true})
|
||||||
original = Repo.aggregate(Setting, :count, :id)
|
assert {:ok, true} = Settings.get(:onboarding)
|
||||||
Settings.set!("foo", "baz")
|
|
||||||
assert Repo.aggregate(Setting, :count, :id) == original
|
|
||||||
assert Settings.get!("foo") == "baz"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns the parsed value" do
|
|
||||||
assert Settings.set!("foo", true) == true
|
|
||||||
assert Settings.set!("foo", false) == false
|
|
||||||
assert Settings.set!("foo", 123) == 123
|
|
||||||
assert Settings.set!("foo", 12.34) == 12.34
|
|
||||||
assert Settings.set!("foo", "bar") == "bar"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "allows for atom keys" do
|
|
||||||
assert Settings.set!(:foo, "bar") == "bar"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "blows up when an unsupported datatype is used" do
|
|
||||||
assert_raise FunctionClauseError, fn ->
|
|
||||||
Settings.set!("foo", nil)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "set/3" do
|
describe "set/1" do
|
||||||
test "allows manual specification of datatype" do
|
test "updates the setting" do
|
||||||
assert Settings.set!("foo", "true", :boolean) == true
|
assert {:ok, true} = Settings.set(onboarding: true)
|
||||||
assert Settings.set!("foo", "false", :boolean) == false
|
assert {:ok, true} = Settings.get(:onboarding)
|
||||||
assert Settings.set!("foo", "123", :integer) == 123
|
end
|
||||||
assert Settings.set!("foo", "12.34", :float) == 12.34
|
|
||||||
|
test "returns an error if the setting key doesn't exist" do
|
||||||
|
assert {:error, :invalid_key} = Settings.set(foo: "bar")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns an error if the setting value is invalid" do
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Settings.set(onboarding: "bar")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get/1" do
|
describe "get/1" do
|
||||||
test "returns the value of the setting" do
|
test "returns the setting value" do
|
||||||
Settings.set!("str", "bar")
|
assert {:ok, false} = Settings.get(:onboarding)
|
||||||
Settings.set!("bool", true)
|
|
||||||
Settings.set!("int", 123)
|
|
||||||
Settings.set!("float", 12.34)
|
|
||||||
|
|
||||||
assert Settings.get!("str") == "bar"
|
|
||||||
assert Settings.get!("bool") == true
|
|
||||||
assert Settings.get!("int") == 123
|
|
||||||
assert Settings.get!("float") == 12.34
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "allows for atom keys" do
|
test "returns an error if the setting key doesn't exist" do
|
||||||
Settings.set!("str", "bar")
|
assert {:error, :invalid_key} = Settings.get(:foo)
|
||||||
assert Settings.get!(:str) == "bar"
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get!/1" do
|
||||||
|
test "returns the setting value" do
|
||||||
|
assert Settings.get!(:onboarding) == false
|
||||||
end
|
end
|
||||||
|
|
||||||
test "blows up when the setting does not exist" do
|
test "raises an error if the setting key doesn't exist" do
|
||||||
assert_raise Ecto.NoResultsError, fn ->
|
assert_raise RuntimeError, "Setting `foo` not found", fn ->
|
||||||
Settings.get!("foo")
|
Settings.get!(:foo)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "fetch/2" do
|
describe "change_setting/2" do
|
||||||
test "creates a setting if one doesn't exist" do
|
test "returns a changeset" do
|
||||||
original = Repo.aggregate(Setting, :count, :id)
|
setting = Settings.record()
|
||||||
assert Settings.fetch!("foo", "bar") == "bar"
|
|
||||||
assert Repo.aggregate(Setting, :count, :id) == original + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns an existing setting if one does exist" do
|
assert %Ecto.Changeset{} = Settings.change_setting(setting, %{onboarding: true})
|
||||||
Settings.set!("foo", "bar")
|
|
||||||
|
|
||||||
assert Settings.fetch!("foo", "baz") == "bar"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "fetch/3" do
|
|
||||||
test "allows manual specification of datatype" do
|
|
||||||
assert Settings.fetch!("foo", "true", :boolean) == true
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
import Pinchflat.SourcesFixtures
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
@@ -14,7 +15,49 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
describe "kickoff_with_task/3" do
|
||||||
|
setup do
|
||||||
|
source = source_fixture(index_frequency_minutes: 10)
|
||||||
|
|
||||||
|
{:ok, %{source: source}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "starts the worker", %{source: source} do
|
||||||
|
assert [] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
||||||
|
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source)
|
||||||
|
assert [_] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "attaches a task", %{source: source} do
|
||||||
|
assert {:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source)
|
||||||
|
assert task.source_id == source.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job arguments", %{source: source} do
|
||||||
|
job_args = %{"force" => true}
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source, job_args)
|
||||||
|
|
||||||
|
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id, "force" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job options", %{source: source} do
|
||||||
|
job_opts = [max_attempts: 5]
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source, %{}, job_opts)
|
||||||
|
|
||||||
|
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
||||||
|
assert job.max_attempts == 5
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
|
setup do
|
||||||
|
stub(AppriseRunnerMock, :run, fn _, _ -> {:ok, ""} end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
test "it indexes the source if it should be indexed" do
|
test "it indexes the source if it should be indexed" do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
|
|
||||||
@@ -31,6 +74,14 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it indexes the source no matter what if the 'force' arg is passed" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
|
|
||||||
|
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
|
||||||
|
|
||||||
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
|
||||||
|
end
|
||||||
|
|
||||||
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
|
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
|
||||||
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
|
|
||||||
@@ -166,4 +217,30 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
assert :ok = perform_job(MediaCollectionIndexingWorker, %{id: 0})
|
assert :ok = perform_job(MediaCollectionIndexingWorker, %{id: 0})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "perform/1 when testing apprise notifications" do
|
||||||
|
setup do
|
||||||
|
Settings.set(apprise_server: "server_1")
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sends a notification if new media was found" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
|
||||||
|
{:ok, source_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(AppriseRunnerMock, :run, fn servers, opts ->
|
||||||
|
assert "server_1" = servers
|
||||||
|
assert is_binary(Keyword.get(opts, :title))
|
||||||
|
assert is_binary(Keyword.get(opts, :body))
|
||||||
|
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user