Compare commits
49 Commits
v2024.5.30
...
v2024.8.28
| Author | SHA1 | Date | |
|---|---|---|---|
| c85e9cb5f1 | |||
| 4837dd44c9 | |||
| e3f42c28d0 | |||
| 4a11d47692 | |||
| 612c760755 | |||
| 14b8ecbe44 | |||
| a6c61ccd0d | |||
| 80209240d2 | |||
| 1d5b62ccf9 | |||
| af8235c6b3 | |||
| 8e9f02c807 | |||
| fd20ac5d84 | |||
| 23492961d5 | |||
| 2326775d78 | |||
| e0a84a521b | |||
| d217ad5744 | |||
| d5ae41cdab | |||
| 7a01db05dd | |||
| d392fc3818 | |||
| 47596d5f72 | |||
| dbc25e5730 | |||
| a3ebf59b29 | |||
| 8dd8e49051 | |||
| 029981b7ee | |||
| d423e4486f | |||
| 4c7a63c970 | |||
| 4822127e3f | |||
| 4f25878f2f | |||
| aec56e3a03 | |||
| e06e050f95 | |||
| 5a10015634 | |||
| 0d5a41fd35 | |||
| 8f91c4e6a2 | |||
| 7f1daf90ca | |||
| bb0e104b7e | |||
| c0b8dec520 | |||
| b8da973a81 | |||
| a46cfd1888 | |||
| f08768b887 | |||
| 199d0065e1 | |||
| 781061b483 | |||
| 132f8e8da6 | |||
| 9daef8b3b8 | |||
| f6708a327c | |||
| 582eb53698 | |||
| a20d06072f | |||
| 6d18130351 | |||
| af86ca1e0e | |||
| 4994e70652 |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Pinchflat Dev",
|
||||||
|
"dockerComposeFile": "../docker-compose.yml",
|
||||||
|
"service": "phx",
|
||||||
|
"workspaceFolder": "/app",
|
||||||
|
"shutdownAction": "stopCompose",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": ["phoenixframework.phoenix", "JakeBecker.elixir-ls", "esbenp.prettier-vscode"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# TODO: figure out why my vscode extension doesn't respect the formatter.exs file
|
||||||
|
# if it's in a subdirectory
|
||||||
[
|
[
|
||||||
import_deps: [:ecto, :ecto_sql, :phoenix],
|
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||||
subdirectories: ["priv/*/migrations"],
|
subdirectories: ["priv/*/migrations"],
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ on:
|
|||||||
options:
|
options:
|
||||||
- 'linux/amd64'
|
- 'linux/amd64'
|
||||||
- 'linux/amd64,linux/arm64'
|
- 'linux/amd64,linux/arm64'
|
||||||
|
docker_tags:
|
||||||
|
type: string
|
||||||
|
description: 'Docker Tags'
|
||||||
|
required: true
|
||||||
|
default: 'dev'
|
||||||
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@@ -41,7 +47,8 @@ jobs:
|
|||||||
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
|
type=raw,value=dev,enable=${{ github.event_name != 'release' && github.event_name != 'workflow_dispatch' }}
|
||||||
|
type=raw,value=${{ inputs.docker_tags }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=auto
|
latest=auto
|
||||||
|
|
||||||
@@ -68,7 +75,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./selfhosted.Dockerfile
|
file: ./docker/selfhosted.Dockerfile
|
||||||
# If the event is a release, use the release_arch, otherwise use the
|
# If the event is a release, use the release_arch, otherwise use the
|
||||||
# platforms input if present, falling back to dev_arch
|
# platforms input if present, falling back to dev_arch
|
||||||
platforms: ${{ github.event_name == 'release' && env.release_arch || (github.event.inputs.platforms || env.dev_arch) }}
|
platforms: ${{ github.event_name == 'release' && env.release_arch || (github.event.inputs.platforms || env.dev_arch) }}
|
||||||
|
|||||||
@@ -16,24 +16,28 @@ jobs:
|
|||||||
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
|
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
|
||||||
env:
|
env:
|
||||||
COMPOSE_FILE: ./docker-compose.ci.yml
|
COMPOSE_FILE: ./docker-compose.ci.yml
|
||||||
|
MIX_ENV: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Pull prebuilt images
|
- name: Pull prebuilt images
|
||||||
run: docker compose pull
|
run: docker compose pull
|
||||||
|
|
||||||
- name: Setup Docker layer caching
|
- name: Set up Docker Buildx
|
||||||
uses: jpribyl/action-docker-layer-caching@v0.1.1
|
uses: docker/setup-buildx-action@v3
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
key: ci-docker-cache-{hash}
|
|
||||||
restore-keys: |
|
|
||||||
ci-docker-cache-
|
|
||||||
layer-ci-docker-cache-
|
|
||||||
|
|
||||||
- name: Build and Run Docker image
|
- name: Build docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/dev.Dockerfile
|
||||||
|
load: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Run Docker image
|
||||||
run: docker compose up --detach
|
run: docker compose up --detach
|
||||||
|
|
||||||
# NOTE: All exec commands use the -T flag to compensate for
|
# NOTE: All exec commands use the -T flag to compensate for
|
||||||
@@ -43,8 +47,7 @@ jobs:
|
|||||||
# See https://github.com/actions/runner/issues/241 and https://github.com/docker/compose/issues/8537
|
# See https://github.com/actions/runner/issues/241 and https://github.com/docker/compose/issues/8537
|
||||||
- name: Install Elixir and JS deps
|
- name: Install Elixir and JS deps
|
||||||
run: |
|
run: |
|
||||||
docker compose exec -T phx yarn install && cd assets && yarn install && cd ..
|
docker compose exec -T phx mix deps.get && yarn install && cd assets && yarn install && cd ..
|
||||||
docker compose exec -T phx mix deps.get
|
|
||||||
|
|
||||||
- name: Create and Migrate database
|
- name: Create and Migrate database
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -23,3 +23,11 @@ alias Pinchflat.Metadata.MetadataFileHelpers
|
|||||||
alias Pinchflat.SlowIndexing.FileFollowerServer
|
alias Pinchflat.SlowIndexing.FileFollowerServer
|
||||||
|
|
||||||
Pinchflat.Release.check_file_permissions()
|
Pinchflat.Release.check_file_permissions()
|
||||||
|
|
||||||
|
defmodule IexHelpers do
|
||||||
|
def restart do
|
||||||
|
:init.restart()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
import IexHelpers
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
assets/vendor/
|
assets/vendor/
|
||||||
deps/
|
|
||||||
_build/
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/kieraneglin/pinchflat/releases)
|
[](https://github.com/kieraneglin/pinchflat/releases)
|
||||||
[](#)
|
[](#)
|
||||||
|
[](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/kieraneglin/pinchflat)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,8 +72,8 @@ If it doesn't work for your use case, please make a feature request! You can als
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<img src="priv/static/images/app-form-screenshot.png" alt="Pinchflat screenshot" width="700" />
|
<img src="priv/static/images/app-form-screenshot.jpg" alt="Pinchflat screenshot" width="700" />
|
||||||
<img src="priv/static/images/app-screenshot.png" alt="Pinchflat screenshot" width="700" />
|
<img src="priv/static/images/app-screenshot.jpg" alt="Pinchflat screenshot" width="700" />
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ services:
|
|||||||
2. Prepare the docker image in one of the two ways below:
|
2. Prepare the docker image in one of the two ways below:
|
||||||
- **From GHCR:** `docker pull ghcr.io/kieraneglin/pinchflat:latest`
|
- **From GHCR:** `docker pull ghcr.io/kieraneglin/pinchflat:latest`
|
||||||
- NOTE: also available on Docker Hub at `keglin/pinchflat:latest`
|
- NOTE: also available on Docker Hub at `keglin/pinchflat:latest`
|
||||||
- **Building locally:** `docker build . --file selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest`
|
- **Building locally:** `docker build . --file docker/selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest`
|
||||||
3. Run the container:
|
3. Run the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -145,15 +146,17 @@ If you change this setting and it works well for you, please leave a comment on
|
|||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
| Name | Required? | Default | Notes |
|
| Name | Required? | Default | Notes |
|
||||||
| --------------------- | --------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
|
| --------------------------- | --------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| TZ | No | `UTC` | Must follow IANA TZ format |
|
| `TZ` | No | `UTC` | Must follow IANA TZ format |
|
||||||
| LOG_LEVEL | No | `debug` | Can be set to `info` |
|
| `LOG_LEVEL` | No | `debug` | Can be set to `info` but `debug` is strongly recommended |
|
||||||
| BASIC_AUTH_USERNAME | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
| `BASIC_AUTH_USERNAME` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
||||||
| BASIC_AUTH_PASSWORD | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
| `BASIC_AUTH_PASSWORD` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
||||||
| EXPOSE_FEED_ENDPOINTS | No | | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) |
|
| `EXPOSE_FEED_ENDPOINTS` | No | `false` | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) |
|
||||||
| JOURNAL_MODE | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) |
|
| `ENABLE_IPV6` | No | `false` | Setting to _any_ non-blank value will enable IPv6 |
|
||||||
| TZ_DATA_DIR | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
|
| `JOURNAL_MODE` | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) |
|
||||||
| BASE_ROUTE_PATH | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies |
|
| `TZ_DATA_DIR` | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
|
||||||
|
| `BASE_ROUTE_PATH` | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies |
|
||||||
|
| `YT_DLP_WORKER_CONCURRENCY` | No | `2` | The number of concurrent workers that use `yt-dlp` _per queue_. Set to 1 if you're getting IP limited, otherwise don't touch it |
|
||||||
|
|
||||||
## EFF donations
|
## EFF donations
|
||||||
|
|
||||||
|
|||||||
+4
-13
@@ -41,7 +41,8 @@ config :pinchflat, PinchflatWeb.Endpoint,
|
|||||||
adapter: Phoenix.Endpoint.Cowboy2Adapter,
|
adapter: Phoenix.Endpoint.Cowboy2Adapter,
|
||||||
render_errors: [
|
render_errors: [
|
||||||
formats: [html: PinchflatWeb.ErrorHTML, json: PinchflatWeb.ErrorJSON],
|
formats: [html: PinchflatWeb.ErrorHTML, json: PinchflatWeb.ErrorJSON],
|
||||||
layout: false
|
root_layout: {PinchflatWeb.Layouts, :root},
|
||||||
|
layout: {PinchflatWeb.Layouts, :app}
|
||||||
],
|
],
|
||||||
pubsub_server: Pinchflat.PubSub,
|
pubsub_server: Pinchflat.PubSub,
|
||||||
live_view: [signing_salt: "/t5878kO"]
|
live_view: [signing_salt: "/t5878kO"]
|
||||||
@@ -57,16 +58,6 @@ config :pinchflat, Oban,
|
|||||||
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
|
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
|
||||||
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
|
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
|
||||||
]}
|
]}
|
||||||
],
|
|
||||||
# TODO: consider making this an env var or something?
|
|
||||||
queues: [
|
|
||||||
default: 10,
|
|
||||||
fast_indexing: 6,
|
|
||||||
media_indexing: 2,
|
|
||||||
media_collection_indexing: 2,
|
|
||||||
media_fetching: 2,
|
|
||||||
local_metadata: 8,
|
|
||||||
remote_metadata: 4
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Configures the mailer
|
# Configures the mailer
|
||||||
@@ -101,8 +92,8 @@ config :tailwind,
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Configures Elixir's Logger
|
# Configures Elixir's Logger
|
||||||
config :logger, :console,
|
config :logger, :default_formatter,
|
||||||
format: "$time $metadata[$level] $message\n",
|
format: "$date $time $metadata[$level] | $message\n",
|
||||||
metadata: [:request_id]
|
metadata: [:request_id]
|
||||||
|
|
||||||
# Use Jason for JSON parsing in Phoenix
|
# Use Jason for JSON parsing in Phoenix
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ config :pinchflat, PinchflatWeb.Endpoint,
|
|||||||
config :pinchflat, dev_routes: true
|
config :pinchflat, dev_routes: true
|
||||||
|
|
||||||
# Do not include metadata nor timestamps in development logs
|
# Do not include metadata nor timestamps in development logs
|
||||||
config :logger, :console, format: "[$level] $message\n"
|
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||||
|
|
||||||
# Set a higher stacktrace during development. Avoid configuring such
|
# Set a higher stacktrace during development. Avoid configuring such
|
||||||
# in production as building large stacktraces may be expensive.
|
# in production as building large stacktraces may be expensive.
|
||||||
|
|||||||
+16
-1
@@ -40,6 +40,20 @@ config :pinchflat, Pinchflat.Repo,
|
|||||||
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
|
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Some users may want to increase the number of workers that use yt-dlp to improve speeds
|
||||||
|
# Others may want to decrease the number of these workers to lessen the chance of an IP ban
|
||||||
|
{yt_dlp_worker_count, _} = Integer.parse(System.get_env("YT_DLP_WORKER_CONCURRENCY", "2"))
|
||||||
|
|
||||||
|
config :pinchflat, Oban,
|
||||||
|
queues: [
|
||||||
|
default: 10,
|
||||||
|
fast_indexing: 6,
|
||||||
|
media_collection_indexing: yt_dlp_worker_count,
|
||||||
|
media_fetching: yt_dlp_worker_count,
|
||||||
|
remote_metadata: yt_dlp_worker_count,
|
||||||
|
local_data: 8
|
||||||
|
]
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
config_path = "/config"
|
config_path = "/config"
|
||||||
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
|
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
|
||||||
@@ -52,6 +66,7 @@ if config_env() == :prod do
|
|||||||
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
|
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
|
||||||
# For running PF in a subdirectory via a reverse proxy
|
# For running PF in a subdirectory via a reverse proxy
|
||||||
base_route_path = System.get_env("BASE_ROUTE_PATH", "/")
|
base_route_path = System.get_env("BASE_ROUTE_PATH", "/")
|
||||||
|
enable_ipv6 = String.length(System.get_env("ENABLE_IPV6", "")) > 0
|
||||||
|
|
||||||
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
|
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
|
||||||
|
|
||||||
@@ -106,7 +121,7 @@ if config_env() == :prod do
|
|||||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||||
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
|
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
|
||||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||||
ip: {0, 0, 0, 0},
|
ip: if(enable_ipv6, do: {0, 0, 0, 0, 0, 0, 0, 0}, else: {0, 0, 0, 0}),
|
||||||
port: String.to_integer(System.get_env("PORT") || "4000")
|
port: String.to_integer(System.get_env("PORT") || "4000")
|
||||||
],
|
],
|
||||||
url: [path: base_route_path],
|
url: [path: base_route_path],
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
phx:
|
phx:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: dev.Dockerfile
|
dockerfile: ./docker/dev.Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- MIX_ENV=test
|
- MIX_ENV=test
|
||||||
volumes:
|
volumes:
|
||||||
- '.:/app'
|
- '.:/app'
|
||||||
|
# These lines ensure the deps can be saved as build artifacts for caching
|
||||||
|
- '/app/deps'
|
||||||
|
- '/app/_build'
|
||||||
ports:
|
ports:
|
||||||
- '4008:4008'
|
- '4008:4008'
|
||||||
command: tail -F /dev/null
|
command: tail -F /dev/null
|
||||||
|
|||||||
+2
-4
@@ -1,14 +1,12 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
phx:
|
phx:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: dev.Dockerfile
|
dockerfile: ./docker/dev.Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- '.:/app'
|
- '.:/app'
|
||||||
ports:
|
ports:
|
||||||
- '4008:4008'
|
- '4008:4008'
|
||||||
command:
|
command: bash -c "chmod +x docker/docker-run.dev.sh && docker/docker-run.dev.sh"
|
||||||
- ./docker-run.dev.sh
|
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ARG ELIXIR_VERSION=1.16.2
|
ARG ELIXIR_VERSION=1.17.0
|
||||||
ARG OTP_VERSION=26.2.2
|
ARG OTP_VERSION=26.2.5
|
||||||
ARG DEBIAN_VERSION=bookworm-20240130
|
ARG DEBIAN_VERSION=bookworm-20240612-slim
|
||||||
ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||||
|
|
||||||
FROM ${DEV_IMAGE}
|
FROM ${DEV_IMAGE}
|
||||||
@@ -9,10 +9,10 @@ ARG TARGETPLATFORM
|
|||||||
RUN echo "Building for ${TARGETPLATFORM:?}"
|
RUN echo "Building for ${TARGETPLATFORM:?}"
|
||||||
|
|
||||||
# Install debian packages
|
# Install debian packages
|
||||||
RUN apt-get update -qq
|
RUN apt-get update -qq && \
|
||||||
RUN apt-get install -y inotify-tools curl git openssh-client jq \
|
apt-get install -y inotify-tools curl git openssh-client jq \
|
||||||
python3 python3-setuptools python3-wheel python3-dev pipx \
|
python3 python3-setuptools python3-wheel python3-dev pipx \
|
||||||
python3-mutagen locales procps build-essential graphviz
|
python3-mutagen locales procps build-essential graphviz zsh
|
||||||
|
|
||||||
# Install ffmpeg
|
# Install ffmpeg
|
||||||
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
@@ -23,42 +23,40 @@ RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
|||||||
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
|
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
|
||||||
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe"
|
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe"
|
||||||
|
|
||||||
# Install nodejs
|
# Install nodejs and Yarn
|
||||||
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 && \
|
||||||
RUN bash nodesource_setup.sh
|
bash nodesource_setup.sh && \
|
||||||
RUN apt-get install nodejs
|
apt-get install -y nodejs && \
|
||||||
RUN npm install -g yarn
|
npm install -g yarn && \
|
||||||
|
|
||||||
# Install baseline Elixir packages
|
# Install baseline Elixir packages
|
||||||
RUN mix local.hex --force
|
mix local.hex --force && \
|
||||||
RUN mix local.rebar --force
|
mix local.rebar --force && \
|
||||||
|
|
||||||
# Download and update YT-DLP
|
# Download and update YT-DLP
|
||||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
|
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
|
chmod a+rx /usr/local/bin/yt-dlp && \
|
||||||
RUN yt-dlp -U
|
yt-dlp -U && \
|
||||||
|
|
||||||
# Install Apprise
|
# Install Apprise
|
||||||
RUN export PIPX_HOME=/opt/pipx && \
|
export PIPX_HOME=/opt/pipx && \
|
||||||
export PIPX_BIN_DIR=/usr/local/bin && \
|
export PIPX_BIN_DIR=/usr/local/bin && \
|
||||||
pipx install apprise
|
pipx install apprise && \
|
||||||
|
# Set up ZSH tools
|
||||||
|
chsh -s $(which zsh) && \
|
||||||
|
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
|
||||||
|
|
||||||
# Set the locale
|
# 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
|
||||||
|
|
||||||
# Create app directory and copy the Elixir projects into it.
|
# Create app directory and copy the Elixir projects into it.
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
# Needs permissions to be updated AFTER the copy step
|
|
||||||
RUN chmod +x ./docker-run.dev.sh
|
|
||||||
|
|
||||||
# Install Elixir deps
|
# Install Elixir deps
|
||||||
# RUN mix archive.install github hexpm/hex branch latest
|
# RUN mix archive.install github hexpm/hex branch latest
|
||||||
RUN mix deps.get
|
RUN MIX_ENV=dev mix deps.get && MIX_ENV=dev mix deps.compile
|
||||||
|
RUN MIX_ENV=test mix deps.get && MIX_ENV=test mix deps.compile
|
||||||
# Gives us iex shell history
|
# Gives us iex shell history
|
||||||
ENV ERL_AFLAGS="-kernel shell_history enabled"
|
ENV ERL_AFLAGS="-kernel shell_history enabled"
|
||||||
|
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Ensure the app's deps are installed
|
echo "\nInstalling Elixir deps..."
|
||||||
mix deps.get
|
mix deps.get
|
||||||
|
|
||||||
# Install JS deps
|
# Install both project-level and assets-level JS dependencies
|
||||||
echo "\nInstalling JS..."
|
echo "\nInstalling JS deps..."
|
||||||
cd assets && yarn install
|
yarn install && cd assets && yarn install
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Potentially Set up the database
|
# Potentially Set up the database
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
|
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
|
||||||
# instead of Alpine to avoid DNS resolution issues in production.
|
# instead of Alpine to avoid DNS resolution issues in production.
|
||||||
ARG ELIXIR_VERSION=1.16.2
|
ARG ELIXIR_VERSION=1.17.0
|
||||||
ARG OTP_VERSION=26.2.2
|
ARG OTP_VERSION=26.2.5
|
||||||
ARG DEBIAN_VERSION=bookworm-20240130-slim
|
ARG DEBIAN_VERSION=bookworm-20240612-slim
|
||||||
|
|
||||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||||
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
||||||
@@ -27,10 +27,10 @@ RUN apt-get update -y && \
|
|||||||
# Hex and Rebar
|
# Hex and Rebar
|
||||||
mix local.hex --force && \
|
mix local.hex --force && \
|
||||||
mix local.rebar --force && \
|
mix local.rebar --force && \
|
||||||
# FFmpeg
|
# FFmpeg (latest build that doesn't cause an illegal instruction error for some users - see #347)
|
||||||
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" ;; \
|
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/autobuild-2024-07-30-14-10/ffmpeg-N-116468-g0e09f6d690-linux64-gpl.tar.xz" ;; \
|
||||||
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz" ;; \
|
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/autobuild-2024-07-30-14-10/ffmpeg-N-116468-g0e09f6d690-linuxarm64-gpl.tar.xz" ;; \
|
||||||
*) echo "" ;; esac) && \
|
*) echo "" ;; esac) && \
|
||||||
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \
|
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \
|
||||||
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \
|
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
defmodule Pinchflat.Downloading.CodecParser do
|
|
||||||
@moduledoc """
|
|
||||||
Functions for generating yt-dlp codec strings
|
|
||||||
"""
|
|
||||||
|
|
||||||
alias Pinchflat.Settings
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generate a video codec string based on the value of the video_codec_preference setting.
|
|
||||||
|
|
||||||
Returns binary()
|
|
||||||
"""
|
|
||||||
def generate_vcodec_string_from_settings do
|
|
||||||
generate_vcodec_string(Settings.get!(:video_codec_preference))
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generate an audio codec string based on the value of the audio_codec_preference setting.
|
|
||||||
|
|
||||||
Returns binary()
|
|
||||||
"""
|
|
||||||
def generate_acodec_string_from_settings do
|
|
||||||
generate_acodec_string(Settings.get!(:audio_codec_preference))
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generate a video codec string from a list of video codecs.
|
|
||||||
|
|
||||||
If the list is nil or empty, the default video codec is AVC.
|
|
||||||
|
|
||||||
Returns binary()
|
|
||||||
"""
|
|
||||||
def generate_vcodec_string(nil), do: "bestvideo[vcodec~='^avc']/bestvideo"
|
|
||||||
def generate_vcodec_string([]), do: generate_vcodec_string(nil)
|
|
||||||
|
|
||||||
def generate_vcodec_string(video_codecs) do
|
|
||||||
video_codecs
|
|
||||||
|> Enum.map(&video_codec_map()[&1])
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|> Enum.map(&"bestvideo[vcodec~='^#{&1}']")
|
|
||||||
|> Enum.concat(["bestvideo"])
|
|
||||||
|> Enum.join("/")
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generate an audio codec string from a list of audio codecs.
|
|
||||||
|
|
||||||
If the list is nil or empty, the default audio codec is MP4A.
|
|
||||||
|
|
||||||
Returns binary()
|
|
||||||
"""
|
|
||||||
def generate_acodec_string(nil), do: "bestaudio[acodec~='^mp4a']/bestaudio"
|
|
||||||
def generate_acodec_string([]), do: generate_acodec_string(nil)
|
|
||||||
|
|
||||||
def generate_acodec_string(audio_codecs) do
|
|
||||||
audio_codecs
|
|
||||||
|> Enum.map(&audio_codec_map()[&1])
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|> Enum.map(&"bestaudio[acodec~='^#{&1}']")
|
|
||||||
|> Enum.concat(["bestaudio"])
|
|
||||||
|> Enum.join("/")
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc false
|
|
||||||
def video_codec_map do
|
|
||||||
%{
|
|
||||||
"av01" => "av01",
|
|
||||||
"avc" => "avc",
|
|
||||||
"vp9" => "vp0?9"
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc false
|
|
||||||
def audio_codec_map do
|
|
||||||
%{
|
|
||||||
"aac" => "aac",
|
|
||||||
"mp4a" => "mp4a",
|
|
||||||
"mp3" => "mp3",
|
|
||||||
"opus" => "opus"
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -4,9 +4,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Downloading.CodecParser
|
|
||||||
alias Pinchflat.Downloading.OutputPathBuilder
|
alias Pinchflat.Downloading.OutputPathBuilder
|
||||||
|
|
||||||
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||||
@@ -74,6 +74,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
{{:download_auto_subs, true}, %{download_subs: true}} ->
|
{{:download_auto_subs, true}, %{download_subs: true}} ->
|
||||||
acc ++ [:write_auto_subs]
|
acc ++ [:write_auto_subs]
|
||||||
|
|
||||||
|
{{:download_auto_subs, true}, %{embed_subs: true}} ->
|
||||||
|
acc ++ [:write_auto_subs]
|
||||||
|
|
||||||
{{:embed_subs, true}, %{preferred_resolution: pr}} when pr != :audio ->
|
{{:embed_subs, true}, %{preferred_resolution: pr}} when pr != :audio ->
|
||||||
acc ++ [:embed_subs]
|
acc ++ [:embed_subs]
|
||||||
|
|
||||||
@@ -122,13 +125,13 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp quality_options(media_profile) do
|
defp quality_options(media_profile) do
|
||||||
vcodec_string = CodecParser.generate_vcodec_string_from_settings()
|
vcodec = Settings.get!(:video_codec_preference)
|
||||||
acodec_string = CodecParser.generate_acodec_string_from_settings()
|
acodec = Settings.get!(:audio_codec_preference)
|
||||||
|
|
||||||
case media_profile.preferred_resolution do
|
case media_profile.preferred_resolution do
|
||||||
# Also be aware that :audio disabled all embedding options for subtitles
|
# Also be aware that :audio disabled all embedding options for subtitles
|
||||||
:audio ->
|
:audio ->
|
||||||
[:extract_audio, format: "#{acodec_string}/best"]
|
[:extract_audio, format_sort: "+acodec:#{acodec}"]
|
||||||
|
|
||||||
resolution_atom ->
|
resolution_atom ->
|
||||||
{resolution_string, _} =
|
{resolution_string, _} =
|
||||||
@@ -137,10 +140,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
|> Integer.parse()
|
|> Integer.parse()
|
||||||
|
|
||||||
[
|
[
|
||||||
format_sort: "res:#{resolution_string}",
|
|
||||||
# Since Plex doesn't support reading metadata from MKV
|
# Since Plex doesn't support reading metadata from MKV
|
||||||
remux_video: "mp4",
|
remux_video: "mp4",
|
||||||
format: "((#{vcodec_string})+(#{acodec_string}))/best"
|
format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}"
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -204,10 +206,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
"source_collection_id" => source.collection_id,
|
"source_collection_id" => source.collection_id,
|
||||||
"source_collection_name" => source.collection_name,
|
"source_collection_name" => source.collection_name,
|
||||||
"source_collection_type" => to_string(source.collection_type),
|
"source_collection_type" => to_string(source.collection_type),
|
||||||
"media_upload_date_index" =>
|
"media_playlist_index" => pad_int(media_item_with_preloads.playlist_index),
|
||||||
media_item_with_preloads.upload_date_index
|
"media_upload_date_index" => pad_int(media_item_with_preloads.upload_date_index)
|
||||||
|> to_string()
|
|
||||||
|> String.pad_leading(2, "0")
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -224,6 +224,12 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
|> build_output_path(media_item_with_preloads)
|
|> build_output_path(media_item_with_preloads)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp pad_int(integer, count \\ 2, padding \\ "0") do
|
||||||
|
integer
|
||||||
|
|> to_string()
|
||||||
|
|> String.pad_leading(count, padding)
|
||||||
|
end
|
||||||
|
|
||||||
defp base_directory do
|
defp base_directory do
|
||||||
Application.get_env(:pinchflat, :media_directory)
|
Application.get_env(:pinchflat, :media_directory)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -91,8 +91,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
|||||||
[m, s, mp],
|
[m, s, mp],
|
||||||
^MediaQuery.for_source(source) and
|
^MediaQuery.for_source(source) and
|
||||||
^MediaQuery.downloaded() and
|
^MediaQuery.downloaded() and
|
||||||
not (^MediaQuery.download_prevented()) and
|
not (^MediaQuery.download_prevented())
|
||||||
not (^MediaQuery.culled())
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|||||||
@@ -39,17 +39,14 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
- `quality_upgrade?`: re-downloads media, including the video. Does not force download
|
- `quality_upgrade?`: re-downloads media, including the video. Does not force download
|
||||||
if the source is set to not download media
|
if the source is set to not download media
|
||||||
|
|
||||||
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
Returns :ok | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
||||||
should_force = Map.get(args, "force", false)
|
should_force = Map.get(args, "force", false)
|
||||||
is_quality_upgrade = Map.get(args, "quality_upgrade?", false)
|
is_quality_upgrade = Map.get(args, "quality_upgrade?", false)
|
||||||
|
|
||||||
media_item =
|
media_item = fetch_and_run_prevent_download_user_script(media_item_id)
|
||||||
media_item_id
|
|
||||||
|> Media.get_media_item!()
|
|
||||||
|> Repo.preload(:source)
|
|
||||||
|
|
||||||
# If the source or media item is set to not download media, perform a no-op unless forced
|
# If the source or media item is set to not download media, perform a no-op unless forced
|
||||||
if (media_item.source.download_media && !media_item.prevent_download) || should_force do
|
if (media_item.source.download_media && !media_item.prevent_download) || should_force do
|
||||||
@@ -62,6 +59,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# If a user script exists and, when run, returns a non-zero exit code, prevent this and all future downloads
|
||||||
|
# of the media item.
|
||||||
|
defp fetch_and_run_prevent_download_user_script(media_item_id) do
|
||||||
|
media_item = Media.get_media_item!(media_item_id)
|
||||||
|
|
||||||
|
{:ok, media_item} =
|
||||||
|
case run_user_script(:media_pre_download, media_item) do
|
||||||
|
{:ok, _, exit_code} when exit_code != 0 -> Media.update_media_item(media_item, %{prevent_download: true})
|
||||||
|
_ -> {:ok, media_item}
|
||||||
|
end
|
||||||
|
|
||||||
|
Repo.preload(media_item, :source)
|
||||||
|
end
|
||||||
|
|
||||||
defp download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force) do
|
defp download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force) do
|
||||||
overwrite_behaviour = if should_force || is_quality_upgrade, do: :force_overwrites, else: :no_force_overwrites
|
overwrite_behaviour = if should_force || is_quality_upgrade, do: :force_overwrites, else: :no_force_overwrites
|
||||||
override_opts = [overwrite_behaviour: overwrite_behaviour]
|
override_opts = [overwrite_behaviour: overwrite_behaviour]
|
||||||
@@ -74,9 +85,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
|
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
|
||||||
})
|
})
|
||||||
|
|
||||||
:ok = run_user_script(updated_media_item)
|
run_user_script(:media_downloaded, updated_media_item)
|
||||||
|
|
||||||
{:ok, updated_media_item}
|
:ok
|
||||||
|
|
||||||
{:recovered, _} ->
|
{:recovered, _} ->
|
||||||
{:error, :retry}
|
{:error, :retry}
|
||||||
@@ -112,9 +123,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
|
|
||||||
# NOTE: I like this pattern of using the default value so that I don't have to
|
# NOTE: I like this pattern of using the default value so that I don't have to
|
||||||
# define it in config.exs (and friends). Consider using this elsewhere.
|
# define it in config.exs (and friends). Consider using this elsewhere.
|
||||||
defp run_user_script(media_item) do
|
defp run_user_script(event, media_item) do
|
||||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||||
|
|
||||||
runner.run(:media_downloaded, media_item)
|
runner.run(event, media_item)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -79,12 +79,13 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
|> MetadataParser.parse_for_media_item()
|
|> MetadataParser.parse_for_media_item()
|
||||||
|> Map.merge(%{
|
|> Map.merge(%{
|
||||||
media_downloaded_at: DateTime.utc_now(),
|
media_downloaded_at: DateTime.utc_now(),
|
||||||
|
culled_at: nil,
|
||||||
nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json),
|
nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json),
|
||||||
metadata: %{
|
metadata: %{
|
||||||
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
||||||
# could fail and I want to handle that in isolation
|
# could fail and I want to handle that in isolation
|
||||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_with_preloads, parsed_json),
|
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)
|
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -105,8 +106,9 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
|
|
||||||
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
|
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
|
||||||
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
|
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
|
||||||
|
runner_opts = [output_filepath: output_filepath, use_cookies: item_with_preloads.source.use_cookies]
|
||||||
|
|
||||||
YtDlpMedia.download(url, options, output_filepath: output_filepath)
|
YtDlpMedia.download(url, options, runner_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp recoverable_errors do
|
defp recoverable_errors do
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do
|
|||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{}) do
|
def perform(%Oban.Job{}) do
|
||||||
redownloadable_media = Media.list_redownloadable_media_items()
|
upgradable_media = Media.list_upgradeable_media_items()
|
||||||
Logger.info("Redownloading #{length(redownloadable_media)} media items")
|
Logger.info("Redownloading #{length(upgradable_media)} media items")
|
||||||
|
|
||||||
Enum.each(redownloadable_media, fn media_item ->
|
Enum.each(upgradable_media, fn media_item ->
|
||||||
MediaDownloadWorker.kickoff_with_task(media_item, %{quality_upgrade?: true})
|
MediaDownloadWorker.kickoff_with_task(media_item, %{quality_upgrade?: true})
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :local_metadata,
|
queue: :local_data,
|
||||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||||
tags: ["media_item", "local_metadata"]
|
tags: ["media_item", "local_data"]
|
||||||
|
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -20,14 +23,51 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
|||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{}) do
|
def perform(%Oban.Job{}) do
|
||||||
cullable_media = Media.list_cullable_media_items()
|
cull_cullable_media_items()
|
||||||
|
delete_media_items_from_before_cutoff()
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cull_cullable_media_items do
|
||||||
|
cullable_media =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.require_assoc(:source)
|
||||||
|
|> where(^MediaQuery.cullable())
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
Logger.info("Culling #{length(cullable_media)} media items past their retention date")
|
Logger.info("Culling #{length(cullable_media)} media items past their retention date")
|
||||||
|
|
||||||
Enum.each(cullable_media, fn media_item ->
|
Enum.each(cullable_media, fn media_item ->
|
||||||
|
# Setting `prevent_download` does what it says on the tin, but `culled_at` is purely informational.
|
||||||
|
# We don't actually do anything with that in terms of queries and it gets set to nil if the media item
|
||||||
|
# gets re-downloaded.
|
||||||
Media.delete_media_files(media_item, %{
|
Media.delete_media_files(media_item, %{
|
||||||
prevent_download: true,
|
prevent_download: true,
|
||||||
culled_at: DateTime.utc_now()
|
culled_at: DateTime.utc_now()
|
||||||
})
|
})
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp delete_media_items_from_before_cutoff do
|
||||||
|
deletable_media =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.require_assoc(:source)
|
||||||
|
|> where(^MediaQuery.deletable_based_on_source_cutoff())
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
Logger.info("Deleting #{length(deletable_media)} media items that are from before the source cutoff")
|
||||||
|
|
||||||
|
Enum.each(deletable_media, fn media_item ->
|
||||||
|
# Note that I'm not setting `prevent_download` on the media_item here.
|
||||||
|
# That's because cutoff_date can easily change and it's a valid behavior to re-download older
|
||||||
|
# media items if the cutoff_date changes.
|
||||||
|
# Download is ultimately prevented because `MediaQuery.pending()` only returns media items
|
||||||
|
# from after the cutoff date (among other things), so it's not like the media will just immediately
|
||||||
|
# be re-downloaded.
|
||||||
|
Media.delete_media_files(media_item, %{
|
||||||
|
culled_at: DateTime.utc_now()
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
|
|||||||
"season_from_date" => "%(upload_date>%Y)S",
|
"season_from_date" => "%(upload_date>%Y)S",
|
||||||
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S",
|
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S",
|
||||||
"season_episode_index_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S{{ media_upload_date_index }}",
|
"season_episode_index_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S{{ media_upload_date_index }}",
|
||||||
"artist_name" => "%(artist,creator,uploader,uploader_id)S"
|
"artist_name" => "%(artist,creator,uploader,uploader_id)S",
|
||||||
|
"static_season__episode_by_index" => "Season 1/s01e{{ media_playlist_index }}",
|
||||||
|
"static_season__episode_by_date" => "Season 1/s01e%(upload_date>%y%m%d)S",
|
||||||
|
"season_by_year__episode_by_date" => "Season %(upload_date>%Y)S/s%(upload_date>%Y)Se%(upload_date>%m%d)S",
|
||||||
|
"season_by_year__episode_by_date_and_index" =>
|
||||||
|
"Season %(upload_date>%Y)S/s%(upload_date>%Y)Se%(upload_date>%m%d)S{{ media_upload_date_index }}"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.YoutubeRss
|
alias Pinchflat.FastIndexing.YoutubeRss
|
||||||
|
alias Pinchflat.FastIndexing.YoutubeApi
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
|
|
||||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Fetches new media IDs from a source's YouTube RSS feed, indexes them, and kicks off downloading
|
Fetches new media IDs for a source from YT's API or RSS, indexes them, and kicks off downloading
|
||||||
tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the
|
tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the
|
||||||
order of operations and how this fits into the indexing process.
|
order of operations and how this fits into the indexing process.
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
downloaded_.
|
downloaded_.
|
||||||
"""
|
"""
|
||||||
def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do
|
def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do
|
||||||
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
{:ok, media_ids} = get_recent_media_ids(source)
|
||||||
existing_media_items = 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)
|
||||||
|
|
||||||
@@ -47,6 +48,17 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
Enum.filter(maybe_new_media_items, & &1)
|
Enum.filter(maybe_new_media_items, & &1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# If possible, use the YouTube API to fetch media IDs. If that fails, fall back to the RSS feed.
|
||||||
|
# If the YouTube API isn't set up, just use the RSS feed.
|
||||||
|
defp get_recent_media_ids(source) do
|
||||||
|
with true <- YoutubeApi.enabled?(),
|
||||||
|
{:ok, media_ids} <- YoutubeApi.get_recent_media_ids(source) do
|
||||||
|
{:ok, media_ids}
|
||||||
|
else
|
||||||
|
_ -> YoutubeRss.get_recent_media_ids(source)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp list_media_items_by_media_id_for(source, media_ids) do
|
defp list_media_items_by_media_id_for(source, media_ids) do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> where(^dynamic([mi], ^MediaQuery.for_source(source) and mi.media_id in ^media_ids))
|
|> where(^dynamic([mi], ^MediaQuery.for_source(source) and mi.media_id in ^media_ids))
|
||||||
@@ -56,7 +68,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
defp create_media_item_from_media_id(source, media_id) do
|
defp create_media_item_from_media_id(source, media_id) do
|
||||||
url = "https://www.youtube.com/watch?v=#{media_id}"
|
url = "https://www.youtube.com/watch?v=#{media_id}"
|
||||||
|
|
||||||
case YtDlpMedia.get_media_attributes(url) do
|
case YtDlpMedia.get_media_attributes(url, use_cookies: source.use_cookies) do
|
||||||
{:ok, media_attrs} ->
|
{:ok, media_attrs} ->
|
||||||
Media.create_media_item_from_backend_attrs(source, media_attrs)
|
Media.create_media_item_from_backend_attrs(source, media_attrs)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
defmodule Pinchflat.FastIndexing.YoutubeApi do
|
||||||
|
@moduledoc """
|
||||||
|
Methods for interacting with the YouTube API for fast indexing
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Pinchflat.Settings
|
||||||
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.Utils.FunctionUtils
|
||||||
|
alias Pinchflat.FastIndexing.YoutubeBehaviour
|
||||||
|
|
||||||
|
@behaviour YoutubeBehaviour
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Determines if the YouTube API is enabled for fast indexing by checking
|
||||||
|
if the user has an API key set
|
||||||
|
|
||||||
|
Returns boolean()
|
||||||
|
"""
|
||||||
|
@impl YoutubeBehaviour
|
||||||
|
def enabled?(), do: is_binary(api_key())
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches the recent media IDs from the YouTube API for a given source.
|
||||||
|
|
||||||
|
Returns {:ok, [binary()]} | {:error, binary()}
|
||||||
|
"""
|
||||||
|
@impl YoutubeBehaviour
|
||||||
|
def get_recent_media_ids(%Source{} = source) do
|
||||||
|
api_response =
|
||||||
|
source
|
||||||
|
|> determine_playlist_id()
|
||||||
|
|> do_api_request()
|
||||||
|
|
||||||
|
case api_response do
|
||||||
|
{:ok, parsed_json} -> get_media_ids_from_response(parsed_json)
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The UC prefix is for channels which won't work with this API endpoint. Swapping
|
||||||
|
# the prefix to UU will get us the playlist that represents the channel's uploads
|
||||||
|
defp determine_playlist_id(%{collection_id: c_id}) do
|
||||||
|
String.replace_prefix(c_id, "UC", "UU")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_api_request(playlist_id) do
|
||||||
|
Logger.debug("Fetching recent media IDs from YouTube API for playlist: #{playlist_id}")
|
||||||
|
|
||||||
|
playlist_id
|
||||||
|
|> construct_api_endpoint()
|
||||||
|
|> http_client().get(accept: "application/json")
|
||||||
|
|> case do
|
||||||
|
{:ok, response} ->
|
||||||
|
Phoenix.json_library().decode(response)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to fetch YouTube API: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_media_ids_from_response(parsed_json) do
|
||||||
|
parsed_json
|
||||||
|
|> Map.get("items", [])
|
||||||
|
|> Enum.map(fn item ->
|
||||||
|
item
|
||||||
|
|> Map.get("contentDetails", %{})
|
||||||
|
|> Map.get("videoId", nil)
|
||||||
|
end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> FunctionUtils.wrap_ok()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp api_key do
|
||||||
|
Settings.get!(:youtube_api_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp construct_api_endpoint(playlist_id) do
|
||||||
|
api_base = "https://youtube.googleapis.com/youtube/v3/playlistItems"
|
||||||
|
property_type = "contentDetails"
|
||||||
|
max_results = 50
|
||||||
|
|
||||||
|
"#{api_base}?part=#{property_type}&maxResults=#{max_results}&playlistId=#{playlist_id}&key=#{api_key()}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp http_client do
|
||||||
|
Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
defmodule Pinchflat.FastIndexing.YoutubeBehaviour do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the behaviour for clients that interface with YouTube
|
||||||
|
for the purpose of fast indexing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.Sources.Source
|
||||||
|
|
||||||
|
@callback enabled?() :: boolean()
|
||||||
|
@callback get_recent_media_ids(%Source{}) :: {:ok, [String.t()]} | {:error, String.t()}
|
||||||
|
end
|
||||||
@@ -1,18 +1,31 @@
|
|||||||
defmodule Pinchflat.FastIndexing.YoutubeRss do
|
defmodule Pinchflat.FastIndexing.YoutubeRss do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Methods for interacting with YouTube RSS feeds
|
Methods for interacting with YouTube RSS feeds for fast indexing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.FastIndexing.YoutubeBehaviour
|
||||||
|
|
||||||
|
@behaviour YoutubeBehaviour
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Determines if the YouTube RSS feed is enabled for fast indexing. Used to satisfy
|
||||||
|
the `YoutubeBehaviour` behaviour.
|
||||||
|
|
||||||
|
Returns true
|
||||||
|
"""
|
||||||
|
@impl YoutubeBehaviour
|
||||||
|
def enabled?(), do: true
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Fetches the recent media IDs from a YouTube RSS feed for a given source.
|
Fetches the recent media IDs from a YouTube RSS feed for a given source.
|
||||||
|
|
||||||
Returns {:ok, [binary()]} | {:error, binary()}
|
Returns {:ok, [binary()]} | {:error, binary()}
|
||||||
"""
|
"""
|
||||||
def get_recent_media_ids_from_rss(%Source{} = source) do
|
@impl YoutubeBehaviour
|
||||||
|
def get_recent_media_ids(%Source{} = source) do
|
||||||
Logger.debug("Fetching recent media IDs from YouTube RSS feed for source: #{source.collection_id}")
|
Logger.debug("Fetching recent media IDs from YouTube RSS feed for source: #{source.collection_id}")
|
||||||
|
|
||||||
case http_client().get(rss_url_for_source(source)) do
|
case http_client().get(rss_url_for_source(source)) do
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ defmodule Pinchflat.HTTP.HTTPClient do
|
|||||||
"""
|
"""
|
||||||
@impl HTTPBehaviour
|
@impl HTTPBehaviour
|
||||||
def get(url, headers \\ [], opts \\ []) do
|
def get(url, headers \\ [], opts \\ []) do
|
||||||
|
headers = parse_headers(headers)
|
||||||
|
|
||||||
case :httpc.request(:get, {url, headers}, [], opts) do
|
case :httpc.request(:get, {url, headers}, [], opts) do
|
||||||
{:ok, {{_version, 200, _reason_phrase}, _headers, body}} ->
|
{:ok, {{_version, 200, _reason_phrase}, _headers, body}} ->
|
||||||
{:ok, body}
|
{:ok, to_string(body)}
|
||||||
|
|
||||||
{:ok, {{_version, status_code, reason_phrase}, _headers, _body}} ->
|
{:ok, {{_version, status_code, reason_phrase}, _headers, _body}} ->
|
||||||
{:error, "HTTP request failed with status code #{status_code}: #{reason_phrase}"}
|
{:error, "HTTP request failed with status code #{status_code}: #{reason_phrase}"}
|
||||||
@@ -32,4 +34,8 @@ defmodule Pinchflat.HTTP.HTTPClient do
|
|||||||
{:error, "HTTP request failed: #{reason}"}
|
{:error, "HTTP request failed: #{reason}"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp parse_headers(headers) do
|
||||||
|
Enum.map(headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
@behaviour UserScriptCommandRunner
|
@behaviour UserScriptCommandRunner
|
||||||
|
|
||||||
@event_types [
|
@event_types [
|
||||||
|
:media_pre_download,
|
||||||
:media_downloaded,
|
:media_downloaded,
|
||||||
:media_deleted
|
:media_deleted
|
||||||
]
|
]
|
||||||
@@ -22,24 +23,25 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
|
|
||||||
This function will succeed in almost all cases, even if the user script command
|
This function will succeed in almost all cases, even if the user script command
|
||||||
failed - this is because I don't want bad scripts to stop the whole process.
|
failed - this is because I don't want bad scripts to stop the whole process.
|
||||||
If something fails, it'll be logged.
|
If something fails, it'll be logged and returned BUT the tuple will always
|
||||||
|
start with {:ok, ...}.
|
||||||
|
|
||||||
The only things that can cause a true failure are passing in an invalid event
|
The only things that can cause a true failure are passing in an invalid event
|
||||||
type or if the passed data cannot be encoded into JSON - both indicative of
|
type or if the passed data cannot be encoded into JSON - both indicative of
|
||||||
failures in the development process.
|
failures in the development process.
|
||||||
|
|
||||||
Returns :ok
|
Returns {:ok, :no_executable} | {:ok, output, exit_code}
|
||||||
"""
|
"""
|
||||||
@impl UserScriptCommandRunner
|
@impl UserScriptCommandRunner
|
||||||
def run(event_type, encodable_data) when event_type in @event_types do
|
def run(event_type, encodable_data) when event_type in @event_types do
|
||||||
case backend_executable() do
|
case backend_executable() do
|
||||||
{:ok, :no_executable} ->
|
{:ok, :no_executable} ->
|
||||||
:ok
|
{:ok, :no_executable}
|
||||||
|
|
||||||
{:ok, executable_path} ->
|
{:ok, executable_path} ->
|
||||||
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
||||||
|
|
||||||
{_output, _exit_code} =
|
{output, exit_code} =
|
||||||
CliUtils.wrap_cmd(
|
CliUtils.wrap_cmd(
|
||||||
executable_path,
|
executable_path,
|
||||||
[to_string(event_type), encoded_data],
|
[to_string(event_type), encoded_data],
|
||||||
@@ -47,7 +49,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
logging_arg_override: "[suppressed]"
|
logging_arg_override: "[suppressed]"
|
||||||
)
|
)
|
||||||
|
|
||||||
:ok
|
{:ok, output, exit_code}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
if FilesystemUtils.exists_and_nonempty?(filepath) do
|
if FilesystemUtils.exists_and_nonempty?(filepath) do
|
||||||
{:ok, filepath}
|
{:ok, filepath}
|
||||||
else
|
else
|
||||||
Logger.warning("User scripts lifecyle file either not present or is empty. Skipping.")
|
Logger.info("User scripts lifecyle file either not present or is empty. Skipping.")
|
||||||
|
|
||||||
{:ok, :no_executable}
|
{:ok, :no_executable}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,21 +25,10 @@ defmodule Pinchflat.Media do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of media_items that are cullable based on the retention period
|
Returns a list of media_items that are upgradeable based on the redownload delay
|
||||||
of the source they belong to.
|
of the media_profile their source belongs to. In this context, upgradeable means
|
||||||
|
that it's been long enough since upload that the video may be in a higher quality
|
||||||
Returns [%MediaItem{}, ...]
|
or have better sponsorblock segments (or similar).
|
||||||
"""
|
|
||||||
def list_cullable_media_items do
|
|
||||||
MediaQuery.new()
|
|
||||||
|> MediaQuery.require_assoc(:source)
|
|
||||||
|> where(^MediaQuery.cullable())
|
|
||||||
|> Repo.all()
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns a list of media_items that are redownloadable based on the redownload delay
|
|
||||||
of the media_profile their source belongs to.
|
|
||||||
|
|
||||||
The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
|
The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
|
||||||
at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
|
at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
|
||||||
@@ -52,10 +41,10 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
Returns [%MediaItem{}, ...]
|
Returns [%MediaItem{}, ...]
|
||||||
"""
|
"""
|
||||||
def list_redownloadable_media_items do
|
def list_upgradeable_media_items do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.require_assoc(:media_profile)
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|> where(^MediaQuery.redownloadable())
|
|> where(^MediaQuery.upgradeable())
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,12 +131,17 @@ defmodule Pinchflat.Media do
|
|||||||
"""
|
"""
|
||||||
def create_media_item_from_backend_attrs(source, media_attrs_struct) do
|
def create_media_item_from_backend_attrs(source, media_attrs_struct) do
|
||||||
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
|
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
|
||||||
|
# Some fields should only be set on insert and not on update.
|
||||||
|
fields_to_drop_on_update = [:playlist_index]
|
||||||
|
|
||||||
%MediaItem{}
|
%MediaItem{}
|
||||||
|> MediaItem.changeset(attrs)
|
|> MediaItem.changeset(attrs)
|
||||||
|> Repo.insert(
|
|> Repo.insert(
|
||||||
on_conflict: [
|
on_conflict: [
|
||||||
set: Map.to_list(attrs)
|
set:
|
||||||
|
attrs
|
||||||
|
|> Map.drop(fields_to_drop_on_update)
|
||||||
|
|> Map.to_list()
|
||||||
],
|
],
|
||||||
conflict_target: [:source_id, :media_id]
|
conflict_target: [:source_id, :media_id]
|
||||||
)
|
)
|
||||||
@@ -177,7 +171,7 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
if delete_files do
|
if delete_files do
|
||||||
{:ok, _} = do_delete_media_files(media_item)
|
{:ok, _} = do_delete_media_files(media_item)
|
||||||
:ok = run_user_script(:media_deleted, media_item)
|
run_user_script(:media_deleted, media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Should delete these no matter what
|
# Should delete these no matter what
|
||||||
@@ -200,7 +194,7 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
Tasks.delete_tasks_for(media_item)
|
Tasks.delete_tasks_for(media_item)
|
||||||
{:ok, _} = do_delete_media_files(media_item)
|
{:ok, _} = do_delete_media_files(media_item)
|
||||||
:ok = run_user_script(:media_deleted, media_item)
|
run_user_script(:media_deleted, media_item)
|
||||||
|
|
||||||
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
alias Pinchflat.Media.MediaItemsSearchIndex
|
alias Pinchflat.Media.MediaItemsSearchIndex
|
||||||
|
|
||||||
@allowed_fields [
|
@allowed_fields [
|
||||||
|
# these fields are only captured on index
|
||||||
|
:playlist_index,
|
||||||
# these fields are captured on indexing (and again on download)
|
# these fields are captured on indexing (and again on download)
|
||||||
:title,
|
:title,
|
||||||
:media_id,
|
:media_id,
|
||||||
@@ -72,6 +74,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
field :uploaded_at, :utc_datetime
|
field :uploaded_at, :utc_datetime
|
||||||
field :upload_date_index, :integer, default: 0
|
field :upload_date_index, :integer, default: 0
|
||||||
field :duration_seconds, :integer
|
field :duration_seconds, :integer
|
||||||
|
field :playlist_index, :integer, default: 0
|
||||||
|
|
||||||
field :media_filepath, :string
|
field :media_filepath, :string
|
||||||
field :media_size_bytes, :integer
|
field :media_size_bytes, :integer
|
||||||
@@ -130,7 +133,31 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Run it on new records no matter what. The method we delegate to
|
||||||
|
# will handle the case where `uploaded_at` is `nil`
|
||||||
|
defp update_upload_date_index(%{data: %{id: nil}} = changeset) do
|
||||||
|
do_update_upload_date_index(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
# For the update case, we only want to recalculate if the day itself has changed.
|
||||||
|
# For instance, this is useful in the migration from `upload_date` to `uploaded_at`
|
||||||
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
|
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
|
||||||
|
old_uploaded_at = changeset.data.uploaded_at
|
||||||
|
new_uploaded_at = get_change(changeset, :uploaded_at)
|
||||||
|
upload_dates_match = DateTime.to_date(old_uploaded_at) == DateTime.to_date(new_uploaded_at)
|
||||||
|
|
||||||
|
if upload_dates_match do
|
||||||
|
changeset
|
||||||
|
else
|
||||||
|
do_update_upload_date_index(changeset)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# If the record is persisted and the `uploaded_at` field is not being changed,
|
||||||
|
# we don't need to recalculate the index.
|
||||||
|
defp update_upload_date_index(changeset), do: changeset
|
||||||
|
|
||||||
|
defp do_update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
|
||||||
source_id = get_field(changeset, :source_id)
|
source_id = get_field(changeset, :source_id)
|
||||||
source = Sources.get_source!(source_id)
|
source = Sources.get_source!(source_id)
|
||||||
# Channels should count down from 99, playlists should count up from 0
|
# Channels should count down from 99, playlists should count up from 0
|
||||||
@@ -151,7 +178,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp update_upload_date_index(changeset), do: changeset
|
defp do_update_upload_date_index(changeset), do: changeset
|
||||||
|
|
||||||
defimpl Jason.Encoder, for: MediaItem do
|
defimpl Jason.Encoder, for: MediaItem do
|
||||||
def encode(value, opts) do
|
def encode(value, opts) do
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath))
|
def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath))
|
||||||
def download_prevented, do: dynamic([mi], mi.prevent_download == true)
|
def download_prevented, do: dynamic([mi], mi.prevent_download == true)
|
||||||
def culling_prevented, do: dynamic([mi], mi.prevent_culling == true)
|
def culling_prevented, do: dynamic([mi], mi.prevent_culling == true)
|
||||||
def culled, do: dynamic([mi], not is_nil(mi.culled_at))
|
|
||||||
def redownloaded, do: dynamic([mi], not is_nil(mi.media_redownloaded_at))
|
def redownloaded, do: dynamic([mi], not is_nil(mi.media_redownloaded_at))
|
||||||
def upload_date_matches(other_date), do: dynamic([mi], fragment("date(?) = date(?)", mi.uploaded_at, ^other_date))
|
def upload_date_matches(other_date), do: dynamic([mi], fragment("date(?) = date(?)", mi.uploaded_at, ^other_date))
|
||||||
|
|
||||||
@@ -76,6 +75,14 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def meets_min_and_max_duration do
|
||||||
|
dynamic(
|
||||||
|
[mi, source],
|
||||||
|
(is_nil(source.min_duration_seconds) or fragment("duration_seconds >= ?", source.min_duration_seconds)) and
|
||||||
|
(is_nil(source.max_duration_seconds) or fragment("duration_seconds <= ?", source.max_duration_seconds))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def past_retention_period do
|
def past_retention_period do
|
||||||
dynamic(
|
dynamic(
|
||||||
[mi, source],
|
[mi, source],
|
||||||
@@ -108,6 +115,15 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def deletable_based_on_source_cutoff do
|
||||||
|
dynamic(
|
||||||
|
[mi, source],
|
||||||
|
^downloaded() and
|
||||||
|
not (^upload_date_after_source_cutoff()) and
|
||||||
|
not (^culling_prevented())
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def pending do
|
def pending do
|
||||||
dynamic(
|
dynamic(
|
||||||
[mi],
|
[mi],
|
||||||
@@ -115,16 +131,16 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
not (^download_prevented()) and
|
not (^download_prevented()) and
|
||||||
^upload_date_after_source_cutoff() and
|
^upload_date_after_source_cutoff() and
|
||||||
^format_matching_profile_preference() and
|
^format_matching_profile_preference() and
|
||||||
^matches_source_title_regex()
|
^matches_source_title_regex() and
|
||||||
|
^meets_min_and_max_duration()
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def redownloadable do
|
def upgradeable do
|
||||||
dynamic(
|
dynamic(
|
||||||
[mi, source],
|
[mi, source],
|
||||||
^downloaded() and
|
^downloaded() and
|
||||||
not (^download_prevented()) and
|
not (^download_prevented()) and
|
||||||
not (^culled()) and
|
|
||||||
not (^redownloaded()) and
|
not (^redownloaded()) and
|
||||||
^past_redownload_delay()
|
^past_redownload_delay()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
|||||||
|
|
||||||
alias Pinchflat.Utils.FilesystemUtils
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the directory where metadata for a database record should be stored.
|
Returns the directory where metadata for a database record should be stored.
|
||||||
|
|
||||||
@@ -47,42 +49,28 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
|||||||
Returns {:ok, map()} | {:error, any}
|
Returns {:ok, map()} | {:error, any}
|
||||||
"""
|
"""
|
||||||
def read_compressed_metadata(filepath) do
|
def read_compressed_metadata(filepath) do
|
||||||
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all))
|
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :eof))
|
||||||
|
|
||||||
Phoenix.json_library().decode(json)
|
Phoenix.json_library().decode(json)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Downloads and stores a thumbnail for a media item, returning the filepath.
|
Downloads and stores a thumbnail for a media item, returning the filepath.
|
||||||
Chooses the highest quality thumbnail available (preferring jpg). Returns
|
Chooses the highest quality thumbnail available and converts it to a JPG
|
||||||
nil if no thumbnails are available.
|
|
||||||
|
Returns nil if no thumbnail is available or if yt-dlp encounters an error
|
||||||
|
|
||||||
Returns binary() | nil
|
Returns binary() | nil
|
||||||
"""
|
"""
|
||||||
def download_and_store_thumbnail_for(database_record, metadata_map) do
|
def download_and_store_thumbnail_for(media_item_with_preloads) do
|
||||||
thumbnails =
|
yt_dlp_filepath = generate_filepath_for(media_item_with_preloads, "thumbnail.%(ext)s")
|
||||||
(metadata_map["thumbnails"] || [])
|
real_filepath = generate_filepath_for(media_item_with_preloads, "thumbnail.jpg")
|
||||||
# Give it a low preference if the `preference` key doesn't exist
|
command_opts = [output: yt_dlp_filepath]
|
||||||
|> Enum.map(&Map.put_new(&1, "preference", -1000))
|
addl_opts = [use_cookies: media_item_with_preloads.source.use_cookies]
|
||||||
# Give it a low preference if image isn't a jpg
|
|
||||||
|> Enum.map(fn t ->
|
|
||||||
preference_weight = if String.ends_with?(t["url"], ".jpg"), do: t["preference"], else: t["preference"] - 1000
|
|
||||||
|
|
||||||
Map.put(t, "preference", preference_weight)
|
case YtDlpMedia.download_thumbnail(media_item_with_preloads.original_url, command_opts, addl_opts) do
|
||||||
end)
|
{:ok, _} -> real_filepath
|
||||||
|
_ -> nil
|
||||||
case Enum.sort_by(thumbnails, & &1["preference"], :desc) do
|
|
||||||
[thumbnail_map | _] ->
|
|
||||||
thumbnail_url = thumbnail_map["url"]
|
|
||||||
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
|
|
||||||
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
|
|
||||||
|
|
||||||
:ok = FilesystemUtils.write_p!(filepath, thumbnail_blob)
|
|
||||||
|
|
||||||
filepath
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -138,11 +126,19 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_thumbnail_from_url(url) do
|
@doc """
|
||||||
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
Attempts to determine the season and episode number from a media filepath.
|
||||||
{:ok, body} = http_client.get(url, [], body_format: :binary)
|
|
||||||
|
|
||||||
body
|
Returns {:ok, {binary(), binary()}} | {:error, :indeterminable}
|
||||||
|
"""
|
||||||
|
def season_and_episode_from_media_filepath(media_filepath) do
|
||||||
|
# matches s + 1 or more digits + e + 1 or more digits (case-insensitive)
|
||||||
|
season_episode_regex = ~r/s(\d+)e(\d+)/i
|
||||||
|
|
||||||
|
case Regex.scan(season_episode_regex, media_filepath) do
|
||||||
|
[[_, season, episode] | _] -> {:ok, {season, episode}}
|
||||||
|
_ -> {:error, :indeterminable}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_filepath_for(database_record, filename) do
|
defp generate_filepath_for(database_record, filename) do
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
|
|
||||||
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
||||||
|
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
|
||||||
alias Pinchflat.Utils.FilesystemUtils
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Builds an NFO file for a media item (read: single "episode") and
|
Builds an NFO file for a media item (read: single "episode") and
|
||||||
@@ -15,12 +15,12 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
|
|
||||||
Returns the filepath of the NFO file.
|
Returns the filepath of the NFO file.
|
||||||
"""
|
"""
|
||||||
def build_and_store_for_media_item(filepath, metadata) do
|
def build_and_store_for_media_item(nfo_filepath, metadata) do
|
||||||
nfo = build_for_media_item(metadata)
|
nfo = build_for_media_item(nfo_filepath, metadata)
|
||||||
|
|
||||||
FilesystemUtils.write_p!(filepath, nfo)
|
FilesystemUtils.write_p!(nfo_filepath, nfo)
|
||||||
|
|
||||||
filepath
|
nfo_filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -37,10 +37,15 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
filepath
|
filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_for_media_item(metadata) do
|
defp build_for_media_item(nfo_filepath, metadata) do
|
||||||
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
|
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
|
||||||
|
# NOTE: the filepath here isn't the path of the media item, it's the path that
|
||||||
|
# the NFO should be saved to. This works because the NFO's path is the same as
|
||||||
|
# the media's path, just with a different extension. If this ever changes I'll
|
||||||
|
# need to pass in the media item's path as well.
|
||||||
|
{season, episode} = determine_season_and_episode_number(nfo_filepath, upload_date)
|
||||||
|
|
||||||
# Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub.
|
# Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub.
|
||||||
# WHO NEEDS A FANCY XML PARSER ANYWAY?!
|
|
||||||
"""
|
"""
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
||||||
<episodedetails>
|
<episodedetails>
|
||||||
@@ -49,8 +54,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
<uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
|
<uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
|
||||||
<plot>#{safe(metadata["description"])}</plot>
|
<plot>#{safe(metadata["description"])}</plot>
|
||||||
<aired>#{safe(upload_date)}</aired>
|
<aired>#{safe(upload_date)}</aired>
|
||||||
<season>#{safe(upload_date.year)}</season>
|
<season>#{safe(season)}</season>
|
||||||
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
|
<episode>#{episode}</episode>
|
||||||
<genre>YouTube</genre>
|
<genre>YouTube</genre>
|
||||||
</episodedetails>
|
</episodedetails>
|
||||||
"""
|
"""
|
||||||
@@ -67,4 +72,11 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
</tvshow>
|
</tvshow>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp determine_season_and_episode_number(filepath, upload_date) do
|
||||||
|
case MetadataFileHelpers.season_and_episode_from_media_filepath(filepath) do
|
||||||
|
{:ok, {season, episode}} -> {season, episode}
|
||||||
|
{:error, _} -> {upload_date.year, Calendar.strftime(upload_date, "%m%d")}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,29 +20,49 @@ defmodule Pinchflat.Metadata.SourceImageParser do
|
|||||||
def store_source_images(base_directory, source_metadata) do
|
def store_source_images(base_directory, source_metadata) do
|
||||||
(source_metadata["thumbnails"] || [])
|
(source_metadata["thumbnails"] || [])
|
||||||
|> Enum.filter(&(&1["filepath"] != nil))
|
|> Enum.filter(&(&1["filepath"] != nil))
|
||||||
|> select_useful_images()
|
|> select_useful_images(source_metadata)
|
||||||
|> Enum.map(&move_image(&1, base_directory))
|
|> Enum.map(&move_image(&1, base_directory))
|
||||||
|> Enum.into(%{})
|
|> Enum.into(%{})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp select_useful_images(images) do
|
defp select_useful_images(images, source_metadata) do
|
||||||
labelled_images =
|
labelled_images =
|
||||||
Enum.reduce(images, [], fn image_map, acc ->
|
Enum.reduce(images, %{}, fn image_map, acc ->
|
||||||
case image_map do
|
case image_map do
|
||||||
%{"id" => "avatar_uncropped"} ->
|
%{"id" => "avatar_uncropped"} -> put_image_key(acc, :poster, image_map["filepath"])
|
||||||
acc ++ [{:poster, :poster_filepath, image_map["filepath"]}]
|
%{"id" => "banner_uncropped"} -> put_image_key(acc, :fanart, image_map["filepath"])
|
||||||
|
_ -> acc
|
||||||
%{"id" => "banner_uncropped"} ->
|
|
||||||
acc ++ [{:fanart, :fanart_filepath, image_map["filepath"]}]
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
acc
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
labelled_images
|
labelled_images
|
||||||
|> Enum.concat([{:banner, :banner_filepath, determine_best_banner(images)}])
|
|> add_fallback_poster(source_metadata)
|
||||||
|> Enum.filter(fn {_, _, tmp_filepath} -> tmp_filepath end)
|
|> put_image_key(:banner, determine_best_banner(images))
|
||||||
|
|> Enum.filter(fn {_key, attrs} -> attrs.current_filepath end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# If a poster is set, short-circuit and return the images as-is
|
||||||
|
defp add_fallback_poster(%{poster: _} = images, _), do: images
|
||||||
|
|
||||||
|
# If a poster is NOT set, see if we can find a suitable image to use as a fallback
|
||||||
|
defp add_fallback_poster(images, source_metadata) do
|
||||||
|
case source_metadata["entries"] do
|
||||||
|
nil -> images
|
||||||
|
[] -> images
|
||||||
|
[first_entry | _] -> add_poster_from_entry_thumbnail(images, first_entry)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_poster_from_entry_thumbnail(images, entry) do
|
||||||
|
thumbnail =
|
||||||
|
(entry["thumbnails"] || [])
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.find(& &1["filepath"])
|
||||||
|
|
||||||
|
case thumbnail do
|
||||||
|
nil -> images
|
||||||
|
_ -> put_image_key(images, :poster, thumbnail["filepath"])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp determine_best_banner(images) do
|
defp determine_best_banner(images) do
|
||||||
@@ -58,12 +78,22 @@ defmodule Pinchflat.Metadata.SourceImageParser do
|
|||||||
Map.get(best_candidate || %{}, "filepath")
|
Map.get(best_candidate || %{}, "filepath")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp move_image({filename, source_attr_name, tmp_filepath}, base_directory) do
|
defp move_image({_key, attrs}, base_directory) do
|
||||||
extension = Path.extname(tmp_filepath)
|
extension = Path.extname(attrs.current_filepath)
|
||||||
final_filepath = Path.join([base_directory, "#{filename}#{extension}"])
|
final_filepath = Path.join([base_directory, "#{attrs.final_filename}#{extension}"])
|
||||||
|
|
||||||
FilesystemUtils.cp_p!(tmp_filepath, final_filepath)
|
FilesystemUtils.cp_p!(attrs.current_filepath, final_filepath)
|
||||||
|
|
||||||
{source_attr_name, final_filepath}
|
{attrs.attribute_name, final_filepath}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_image_key(map, key, image) do
|
||||||
|
attribute_atom = String.to_existing_atom("#{key}_filepath")
|
||||||
|
|
||||||
|
Map.put(map, key, %{
|
||||||
|
attribute_name: attribute_atom,
|
||||||
|
final_filename: to_string(key),
|
||||||
|
current_filepath: image
|
||||||
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -77,10 +77,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
|||||||
|
|
||||||
defp fetch_source_metadata_and_images(series_directory, source) do
|
defp fetch_source_metadata_and_images(series_directory, source) do
|
||||||
metadata_directory = MetadataFileHelpers.metadata_directory_for(source)
|
metadata_directory = MetadataFileHelpers.metadata_directory_for(source)
|
||||||
tmp_output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
|
|
||||||
opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: tmp_output_path]
|
|
||||||
|
|
||||||
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts)
|
{:ok, metadata} = fetch_metadata_for_source(source)
|
||||||
metadata_image_attrs = SourceImageParser.store_source_images(metadata_directory, metadata)
|
metadata_image_attrs = SourceImageParser.store_source_images(metadata_directory, metadata)
|
||||||
|
|
||||||
if source.media_profile.download_source_images && series_directory do
|
if source.media_profile.download_source_images && series_directory do
|
||||||
@@ -94,7 +92,9 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
|||||||
|
|
||||||
defp determine_series_directory(source) do
|
defp determine_series_directory(source) do
|
||||||
output_path = DownloadOptionBuilder.build_output_path_for(source)
|
output_path = DownloadOptionBuilder.build_output_path_for(source)
|
||||||
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, output: output_path)
|
runner_opts = [output: output_path]
|
||||||
|
addl_opts = [use_cookies: source.use_cookies]
|
||||||
|
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, runner_opts, addl_opts)
|
||||||
|
|
||||||
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
|
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
|
||||||
{:ok, series_directory} -> series_directory
|
{:ok, series_directory} -> series_directory
|
||||||
@@ -110,6 +110,20 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp fetch_metadata_for_source(source) do
|
||||||
|
tmp_output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
|
||||||
|
base_opts = [convert_thumbnails: "jpg", output: tmp_output_path]
|
||||||
|
|
||||||
|
opts =
|
||||||
|
if source.collection_type == :channel do
|
||||||
|
base_opts ++ [:write_all_thumbnails, playlist_items: 0]
|
||||||
|
else
|
||||||
|
base_opts ++ [:write_thumbnail, playlist_items: 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
MediaCollection.get_source_metadata(source.original_url, opts, use_cookies: source.use_cookies)
|
||||||
|
end
|
||||||
|
|
||||||
defp tmp_directory do
|
defp tmp_directory do
|
||||||
Application.get_env(:pinchflat, :tmpfile_directory)
|
Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
livestream_behaviour
|
livestream_behaviour
|
||||||
preferred_resolution
|
preferred_resolution
|
||||||
redownload_delay_days
|
redownload_delay_days
|
||||||
|
marked_for_deletion_at
|
||||||
)a
|
)a
|
||||||
|
|
||||||
@required_fields ~w(name output_path_template)a
|
@required_fields ~w(name output_path_template)a
|
||||||
@@ -65,6 +66,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
||||||
field :preferred_resolution, Ecto.Enum, values: ~w(4320p 2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
|
field :preferred_resolution, Ecto.Enum, values: ~w(4320p 2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
|
||||||
|
|
||||||
|
field :marked_for_deletion_at, :utc_datetime
|
||||||
|
|
||||||
has_many :sources, Source
|
has_many :sources, Source
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule Pinchflat.Profiles.MediaProfileDeletionWorker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :local_data,
|
||||||
|
tags: ["media_profiles", "local_data"]
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias __MODULE__
|
||||||
|
alias Pinchflat.Profiles
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts the profile deletion worker. Does not attach it to a task like `kickoff_with_task/2`
|
||||||
|
since deletion also cancels all tasks for the profile
|
||||||
|
|
||||||
|
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
|
||||||
|
"""
|
||||||
|
def kickoff(profile, job_args \\ %{}, job_opts \\ []) do
|
||||||
|
%{id: profile.id}
|
||||||
|
|> Map.merge(job_args)
|
||||||
|
|> MediaProfileDeletionWorker.new(job_opts)
|
||||||
|
|> Oban.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a profile and optionally deletes its files
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: %{"id" => profile_id} = args}) do
|
||||||
|
delete_files = Map.get(args, "delete_files", false)
|
||||||
|
profile = Profiles.get_media_profile!(profile_id)
|
||||||
|
|
||||||
|
Profiles.delete_media_profile(profile, delete_files: delete_files)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -13,17 +13,15 @@ defmodule Pinchflat.Settings.Setting do
|
|||||||
:apprise_version,
|
:apprise_version,
|
||||||
:apprise_server,
|
:apprise_server,
|
||||||
:video_codec_preference,
|
:video_codec_preference,
|
||||||
:audio_codec_preference
|
:audio_codec_preference,
|
||||||
]
|
:youtube_api_key
|
||||||
|
|
||||||
@virtual_fields [
|
|
||||||
:video_codec_preference_string,
|
|
||||||
:audio_codec_preference_string
|
|
||||||
]
|
]
|
||||||
|
|
||||||
@required_fields ~w(
|
@required_fields ~w(
|
||||||
onboarding
|
onboarding
|
||||||
pro_enabled
|
pro_enabled
|
||||||
|
video_codec_preference
|
||||||
|
audio_codec_preference
|
||||||
)a
|
)a
|
||||||
|
|
||||||
schema "settings" do
|
schema "settings" do
|
||||||
@@ -32,43 +30,16 @@ defmodule Pinchflat.Settings.Setting do
|
|||||||
field :yt_dlp_version, :string
|
field :yt_dlp_version, :string
|
||||||
field :apprise_version, :string
|
field :apprise_version, :string
|
||||||
field :apprise_server, :string
|
field :apprise_server, :string
|
||||||
|
field :youtube_api_key, :string
|
||||||
|
|
||||||
field :video_codec_preference, {:array, :string}, default: []
|
field :video_codec_preference, :string
|
||||||
field :audio_codec_preference, {:array, :string}, default: []
|
field :audio_codec_preference, :string
|
||||||
field :video_codec_preference_string, :string, default: nil, virtual: true
|
|
||||||
field :audio_codec_preference_string, :string, default: nil, virtual: true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def changeset(setting, attrs) do
|
def changeset(setting, attrs) do
|
||||||
setting
|
setting
|
||||||
|> cast(attrs, @allowed_fields)
|
|> cast(attrs, @allowed_fields)
|
||||||
|> cast(attrs, @virtual_fields, empty_values: [])
|
|
||||||
|> convert_codec_preference_strings()
|
|
||||||
|> validate_required(@required_fields)
|
|> validate_required(@required_fields)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp convert_codec_preference_strings(changeset) do
|
|
||||||
fields = [
|
|
||||||
video_codec_preference_string: :video_codec_preference,
|
|
||||||
audio_codec_preference_string: :audio_codec_preference
|
|
||||||
]
|
|
||||||
|
|
||||||
Enum.reduce(fields, changeset, fn {virtual_field, actual_field}, changeset ->
|
|
||||||
case get_change(changeset, virtual_field) do
|
|
||||||
nil ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
value ->
|
|
||||||
new_value =
|
|
||||||
value
|
|
||||||
|> String.split(">")
|
|
||||||
|> Enum.map(&String.trim/1)
|
|
||||||
|> Enum.reject(&(String.trim(&1) == ""))
|
|
||||||
|> Enum.map(&String.downcase/1)
|
|
||||||
|
|
||||||
put_change(changeset, actual_field, new_value)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers 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
|
||||||
result = MediaCollection.get_media_attributes_for_collection(source.original_url, file_listener_handler: handler)
|
runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies]
|
||||||
|
result = MediaCollection.get_media_attributes_for_collection(source.original_url, runner_opts)
|
||||||
|
|
||||||
FileFollowerServer.stop(pid)
|
FileFollowerServer.stop(pid)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
series_directory
|
series_directory
|
||||||
index_frequency_minutes
|
index_frequency_minutes
|
||||||
fast_index
|
fast_index
|
||||||
|
use_cookies
|
||||||
download_media
|
download_media
|
||||||
last_indexed_at
|
last_indexed_at
|
||||||
original_url
|
original_url
|
||||||
@@ -35,6 +36,9 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
title_filter_regex
|
title_filter_regex
|
||||||
media_profile_id
|
media_profile_id
|
||||||
output_path_template_override
|
output_path_template_override
|
||||||
|
marked_for_deletion_at
|
||||||
|
min_duration_seconds
|
||||||
|
max_duration_seconds
|
||||||
)a
|
)a
|
||||||
|
|
||||||
# Expensive API calls are made when a source is inserted/updated so
|
# Expensive API calls are made when a source is inserted/updated so
|
||||||
@@ -72,6 +76,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
|
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
|
||||||
field :index_frequency_minutes, :integer, default: 60 * 24
|
field :index_frequency_minutes, :integer, default: 60 * 24
|
||||||
field :fast_index, :boolean, default: false
|
field :fast_index, :boolean, default: false
|
||||||
|
field :use_cookies, :boolean, default: false
|
||||||
field :download_media, :boolean, default: true
|
field :download_media, :boolean, default: true
|
||||||
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
|
||||||
@@ -81,12 +86,17 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
field :title_filter_regex, :string
|
field :title_filter_regex, :string
|
||||||
field :output_path_template_override, :string
|
field :output_path_template_override, :string
|
||||||
|
|
||||||
|
field :min_duration_seconds, :integer
|
||||||
|
field :max_duration_seconds, :integer
|
||||||
|
|
||||||
field :series_directory, :string
|
field :series_directory, :string
|
||||||
field :nfo_filepath, :string
|
field :nfo_filepath, :string
|
||||||
field :poster_filepath, :string
|
field :poster_filepath, :string
|
||||||
field :fanart_filepath, :string
|
field :fanart_filepath, :string
|
||||||
field :banner_filepath, :string
|
field :banner_filepath, :string
|
||||||
|
|
||||||
|
field :marked_for_deletion_at, :utc_datetime
|
||||||
|
|
||||||
belongs_to :media_profile, MediaProfile
|
belongs_to :media_profile, MediaProfile
|
||||||
|
|
||||||
has_one :metadata, SourceMetadata, on_replace: :update
|
has_one :metadata, SourceMetadata, on_replace: :update
|
||||||
@@ -112,9 +122,12 @@ 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_title_regex()
|
||||||
|
|> validate_min_and_max_durations()
|
||||||
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|
||||||
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|
||||||
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|
||||||
|
|> validate_format(:original_url, youtube_channel_or_playlist_regex(), message: "must be a channel or playlist URL")
|
||||||
|> 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)
|
|> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
|
||||||
end
|
end
|
||||||
@@ -128,7 +141,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
@doc false
|
@doc false
|
||||||
def fast_index_frequency do
|
def fast_index_frequency do
|
||||||
# minutes
|
# minutes
|
||||||
15
|
10
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@@ -141,6 +154,33 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
~w(__meta__ __struct__ metadata tasks media_items)a
|
~w(__meta__ __struct__ metadata tasks media_items)a
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def youtube_channel_or_playlist_regex do
|
||||||
|
# Validate that the original URL is not a video URL
|
||||||
|
# Also matches if the string does NOT contain youtube.com or youtu.be. This preserves my tenuous support
|
||||||
|
# for non-youtube sources.
|
||||||
|
~r<^(?:(?!youtube\.com/(watch|shorts|embed)|youtu\.be).)*$>
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_title_regex(%{changes: %{title_filter_regex: regex}} = changeset) when is_binary(regex) do
|
||||||
|
case Ecto.Adapters.SQL.query(Repo, "SELECT regexp_like('', ?)", [regex]) do
|
||||||
|
{:ok, _} -> changeset
|
||||||
|
_ -> add_error(changeset, :title_filter_regex, "is invalid")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_title_regex(changeset), do: changeset
|
||||||
|
|
||||||
|
defp validate_min_and_max_durations(changeset) do
|
||||||
|
min_duration = get_change(changeset, :min_duration_seconds)
|
||||||
|
max_duration = get_change(changeset, :max_duration_seconds)
|
||||||
|
|
||||||
|
case {min_duration, max_duration} do
|
||||||
|
{min, max} when is_nil(min) or is_nil(max) -> changeset
|
||||||
|
{min, max} when min >= max -> add_error(changeset, :max_duration_seconds, "must be greater than minumum duration")
|
||||||
|
_ -> changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defimpl Jason.Encoder, for: Source do
|
defimpl Jason.Encoder, for: Source do
|
||||||
def encode(value, opts) do
|
def encode(value, opts) do
|
||||||
value
|
value
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule Pinchflat.Sources.SourceDeletionWorker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :local_data,
|
||||||
|
tags: ["sources", "local_data"]
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias __MODULE__
|
||||||
|
alias Pinchflat.Sources
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts the source deletion worker. Does not attach it to a task like `kickoff_with_task/2`
|
||||||
|
since deletion also cancels all tasks for the source
|
||||||
|
|
||||||
|
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
|
||||||
|
"""
|
||||||
|
def kickoff(source, job_args \\ %{}, job_opts \\ []) do
|
||||||
|
%{id: source.id}
|
||||||
|
|> Map.merge(job_args)
|
||||||
|
|> SourceDeletionWorker.new(job_opts)
|
||||||
|
|> Oban.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a source and optionally deletes its files
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: %{"id" => source_id} = args}) do
|
||||||
|
delete_files = Map.get(args, "delete_files", false)
|
||||||
|
source = Sources.get_source!(source_id)
|
||||||
|
|
||||||
|
Sources.delete_source(source, delete_files: delete_files)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -180,7 +180,9 @@ defmodule Pinchflat.Sources do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp add_source_details_to_changeset(source, changeset) do
|
defp add_source_details_to_changeset(source, changeset) do
|
||||||
case MediaCollection.get_source_details(changeset.changes.original_url) do
|
use_cookies = Ecto.Changeset.get_field(changeset, :use_cookies)
|
||||||
|
|
||||||
|
case MediaCollection.get_source_details(changeset.changes.original_url, [], use_cookies: use_cookies) do
|
||||||
{:ok, source_details} ->
|
{:ok, source_details} ->
|
||||||
add_source_details_by_collection_type(source, changeset, source_details)
|
add_source_details_by_collection_type(source, changeset, source_details)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
- :output_filepath - the path to save the output to. If not provided, a temporary
|
- :output_filepath - the path to save the output to. If not provided, a temporary
|
||||||
file will be created and used. Useful for if you need a reference to the file
|
file will be created and used. Useful for if you need a reference to the file
|
||||||
for a file watcher.
|
for a file watcher.
|
||||||
|
- :use_cookies - if true, will add a cookie file to the command options. Will not
|
||||||
|
attach a cookie file if the user hasn't set one up.
|
||||||
|
|
||||||
Returns {:ok, binary()} | {:error, output, status}.
|
Returns {:ok, binary()} | {:error, output, status}.
|
||||||
"""
|
"""
|
||||||
@@ -28,7 +30,7 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
|
|
||||||
output_filepath = generate_output_filepath(addl_opts)
|
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]
|
||||||
user_configured_opts = cookie_file_options()
|
user_configured_opts = cookie_file_options(addl_opts)
|
||||||
# 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.
|
||||||
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
|
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
|
||||||
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
||||||
@@ -78,7 +80,14 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp cookie_file_options do
|
defp cookie_file_options(addl_opts) do
|
||||||
|
case Keyword.get(addl_opts, :use_cookies) do
|
||||||
|
true -> add_cookie_file()
|
||||||
|
_ -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_cookie_file do
|
||||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
filename_options_map = %{cookies: "cookies.txt"}
|
filename_options_map = %{cookies: "cookies.txt"}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:livestream,
|
:livestream,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:uploaded_at,
|
:uploaded_at,
|
||||||
:duration_seconds
|
:duration_seconds,
|
||||||
|
:playlist_index
|
||||||
]
|
]
|
||||||
|
|
||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
@@ -36,9 +37,9 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
Returns {:ok, map()} | {:error, any, ...}.
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def download(url, command_opts \\ [], addl_opts \\ []) do
|
def download(url, command_opts \\ [], addl_opts \\ []) do
|
||||||
opts = [:no_simulate] ++ command_opts
|
all_command_opts = [:no_simulate] ++ command_opts
|
||||||
|
|
||||||
with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j", addl_opts),
|
with {:ok, output} <- backend_runner().run(url, all_command_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
|
||||||
@@ -46,17 +47,31 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Downloads a thumbnail for a single piece of media. Usually used for
|
||||||
|
downloading thumbnails for internal use
|
||||||
|
|
||||||
|
Returns {:ok, ""} | {:error, any, ...}.
|
||||||
|
"""
|
||||||
|
def download_thumbnail(url, command_opts \\ [], addl_opts \\ []) do
|
||||||
|
all_command_opts = [:no_simulate, :skip_download, :write_thumbnail, convert_thumbnail: "jpg"] ++ command_opts
|
||||||
|
|
||||||
|
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since
|
||||||
|
# we aren't downloading the main media file
|
||||||
|
backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a map representing the media at the given URL.
|
Returns a map representing the media at the given URL.
|
||||||
|
|
||||||
Returns {:ok, [map()]} | {:error, any, ...}.
|
Returns {:ok, %Media{}} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_media_attributes(url) do
|
def get_media_attributes(url, addl_opts \\ []) do
|
||||||
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
|
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||||
command_opts = [:simulate, :skip_download]
|
command_opts = [:simulate, :skip_download]
|
||||||
output_template = indexing_output_template()
|
output_template = indexing_output_template()
|
||||||
|
|
||||||
case runner.run(url, command_opts, output_template) do
|
case runner.run(url, command_opts, output_template, addl_opts) do
|
||||||
{:ok, output} ->
|
{:ok, output} ->
|
||||||
output
|
output
|
||||||
|> Phoenix.json_library().decode!()
|
|> Phoenix.json_library().decode!()
|
||||||
@@ -70,9 +85,11 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the output template for yt-dlp's indexing command.
|
Returns the output template for yt-dlp's indexing command.
|
||||||
|
|
||||||
|
NOTE: playlist_index is really only useful for playlists that will never change their order.
|
||||||
"""
|
"""
|
||||||
def indexing_output_template do
|
def indexing_output_template do
|
||||||
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp})j"
|
"%(.{id,title,live_status,webpage_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j"
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -87,10 +104,11 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
title: response["title"],
|
title: response["title"],
|
||||||
description: response["description"],
|
description: response["description"],
|
||||||
original_url: response["webpage_url"],
|
original_url: response["webpage_url"],
|
||||||
livestream: !!response["was_live"],
|
livestream: !!response["live_status"] && response["live_status"] != "not_live",
|
||||||
duration_seconds: response["duration"] && round(response["duration"]),
|
duration_seconds: response["duration"] && round(response["duration"]),
|
||||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
short_form_content: response["webpage_url"] && short_form_content?(response),
|
||||||
uploaded_at: response["upload_date"] && parse_uploaded_at(response)
|
uploaded_at: response["upload_date"] && parse_uploaded_at(response),
|
||||||
|
playlist_index: response["playlist_index"] || 0
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,17 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
# the first video has not released yet (ie: is a premier). We don't care about
|
# the first video has not released yet (ie: is a premier). We don't care about
|
||||||
# available formats since we're just getting the media details
|
# available formats since we're just getting the media details
|
||||||
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings]
|
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings]
|
||||||
|
use_cookies = Keyword.get(addl_opts, :use_cookies, false)
|
||||||
output_template = YtDlpMedia.indexing_output_template()
|
output_template = YtDlpMedia.indexing_output_template()
|
||||||
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
||||||
|
runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies]
|
||||||
|
|
||||||
if file_listener_handler do
|
if file_listener_handler do
|
||||||
file_listener_handler.(output_filepath)
|
file_listener_handler.(output_filepath)
|
||||||
end
|
end
|
||||||
|
|
||||||
case runner.run(url, command_opts, output_template, output_filepath: output_filepath) do
|
case runner.run(url, command_opts, output_template, runner_opts) do
|
||||||
{:ok, output} ->
|
{:ok, output} ->
|
||||||
parsed_lines =
|
parsed_lines =
|
||||||
output
|
output
|
||||||
@@ -64,7 +66,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
|
|
||||||
Returns {:ok, map()} | {:error, any, ...}.
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_source_details(source_url, addl_opts \\ []) do
|
def get_source_details(source_url, command_opts \\ [], addl_opts \\ []) do
|
||||||
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
||||||
# the first video has not released yet (ie: is a premier). We don't care about
|
# the first video has not released yet (ie: is a premier). We don't care about
|
||||||
# available formats since we're just getting the source details
|
# available formats since we're just getting the source details
|
||||||
@@ -75,10 +77,10 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
playlist_end: 1
|
playlist_end: 1
|
||||||
]
|
]
|
||||||
|
|
||||||
command_opts = default_opts ++ addl_opts
|
all_command_opts = default_opts ++ command_opts
|
||||||
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
|
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
|
||||||
|
|
||||||
with {:ok, output} <- backend_runner().run(source_url, command_opts, output_template),
|
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts),
|
||||||
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
{:ok, format_source_details(parsed_json)}
|
{:ok, format_source_details(parsed_json)}
|
||||||
else
|
else
|
||||||
@@ -99,13 +101,24 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
as a compressed blob for possible future use. That's why it's not getting formatted like
|
as a compressed blob for possible future use. That's why it's not getting formatted like
|
||||||
`get_source_details/1`
|
`get_source_details/1`
|
||||||
|
|
||||||
|
! IMPORTANT ! - you'll always want to set `playlist_items: int` in `addl_opts.
|
||||||
|
This is great if you want to also return details about the videos in the playlists,
|
||||||
|
but it should be set in all cases to not over-fetch data.
|
||||||
|
For channels you should usually set this to 0 since channels return all the
|
||||||
|
metadata we need without needing to fetch the videos. On the other hand, playlists
|
||||||
|
don't return very useful images so you can set this to 1 to get the first video's
|
||||||
|
images, for instance.
|
||||||
|
|
||||||
Returns {:ok, map()} | {:error, any, ...}.
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_source_metadata(source_url, addl_opts \\ []) do
|
def get_source_metadata(source_url, command_opts, addl_opts \\ []) do
|
||||||
opts = [playlist_items: 0] ++ addl_opts
|
# This only validates that the `playlist_items` key is present. It's otherwise unused
|
||||||
|
_playlist_items = Keyword.fetch!(command_opts, :playlist_items)
|
||||||
|
|
||||||
|
all_command_opts = [:skip_download] ++ command_opts
|
||||||
output_template = "playlist:%()j"
|
output_template = "playlist:%()j"
|
||||||
|
|
||||||
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
|
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, 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
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
import PinchflatWeb.Gettext
|
import PinchflatWeb.Gettext
|
||||||
|
|
||||||
alias Phoenix.LiveView.JS
|
alias Phoenix.LiveView.JS
|
||||||
|
alias PinchflatWeb.CustomComponents.TextComponents
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a modal.
|
Renders a modal.
|
||||||
@@ -644,7 +645,11 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
<li :for={{k, v} <- @iterable_attributes} class="mb-2 w-2/3">
|
<li :for={{k, v} <- @iterable_attributes} class="mb-2 w-2/3">
|
||||||
<strong><%= k %>:</strong>
|
<strong><%= k %>:</strong>
|
||||||
<code class="inline-block text-sm font-mono text-gray p-0.5 mx-0.5">
|
<code class="inline-block text-sm font-mono text-gray p-0.5 mx-0.5">
|
||||||
|
<%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %>
|
||||||
|
<TextComponents.inline_link href={v}><%= v %></TextComponents.inline_link>
|
||||||
|
<% else %>
|
||||||
<%= v %>
|
<%= v %>
|
||||||
|
<% end %>
|
||||||
</code>
|
</code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
defmodule PinchflatWeb.ErrorHTML do
|
defmodule PinchflatWeb.ErrorHTML do
|
||||||
use PinchflatWeb, :html
|
use PinchflatWeb, :html
|
||||||
|
|
||||||
# If you want to customize your error pages,
|
embed_templates "error_html/*"
|
||||||
# uncomment the embed_templates/1 call below
|
|
||||||
# and add pages to the error directory:
|
|
||||||
#
|
|
||||||
# * lib/pinchflat_web/controllers/error_html/404.html.heex
|
|
||||||
# * lib/pinchflat_web/controllers/error_html/500.html.heex
|
|
||||||
#
|
|
||||||
# embed_templates "error_html/*"
|
|
||||||
|
|
||||||
# The default is to render a plain text page based on
|
# The default is to render a plain text page based on
|
||||||
# the template name. For example, "404.html" becomes
|
# the template name. For example, "404.html" becomes
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<section>
|
||||||
|
<h2 class="text-title-md2 font-bold text-white">404 (not found)</h2>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<section>
|
||||||
|
<h2 class="text-title-md2 font-bold text-white">Internal Server Error</h2>
|
||||||
|
<p class="text-body-md text-white mt-2">
|
||||||
|
This shouldn't happen! Please make a
|
||||||
|
<.inline_link href="https://github.com/kieraneglin/pinchflat/issues/new/choose">GitHub issue</.inline_link>
|
||||||
|
with the following information:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="list-disc ml-8 mb-8">
|
||||||
|
<li>What you were doing when you saw this page</li>
|
||||||
|
<li>
|
||||||
|
Your system details and logs from
|
||||||
|
<.inline_link href={~p"/app_info"}>app info</.inline_link>
|
||||||
|
</li>
|
||||||
|
<li>All the information in the textarea below (use select all + copy)</li>
|
||||||
|
</ul>
|
||||||
|
<textarea class="w-full min-h-96 font-mono inline-block rounded-lg" readonly>
|
||||||
|
**Status**:
|
||||||
|
`<%= if Map.has_key?(assigns, :status), do: @status, else: "" %>`
|
||||||
|
|
||||||
|
**Reason**:
|
||||||
|
`<%= if Map.has_key?(assigns, :reason), do: inspect(@reason), else: "" %>`
|
||||||
|
|
||||||
|
**Stacktrace**:
|
||||||
|
```
|
||||||
|
<%= if Map.has_key?(assigns, :stack), do: Exception.format_stacktrace(@stack), else: "" %>
|
||||||
|
```
|
||||||
|
</textarea>
|
||||||
|
</section>
|
||||||
@@ -5,10 +5,12 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
|||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.Profiles
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
alias Pinchflat.Profiles.MediaProfileDeletionWorker
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
media_profiles =
|
media_profiles =
|
||||||
MediaProfile
|
MediaProfile
|
||||||
|
|> where([mp], is_nil(mp.marked_for_deletion_at))
|
||||||
|> order_by(asc: :name)
|
|> order_by(asc: :name)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|
||||||
@@ -70,19 +72,15 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete(conn, %{"id" => id} = params) do
|
def delete(conn, %{"id" => id} = params) do
|
||||||
delete_files = Map.get(params, "delete_files", false)
|
# This awkward comparison converts the string to a boolean
|
||||||
|
delete_files = Map.get(params, "delete_files", "") == "true"
|
||||||
media_profile = Profiles.get_media_profile!(id)
|
media_profile = Profiles.get_media_profile!(id)
|
||||||
{:ok, _media_profile} = Profiles.delete_media_profile(media_profile, delete_files: delete_files)
|
|
||||||
|
|
||||||
flash_message =
|
{:ok, _} = Profiles.update_media_profile(media_profile, %{marked_for_deletion_at: DateTime.utc_now()})
|
||||||
if delete_files do
|
MediaProfileDeletionWorker.kickoff(media_profile, %{delete_files: delete_files})
|
||||||
"Media profile, its sources, and its files deleted successfully."
|
|
||||||
else
|
|
||||||
"Media profile and its sources deleted successfully. Files were not deleted."
|
|
||||||
end
|
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, flash_message)
|
|> put_flash(:info, "Media Profile deletion started. This may take a while to complete.")
|
||||||
|> redirect(to: ~p"/media_profiles")
|
|> redirect(to: ~p"/media_profiles")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -53,12 +53,24 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def custom_output_template_options do
|
def media_center_custom_output_template_options do
|
||||||
|
%{
|
||||||
|
season_by_year__episode_by_date: "<code>Season YYYY/sYYYYeMMDD</code>",
|
||||||
|
season_by_year__episode_by_date_and_index:
|
||||||
|
"same as the above but it handles dates better. <strong>This is the recommended option</strong>",
|
||||||
|
static_season__episode_by_index:
|
||||||
|
"<code>Season 1/s01eXX</code> where <code>XX</code> is the video's position in the playlist. Only recommended for playlists (not channels) that don't change",
|
||||||
|
static_season__episode_by_date:
|
||||||
|
"<code>Season 1/s01eYYMMDD</code>. Recommended for playlists that might change or where order isn't important"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def other_custom_output_template_options do
|
||||||
%{
|
%{
|
||||||
upload_day: nil,
|
upload_day: nil,
|
||||||
upload_month: nil,
|
upload_month: nil,
|
||||||
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 <code>YYYY-MM-DD</code>",
|
||||||
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_id: "the YouTube ID of the sources that use this profile",
|
||||||
source_collection_name:
|
source_collection_name:
|
||||||
@@ -66,9 +78,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
|
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",
|
||||||
season_from_date: "alias for upload_year",
|
season_from_date: "alias for upload_year",
|
||||||
season_episode_from_date: "the upload date formatted as sYYYYeMMDD",
|
season_episode_from_date: "the upload date formatted as <code>sYYYYeMMDD</code>",
|
||||||
season_episode_index_from_date:
|
season_episode_index_from_date:
|
||||||
"the upload date formatted as sYYYYeMMDDII where II is an index to prevent date collisions"
|
"the upload date formatted as <code>sYYYYeMMDDII</code> where <code>II</code> is an index to prevent date collisions",
|
||||||
|
media_playlist_index:
|
||||||
|
"the place of the media item in the playlist. Do not use with channels. May not work if the playlist is updated"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -77,7 +91,6 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
id
|
id
|
||||||
ext
|
ext
|
||||||
title
|
title
|
||||||
fulltitle
|
|
||||||
uploader
|
uploader
|
||||||
channel
|
channel
|
||||||
upload_date
|
upload_date
|
||||||
@@ -99,7 +112,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp media_center_output_template do
|
defp media_center_output_template do
|
||||||
"/shows/{{ source_custom_name }}/Season {{ season_from_date }}/{{ season_episode_index_from_date }} - {{ title }}.{{ ext }}"
|
"/shows/{{ source_custom_name }}/{{ season_by_year__episode_by_date_and_index }} - {{ title }}.{{ ext }}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp audio_output_template do
|
defp audio_output_template do
|
||||||
|
|||||||
+11
-11
@@ -84,22 +84,22 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: false } }">
|
|
||||||
<.input
|
|
||||||
field={f[:download_auto_subs]}
|
|
||||||
type="toggle"
|
|
||||||
label="Download Autogenerated Subtitles"
|
|
||||||
help="Prefers normal subs but will download autogenerated if needed. Requires 'Download Subtitles' to be enabled"
|
|
||||||
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
|
<section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
|
||||||
<.input
|
<.input
|
||||||
field={f[:embed_subs]}
|
field={f[:embed_subs]}
|
||||||
type="toggle"
|
type="toggle"
|
||||||
label="Embed Subtitles"
|
label="Embed Subtitles"
|
||||||
help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles' (recommended)"
|
help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles'"
|
||||||
|
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: false } }">
|
||||||
|
<.input
|
||||||
|
field={f[:download_auto_subs]}
|
||||||
|
type="toggle"
|
||||||
|
label="Use Autogenerated Subtitles"
|
||||||
|
help="Prefers normal subs with 'Download Subtitles' or 'Embed Subtitles' but will use autogenerated subs if needed."
|
||||||
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
|
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+14
-7
@@ -1,7 +1,7 @@
|
|||||||
<%!-- The heex HTML formatter is really struggling with this file - I apologize in advance --%>
|
<%!-- The heex HTML formatter is really struggling with this file - I apologize in advance --%>
|
||||||
<aside>
|
<aside>
|
||||||
<h2 class="text-xl font-bold mb-2">Output Template Syntax</h2>
|
<h2 class="text-xl font-bold mb-2">Output Template Syntax</h2>
|
||||||
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
|
<section class="ml-2 md:ml-4 mb-4 max-w-prose overflow-auto">
|
||||||
<p>When generating an output template, you have 3 options for syntax:</p>
|
<p>When generating an output template, you have 3 options for syntax:</p>
|
||||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||||
<li>
|
<li>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 class="text-xl font-bold mb-2">Template Options</h2>
|
<h2 class="text-xl font-bold mb-2">Template Options</h2>
|
||||||
<section class="ml-2 md:ml-4 mb-4">
|
<section class="ml-2 md:ml-4 mb-4 overflow-auto">
|
||||||
<p class="max-w-prose">
|
<p class="max-w-prose">
|
||||||
Any single-word <code class="text-sm">yt-dlp</code>
|
Any single-word <code class="text-sm">yt-dlp</code>
|
||||||
option
|
option
|
||||||
@@ -56,16 +56,23 @@
|
|||||||
can be used with the curly braced liquid-style syntax.
|
can be used with the curly braced liquid-style syntax.
|
||||||
This is just a list of the most common options as well as some custom aliases
|
This is just a list of the most common options as well as some custom aliases
|
||||||
</p>
|
</p>
|
||||||
<h3 class="text-lg font-bold mb-2">Custom Aliases</h3>
|
<h3 class="text-lg font-bold my-2">Media Center Custom Aliases</h3>
|
||||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
||||||
<li :for={{k, v} <- custom_output_template_options()}>
|
<li :for={{k, v} <- media_center_custom_output_template_options()} class="mt-1">
|
||||||
<.inline_code>{{ <%= k %> }}</.inline_code>
|
<.inline_code>{{ <%= k %> }}</.inline_code>
|
||||||
<span :if={v}>- <%= v %></span>
|
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<h3 class="text-lg font-bold mb-2">Other Custom Aliases</h3>
|
||||||
|
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
||||||
|
<li :for={{k, v} <- other_custom_output_template_options()} class="mt-1">
|
||||||
|
<.inline_code>{{ <%= k %> }}</.inline_code>
|
||||||
|
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3 class="text-lg font-bold mb-2">Common Options</h3>
|
<h3 class="text-lg font-bold mb-2">Common Options</h3>
|
||||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||||
<li :for={opt <- common_output_template_options()}>
|
<li :for={opt <- common_output_template_options()} class="mt-1">
|
||||||
<.inline_code>{{ <%= opt %> }}</.inline_code>
|
<.inline_code>{{ <%= opt %> }}</.inline_code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
defmodule PinchflatWeb.Settings.SettingHTML do
|
defmodule PinchflatWeb.Settings.SettingHTML do
|
||||||
use PinchflatWeb, :html
|
use PinchflatWeb, :html
|
||||||
|
|
||||||
alias Pinchflat.Downloading.CodecParser
|
|
||||||
|
|
||||||
embed_templates "setting_html/*"
|
embed_templates "setting_html/*"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -16,9 +14,14 @@ defmodule PinchflatWeb.Settings.SettingHTML do
|
|||||||
|
|
||||||
def apprise_server_help do
|
def apprise_server_help do
|
||||||
url = "https://github.com/caronc/apprise/wiki/URLBasics"
|
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)
|
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{help_link_classes()}" target="_blank">Apprise docs</a> for more information)
|
||||||
|
end
|
||||||
|
|
||||||
|
def youtube_api_help do
|
||||||
|
url = "https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key"
|
||||||
|
|
||||||
|
~s(API key for YouTube Data API v3. Greatly improves the accuracy of Fast Indexing. See <a href="#{url}" class="#{help_link_classes()}" target="_blank">here</a> for details on generating an API key)
|
||||||
end
|
end
|
||||||
|
|
||||||
def diagnostic_info_string do
|
def diagnostic_info_string do
|
||||||
@@ -30,4 +33,8 @@ defmodule PinchflatWeb.Settings.SettingHTML do
|
|||||||
- Timezone: #{Application.get_env(:pinchflat, :timezone)}
|
- Timezone: #{Application.get_env(:pinchflat, :timezone)}
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp help_link_classes do
|
||||||
|
"underline decoration-bodydark decoration-1 hover:decoration-white"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<aside>
|
<aside>
|
||||||
<h2 class="text-xl font-bold mb-2">Codec Preferences</h2>
|
<h2 class="text-xl font-bold mb-2">Codec Preference</h2>
|
||||||
<section class="ml-2 md:ml-4 mb-2 max-w-prose">
|
<section class="ml-2 md:ml-4 mb-2 max-w-prose">
|
||||||
<section>
|
<p>
|
||||||
Available video codecs:
|
Some users may want to specify the audio codec that <code class="text-sm">yt-dlp</code>
|
||||||
<ul class="list-disc ml-8">
|
searches for when downloading content. For these users, you can specify one audio and video
|
||||||
<li :for={{codec, _} <- CodecParser.video_codec_map()}><%= codec %></li>
|
codec to prefer above all others.
|
||||||
</ul>
|
</p>
|
||||||
</section>
|
<p class="mt-4">
|
||||||
<section class="mt-4">
|
NOTE: this is a <em>soft</em>
|
||||||
Available audio codecs:
|
preference. If the codec you specify is not available, <code class="text-sm">yt-dlp</code>
|
||||||
<ul class="list-disc ml-8">
|
will download the next best available codec. This is expected and intended behavior.
|
||||||
<li :for={{codec, _} <- CodecParser.audio_codec_map()}><%= codec %></li>
|
<.inline_link href="https://github.com/yt-dlp/yt-dlp#sorting-formats">See here</.inline_link>
|
||||||
</ul>
|
for more information and a list of available codecs.
|
||||||
</section>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
Notification Settings
|
Notification Settings
|
||||||
</h3>
|
</h3>
|
||||||
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
|
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -26,7 +26,25 @@
|
|||||||
) %>
|
) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-10" x-show="advancedMode">
|
<section class="mt-8">
|
||||||
|
<section>
|
||||||
|
<h3 class="text-2xl text-black dark:text-white">
|
||||||
|
Indexing Settings
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={f[:youtube_api_key]}
|
||||||
|
placeholder="ABC123"
|
||||||
|
type="text"
|
||||||
|
label="YouTube API Key"
|
||||||
|
help={youtube_api_help()}
|
||||||
|
html_help={true}
|
||||||
|
inputclass="font-mono text-sm mr-4"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8" x-show="advancedMode">
|
||||||
<section>
|
<section>
|
||||||
<h3 class="text-2xl text-black dark:text-white">
|
<h3 class="text-2xl text-black dark:text-white">
|
||||||
Codec Options
|
Codec Options
|
||||||
@@ -37,24 +55,20 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
id="video_codec_preference_string"
|
field={f[:video_codec_preference]}
|
||||||
name="setting[video_codec_preference_string]"
|
placeholder="avc"
|
||||||
value={Enum.join(f[:video_codec_preference].value, ">")}
|
|
||||||
placeholder="avc>vp9>av01"
|
|
||||||
type="text"
|
type="text"
|
||||||
label="Video Codec Preference"
|
label="Video Codec Preference"
|
||||||
help="Order of preference for video codecs. Separate with >. Will be remuxed into an MP4 container. See below for available codecs"
|
help="Video codec preference. Will be remuxed into an MP4 container. See below for more details"
|
||||||
inputclass="font-mono text-sm mr-4"
|
inputclass="font-mono text-sm mr-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
id="audio_codec_preference_string"
|
field={f[:audio_codec_preference]}
|
||||||
name="setting[audio_codec_preference_string]"
|
placeholder="m4a"
|
||||||
value={Enum.join(f[:audio_codec_preference].value, ">")}
|
|
||||||
placeholder="mp4a>opus>aac"
|
|
||||||
type="text"
|
type="text"
|
||||||
label="Audio Codec Preference"
|
label="Audio Codec Preference"
|
||||||
help="Order of preference for audio codecs. Separate with >. See below for available codecs"
|
help="Audio codec preference. See below for more details"
|
||||||
inputclass="font-mono text-sm mr-4"
|
inputclass="font-mono text-sm mr-4"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
alias Pinchflat.Sources.SourceDeletionWorker
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||||
@@ -17,6 +18,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
from s in Source,
|
from s in Source,
|
||||||
as: :source,
|
as: :source,
|
||||||
inner_join: mp in assoc(s, :media_profile),
|
inner_join: mp in assoc(s, :media_profile),
|
||||||
|
where: is_nil(s.marked_for_deletion_at) and is_nil(mp.marked_for_deletion_at),
|
||||||
preload: [media_profile: mp],
|
preload: [media_profile: mp],
|
||||||
order_by: [asc: s.custom_name],
|
order_by: [asc: s.custom_name],
|
||||||
select: map(s, ^Source.__schema__(:fields)),
|
select: map(s, ^Source.__schema__(:fields)),
|
||||||
@@ -41,13 +43,31 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
render(conn, :index, sources: Repo.all(source_query))
|
render(conn, :index, sources: Repo.all(source_query))
|
||||||
end
|
end
|
||||||
|
|
||||||
def new(conn, _params) do
|
def new(conn, params) do
|
||||||
changeset = Sources.change_source(%Source{})
|
# This lets me preload the settings from another source for more efficient creation
|
||||||
|
cs_struct =
|
||||||
|
case to_string(params["template_id"]) do
|
||||||
|
"" -> %Source{}
|
||||||
|
template_id -> Repo.get(Source, template_id) || %Source{}
|
||||||
|
end
|
||||||
|
|
||||||
render(conn, :new,
|
render(conn, :new,
|
||||||
changeset: changeset,
|
|
||||||
media_profiles: media_profiles(),
|
media_profiles: media_profiles(),
|
||||||
layout: get_onboarding_layout()
|
layout: get_onboarding_layout(),
|
||||||
|
# Most of these don't actually _need_ to be nullified at this point,
|
||||||
|
# but if I don't do it now I know it'll bite me
|
||||||
|
changeset:
|
||||||
|
Sources.change_source(%Source{
|
||||||
|
cs_struct
|
||||||
|
| id: nil,
|
||||||
|
uuid: nil,
|
||||||
|
custom_name: nil,
|
||||||
|
description: nil,
|
||||||
|
collection_name: nil,
|
||||||
|
collection_id: nil,
|
||||||
|
collection_type: nil,
|
||||||
|
original_url: nil
|
||||||
|
})
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -107,19 +127,15 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete(conn, %{"id" => id} = params) do
|
def delete(conn, %{"id" => id} = params) do
|
||||||
delete_files = Map.get(params, "delete_files", false)
|
# This awkward comparison converts the string to a boolean
|
||||||
|
delete_files = Map.get(params, "delete_files", "") == "true"
|
||||||
source = Sources.get_source!(id)
|
source = Sources.get_source!(id)
|
||||||
{:ok, _source} = Sources.delete_source(source, delete_files: delete_files)
|
|
||||||
|
|
||||||
flash_message =
|
{:ok, _} = Sources.update_source(source, %{marked_for_deletion_at: DateTime.utc_now()})
|
||||||
if delete_files do
|
SourceDeletionWorker.kickoff(source, %{delete_files: delete_files})
|
||||||
"Source and files deleted successfully."
|
|
||||||
else
|
|
||||||
"Source deleted successfully. Files were not deleted."
|
|
||||||
end
|
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, flash_message)
|
|> put_flash(:info, "Source deletion started. This may take a while to complete.")
|
||||||
|> redirect(to: ~p"/sources")
|
|> redirect(to: ~p"/sources")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
|||||||
attr :changeset, Ecto.Changeset, required: true
|
attr :changeset, Ecto.Changeset, required: true
|
||||||
attr :action, :string, required: true
|
attr :action, :string, required: true
|
||||||
attr :media_profiles, :list, required: true
|
attr :media_profiles, :list, required: true
|
||||||
|
attr :method, :string, required: true
|
||||||
|
|
||||||
def source_form(assigns)
|
def source_form(assigns)
|
||||||
|
|
||||||
def friendly_index_frequencies do
|
def friendly_index_frequencies do
|
||||||
[
|
[
|
||||||
{"Only once when first created", -1},
|
{"Only once when first created", -1},
|
||||||
|
{"30 minutes", 30},
|
||||||
{"1 Hour", 60},
|
{"1 Hour", 60},
|
||||||
{"3 Hours", 3 * 60},
|
{"3 Hours", 3 * 60},
|
||||||
{"6 Hours", 6 * 60},
|
{"6 Hours", 6 * 60},
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
</span>
|
</span>
|
||||||
</:option>
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link href={~p"/sources/new?template_id=#{@source}"} method="get">
|
||||||
|
Use as Template
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
<:option>
|
<:option>
|
||||||
<div class="h-px w-full bg-bodydark2"></div>
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
</:option>
|
</:option>
|
||||||
|
|||||||
@@ -10,7 +10,12 @@
|
|||||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<div class="flex flex-col gap-10">
|
<div class="flex flex-col gap-10">
|
||||||
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources/#{@source}"} />
|
<.source_form
|
||||||
|
changeset={@changeset}
|
||||||
|
media_profiles={@media_profiles}
|
||||||
|
action={~p"/sources/#{@source}"}
|
||||||
|
method="patch"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,63 @@
|
|||||||
<aside>
|
<aside>
|
||||||
<h2 class="text-xl font-bold mb-2">What is fast indexing (experimental)?</h2>
|
<h2 class="text-2xl font-bold mb-2">What is fast indexing?</h2>
|
||||||
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
|
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
|
||||||
<p>
|
<p>
|
||||||
Indexing is the act of scanning a channel or playlist (aka: source) for new media.
|
Indexing is the act of scanning a channel or playlist (aka: source) for new media.
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
Normal indexing uses <code class="text-sm">yt-dlp</code>
|
Normal indexing uses <code class="text-sm">yt-dlp</code>
|
||||||
to scan the entire source on your specified frequency, but it's very slow for large sources. This is the most accurate way to find uploaded media with the tradeoff being that pairing a large source with a low index frequency will result in you spending most of your time indexing. Only so many indexing operations can be running at the same time, so this can impact your other source's ability to index.
|
to scan the entire source on your specified frequency, but it's very slow for large sources. This is the most accurate way to find uploaded media with the tradeoff being that pairing a large source that's indexed frequently will result in you spending most of your time indexing. Only so many indexing operations can be running at the same time so this can impact your other source's ability to index.
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
Fast indexing takes a different approach. It still does an initial scan the slow way but after that it uses an RSS feed to frequently check for new videos. This has the potential to be hundreds of times faster, but it can miss videos if the uploader un-privates an old video or uploads dozens of videos in the space of a few minutes. It works well for most channels or playlists but it's not perfect.
|
Fast indexing takes a different approach. It still does an initial scan the slow way but after that it uses a secondary mechanism (either RSS or YouTube's API) to frequently check for new videos. This has the potential to be hundreds of times faster, but it can miss videos if the uploader un-privates an old video or uploads dozens of videos in the space of a few minutes.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
RSS is used by default but you should enable the YouTube API if you want the best version of fast indexing. This isn't needed for most users but it provides the fastest and most reliable media updates.
|
||||||
|
<.inline_link href="https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key">
|
||||||
|
Here is some documentation
|
||||||
|
</.inline_link>
|
||||||
|
on how to get your API key which you can add in the
|
||||||
|
<.inline_link href={~p"/settings"}>
|
||||||
|
settings
|
||||||
|
</.inline_link>
|
||||||
|
page.
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
To make up for this limitation, a normal index is still run monthly to catch any videos that were missed by fast indexing. Fast indexing overrides the normal index frequency.
|
To make up for this limitation, a normal index is still run monthly to catch any videos that were missed by fast indexing. Fast indexing overrides the normal index frequency.
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2">
|
<p class="mt-4">
|
||||||
Fast indexing is experimental so please report any issues on GitHub. It's only recommended for sources with over 200-ish videos and that upload frequently. Not recommended for small or inactive sources.
|
<h4 class="font-bold text-xl">TL;DR</h4>
|
||||||
|
|
||||||
|
<strong class="mt-2 inline-block">In general:</strong>
|
||||||
|
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||||
|
<li>
|
||||||
|
Uses RSS by default which is fine for most users
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.inline_link href="https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key">
|
||||||
|
Create a YouTube API key
|
||||||
|
</.inline_link>
|
||||||
|
and add it in your
|
||||||
|
<.inline_link href={~p"/settings"}>
|
||||||
|
settings
|
||||||
|
</.inline_link>
|
||||||
|
for the fastest possible media updates
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<strong class="mt-2 inline-block">Fast indexing is great if any of these apply:</strong>
|
||||||
|
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||||
|
<li>The source is large channel and uploads frequently</li>
|
||||||
|
<li>You want to download a source's new content as soon as possible</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<strong class="mt-2 inline-block">Consider <em>not</em> using fast indexing if any of these apply:</strong>
|
||||||
|
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||||
|
<li>The source is a playlist</li>
|
||||||
|
<li>The source has under 200 videos</li>
|
||||||
|
<li>The source rarely uploads</li>
|
||||||
|
<li>You don't mind if it takes longer for new content to be picked up</li>
|
||||||
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<div class="flex flex-col gap-10">
|
<div class="flex flex-col gap-10">
|
||||||
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources"} />
|
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources"} method="post" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
:let={f}
|
:let={f}
|
||||||
for={@changeset}
|
for={@changeset}
|
||||||
action={@action}
|
action={@action}
|
||||||
|
method={@method}
|
||||||
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
|
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
|
||||||
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
|
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
|
||||||
>
|
>
|
||||||
@@ -10,15 +11,23 @@
|
|||||||
</.error>
|
</.error>
|
||||||
|
|
||||||
<section x-data="{ mediaProfileId: null }">
|
<section x-data="{ mediaProfileId: null }">
|
||||||
<section class="flex justify-between items-center mt-8">
|
<section class="flex justify-between items-center mt-4">
|
||||||
<h3 class=" text-2xl text-black dark:text-white">
|
<h3 class=" text-2xl text-black dark:text-white">
|
||||||
General Options
|
General Options
|
||||||
</h3>
|
</h3>
|
||||||
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
|
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={f[:original_url]}
|
||||||
|
type="text"
|
||||||
|
label="Source URL"
|
||||||
|
help="URL of a channel or playlist (required)"
|
||||||
|
x-init="$el.focus()"
|
||||||
|
/>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:custom_name]}
|
field={f[:custom_name]}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -26,8 +35,6 @@
|
|||||||
help="Does not impact indexing or downloading. Will be inferred from the source if left blank"
|
help="Does not impact indexing or downloading. Will be inferred from the source if left blank"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.input field={f[:original_url]} type="text" label="Source URL" help="URL of a channel or playlist (required)" />
|
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:media_profile_id]}
|
field={f[:media_profile_id]}
|
||||||
options={Enum.map(@media_profiles, &{&1.name, &1.id})}
|
options={Enum.map(@media_profiles, &{&1.name, &1.id})}
|
||||||
@@ -49,7 +56,7 @@
|
|||||||
label="Index Frequency"
|
label="Index Frequency"
|
||||||
x-bind:disabled="fastIndexingEnabled == true"
|
x-bind:disabled="fastIndexingEnabled == true"
|
||||||
x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))"
|
x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))"
|
||||||
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting"
|
help="Indexing is the process of checking for media to download. For best results, set this to the longest delay you can tolerate for this source"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div phx-click={show_modal("upgrade-modal")}>
|
<div phx-click={show_modal("upgrade-modal")}>
|
||||||
@@ -58,7 +65,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. Does not work with private playlists. See below for more info"
|
help="Not recommended for playlists. Overrides 'Index Frequency'. See below for more details (seriously, there's a TL;DR that's worth reading)"
|
||||||
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
|
||||||
@@ -79,6 +86,31 @@
|
|||||||
help="Unchecking still indexes media but it won't be downloaded until you enable this option"
|
help="Unchecking still indexes media but it won't be downloaded until you enable this option"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={f[:use_cookies]}
|
||||||
|
type="toggle"
|
||||||
|
label="Use Cookies for Downloading"
|
||||||
|
help="Uses your YouTube cookies for this source (if configured). Used for downloading private playlists and videos. See docs for important details"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section x-show="advancedMode">
|
||||||
|
<.input
|
||||||
|
field={f[:min_duration_seconds]}
|
||||||
|
type="number"
|
||||||
|
label="Minimum Duration (seconds)"
|
||||||
|
min="0"
|
||||||
|
help="Minimum duration of the media to be downloaded. Can be blank"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={f[:max_duration_seconds]}
|
||||||
|
type="number"
|
||||||
|
label="Maximum Duration (seconds)"
|
||||||
|
min="0"
|
||||||
|
help="Maximum duration of the media to be downloaded. Can be blank"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:download_cutoff_date]}
|
field={f[:download_cutoff_date]}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -87,7 +119,7 @@
|
|||||||
maxlength="10"
|
maxlength="10"
|
||||||
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
|
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
|
||||||
title="YYYY-MM-DD"
|
title="YYYY-MM-DD"
|
||||||
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
|
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format. Old media may be deleted or downloaded if you change this date"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "2024.5.30",
|
version: "2024.8.28",
|
||||||
elixir: "~> 1.16",
|
elixir: "~> 1.17",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
aliases: aliases(),
|
aliases: aliases(),
|
||||||
@@ -71,7 +71,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
{:nimble_parsec, "~> 1.4"},
|
{:nimble_parsec, "~> 1.4"},
|
||||||
{:timex, "~> 3.0"},
|
{:timex, "~> 3.0"},
|
||||||
{:mox, "~> 1.0", only: :test},
|
{:mox, "~> 1.0", only: :test},
|
||||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.7.7", only: [:dev, :test], runtime: false},
|
||||||
{:credo_naming, "~> 2.1", only: [:dev, :test], runtime: false},
|
{:credo_naming, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||||
{:ex_check, "~> 0.14.0", only: [:dev, :test], runtime: false},
|
{:ex_check, "~> 0.14.0", only: [:dev, :test], runtime: false},
|
||||||
{:faker, "~> 0.17", only: :test},
|
{:faker, "~> 0.17", only: :test},
|
||||||
@@ -87,6 +87,8 @@ defmodule Pinchflat.MixProject do
|
|||||||
# See the documentation for `Mix` for more info on aliases.
|
# See the documentation for `Mix` for more info on aliases.
|
||||||
defp aliases do
|
defp aliases do
|
||||||
[
|
[
|
||||||
|
check: "check --config=tooling/.check.exs",
|
||||||
|
credo: "credo --config-file=tooling/.credo.exs",
|
||||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||||
@@ -101,7 +103,8 @@ defmodule Pinchflat.MixProject do
|
|||||||
"ecto.rollback": [
|
"ecto.rollback": [
|
||||||
"ecto.rollback",
|
"ecto.rollback",
|
||||||
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
|
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
|
||||||
]
|
],
|
||||||
|
"version.bump": "cmd ./tooling/version_bump.sh"
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
|
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
|
||||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||||
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
|
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
|
||||||
"credo": {:hex, :credo, "1.7.3", "05bb11eaf2f2b8db370ecaa6a6bda2ec49b2acd5e0418bc106b73b07128c0436", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "35ea675a094c934c22fb1dca3696f3c31f2728ae6ef5a53b5d648c11180a4535"},
|
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
|
||||||
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
|
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
|
||||||
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
|
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
|
||||||
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@
|
|||||||
"sqleton": "^2.2.0"
|
"sqleton": "^2.2.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db"
|
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db",
|
||||||
|
"lint:check": "prettier . --check --config=.prettierrc.js --ignore-path=.prettierignore --ignore-path=.gitignore",
|
||||||
|
"lint:fix": "prettier . --write --config=.prettierrc.js --ignore-path=.prettierignore --ignore-path=.gitignore"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 471 KiB After Width: | Height: | Size: 436 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.RefactorCodecPreferences do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:settings) do
|
||||||
|
remove :video_codec_preference, {:array, :string}, default: []
|
||||||
|
remove :audio_codec_preference, {:array, :string}, default: []
|
||||||
|
end
|
||||||
|
|
||||||
|
alter table(:settings) do
|
||||||
|
add :video_codec_preference, :string, default: "avc", null: false
|
||||||
|
add :audio_codec_preference, :string, default: "m4a", null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddYoutubeApiKeySetting do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :youtube_api_key, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddPlaylistIndexToMediaItems do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:media_items) do
|
||||||
|
add :playlist_index, :integer, null: false, default: 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddMarkedForDeletionAtToSources do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:sources) do
|
||||||
|
add :marked_for_deletion_at, :utc_datetime
|
||||||
|
end
|
||||||
|
|
||||||
|
alter table(:media_profiles) do
|
||||||
|
add :marked_for_deletion_at, :utc_datetime
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddUseCookiesToSources do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:sources) do
|
||||||
|
add :use_cookies, :boolean, default: true, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddDurationLimitsToSources do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:sources) do
|
||||||
|
add :min_duration_seconds, :integer
|
||||||
|
add :max_duration_seconds, :integer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
@@ -1,70 +0,0 @@
|
|||||||
defmodule Pinchflat.Downloading.CodecParserTest do
|
|
||||||
use Pinchflat.DataCase
|
|
||||||
|
|
||||||
alias Pinchflat.Settings
|
|
||||||
alias Pinchflat.Downloading.CodecParser
|
|
||||||
|
|
||||||
describe "generate_vcodec_string_from_settings/1" do
|
|
||||||
test "returns a default vcodec string when setting isn't set" do
|
|
||||||
Settings.set(video_codec_preference: [])
|
|
||||||
|
|
||||||
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string_from_settings()
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates a vcodec string" do
|
|
||||||
Settings.set(video_codec_preference: ["av01"])
|
|
||||||
|
|
||||||
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string_from_settings()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "generate_acodec_string_from_settings/1" do
|
|
||||||
test "returns a default acodec string when setting isn't set" do
|
|
||||||
Settings.set(audio_codec_preference: [])
|
|
||||||
|
|
||||||
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string_from_settings()
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates an acodec string" do
|
|
||||||
Settings.set(audio_codec_preference: ["mp3"])
|
|
||||||
|
|
||||||
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string_from_settings()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "generate_vcodec_string/1" do
|
|
||||||
test "returns a default vcodec string when nil" do
|
|
||||||
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string(nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns a default vcodec string when empty" do
|
|
||||||
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string([])
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates a vcodec string" do
|
|
||||||
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string(["av01"])
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores options that don't exist" do
|
|
||||||
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string(["av01", "foo"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "generate_acodec_string/1" do
|
|
||||||
test "returns a default acodec string when nil" do
|
|
||||||
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string(nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns a default acodec string when empty" do
|
|
||||||
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string([])
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates an acodec string" do
|
|
||||||
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string(["mp3"])
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores options that don't exist" do
|
|
||||||
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string(["mp3", "foo"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -93,15 +93,23 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "includes :write_auto_subs option when specified", %{media_item: media_item} do
|
test "includes :write_auto_subs option when specified", %{media_item: media_item} do
|
||||||
media_item = update_media_profile_attribute(media_item, %{download_subs: true, download_auto_subs: true})
|
media_item_1 = update_media_profile_attribute(media_item, %{download_subs: true, download_auto_subs: true})
|
||||||
|
media_item_2 = update_media_profile_attribute(media_item, %{embed_subs: true, download_auto_subs: true})
|
||||||
|
|
||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res_1} = DownloadOptionBuilder.build(media_item_1)
|
||||||
|
assert {:ok, res_2} = DownloadOptionBuilder.build(media_item_2)
|
||||||
|
|
||||||
assert :write_auto_subs in res
|
assert :write_auto_subs in res_1
|
||||||
|
assert :write_auto_subs in res_2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "doesn't include :write_auto_subs option when download_subs is false", %{media_item: media_item} do
|
test "doesn't include :write_auto_subs option when download_subs and embed_subs is false", %{media_item: media_item} do
|
||||||
media_item = update_media_profile_attribute(media_item, %{download_subs: false, download_auto_subs: true})
|
media_item =
|
||||||
|
update_media_profile_attribute(media_item, %{
|
||||||
|
download_subs: false,
|
||||||
|
embed_subs: false,
|
||||||
|
download_auto_subs: true
|
||||||
|
})
|
||||||
|
|
||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
@@ -256,10 +264,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
|
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
|
||||||
|
|
||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
assert {:format_sort, "res:#{resolution}"} in res
|
|
||||||
|
|
||||||
assert {:format, "((bestvideo[vcodec~='^avc']/bestvideo)+(bestaudio[acodec~='^mp4a']/bestaudio))/best"} in res
|
|
||||||
|
|
||||||
|
assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res
|
||||||
assert {:remux_video, "mp4"} in res
|
assert {:remux_video, "mp4"} in res
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -270,20 +276,20 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
assert :extract_audio in res
|
assert :extract_audio in res
|
||||||
assert {:format, "bestaudio[acodec~='^mp4a']/bestaudio/best"} in res
|
assert {:format_sort, "+acodec:m4a"} in res
|
||||||
|
|
||||||
refute {:remux_video, "mp4"} in res
|
refute {:remux_video, "mp4"} in res
|
||||||
end
|
end
|
||||||
|
|
||||||
test "includes custom quality options if specified", %{media_item: media_item} do
|
test "includes custom quality options if specified", %{media_item: media_item} do
|
||||||
Settings.set(video_codec_preference: ["av01"])
|
Settings.set(video_codec_preference: "av01")
|
||||||
Settings.set(audio_codec_preference: ["aac"])
|
Settings.set(audio_codec_preference: "aac")
|
||||||
|
|
||||||
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"})
|
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"})
|
||||||
|
|
||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
assert {:format, "((bestvideo[vcodec~='^av01']/bestvideo)+(bestaudio[acodec~='^aac']/bestaudio))/best"} in res
|
assert {:format_sort, "res:1080,+codec:av01:aac"} in res
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,6 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
|||||||
_download_prevented =
|
_download_prevented =
|
||||||
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", prevent_download: true)
|
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", prevent_download: true)
|
||||||
|
|
||||||
_culled =
|
|
||||||
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", culled_at: now())
|
|
||||||
|
|
||||||
assert [] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
|
assert [] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
|
||||||
|
|
||||||
refute_enqueued(worker: MediaDownloadWorker)
|
refute_enqueued(worker: MediaDownloadWorker)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
|
||||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||||
|
|
||||||
media_item =
|
media_item =
|
||||||
@@ -52,10 +53,12 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
|
|
||||||
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, _addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
|
||||||
assert media_item.media_filepath == nil
|
assert media_item.media_filepath == nil
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
media_item = Repo.reload(media_item)
|
media_item = Repo.reload(media_item)
|
||||||
@@ -64,10 +67,12 @@ 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, _addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
|
||||||
assert media_item.metadata == nil
|
assert media_item.metadata == nil
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
assert Repo.reload(media_item).metadata != nil
|
assert Repo.reload(media_item).metadata != nil
|
||||||
@@ -137,13 +142,15 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end
|
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, _addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
|
||||||
metadata = render_parsed_metadata(:media_metadata)
|
metadata = render_parsed_metadata(:media_metadata)
|
||||||
FilesystemUtils.write_p!(metadata["filepath"], "test")
|
FilesystemUtils.write_p!(metadata["filepath"], "test")
|
||||||
|
|
||||||
{:ok, Phoenix.json_library().encode!(metadata)}
|
{:ok, Phoenix.json_library().encode!(metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
media_item = Repo.reload(media_item)
|
media_item = Repo.reload(media_item)
|
||||||
|
|
||||||
@@ -151,7 +158,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "does not set redownloaded_at by default", %{media_item: media_item} do
|
test "does not set redownloaded_at by default", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -161,32 +168,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
assert media_item.media_redownloaded_at == nil
|
assert media_item.media_redownloaded_at == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calls the user script runner", %{media_item: media_item} do
|
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
|
||||||
{:ok, render_metadata(:media_metadata)}
|
|
||||||
end)
|
|
||||||
|
|
||||||
expect(UserScriptRunnerMock, :run, fn :media_downloaded, data ->
|
|
||||||
assert data.id == media_item.id
|
|
||||||
|
|
||||||
:ok
|
|
||||||
end)
|
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not blow up if the record doesn't exist" do
|
test "does not blow up if the record doesn't exist" do
|
||||||
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets the no_force_overwrites runner option", %{media_item: media_item} do
|
test "sets the no_force_overwrites runner option", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
|
||||||
assert :no_force_overwrites in opts
|
assert :no_force_overwrites in opts
|
||||||
refute :force_overwrites in opts
|
refute :force_overwrites in opts
|
||||||
|
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -202,23 +197,27 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "sets force_overwrites runner option", %{media_item: media_item} do
|
test "sets force_overwrites runner option", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
|
||||||
assert :force_overwrites in opts
|
assert :force_overwrites in opts
|
||||||
refute :no_force_overwrites in opts
|
refute :no_force_overwrites in opts
|
||||||
|
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1 when testing re-downloads" do
|
describe "perform/1 when testing re-downloads" do
|
||||||
test "sets redownloaded_at on the media_item", %{media_item: media_item} do
|
test "sets redownloaded_at on the media_item", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id, quality_upgrade?: true})
|
perform_job(MediaDownloadWorker, %{id: media_item.id, quality_upgrade?: true})
|
||||||
media_item = Repo.reload(media_item)
|
media_item = Repo.reload(media_item)
|
||||||
|
|
||||||
@@ -226,14 +225,71 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "sets force_overwrites runner option", %{media_item: media_item} do
|
test "sets force_overwrites runner option", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
|
||||||
assert :force_overwrites in opts
|
assert :force_overwrites in opts
|
||||||
refute :no_force_overwrites in opts
|
refute :no_force_overwrites in opts
|
||||||
|
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "perform/1 when testing user script callbacks" do
|
||||||
|
setup do
|
||||||
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calls the media_pre_download user script runner", %{media_item: media_item} do
|
||||||
|
expect(UserScriptRunnerMock, :run, fn :media_pre_download, data ->
|
||||||
|
assert data.id == media_item.id
|
||||||
|
|
||||||
|
{:ok, "", 0}
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(UserScriptRunnerMock, :run, fn :media_downloaded, _ -> {:ok, "", 0} end)
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not download the media if the pre-download script returns an error", %{media_item: media_item} do
|
||||||
|
expect(UserScriptRunnerMock, :run, fn :media_pre_download, _ -> {:ok, "", 1} end)
|
||||||
|
|
||||||
|
assert :ok = perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
|
media_item = Repo.reload!(media_item)
|
||||||
|
|
||||||
|
refute media_item.media_filepath
|
||||||
|
assert media_item.prevent_download
|
||||||
|
end
|
||||||
|
|
||||||
|
test "downloads media if the pre-download script is not present", %{media_item: media_item} do
|
||||||
|
expect(UserScriptRunnerMock, :run, fn :media_pre_download, _ -> {:ok, :no_executable} end)
|
||||||
|
expect(UserScriptRunnerMock, :run, fn :media_downloaded, _ -> {:ok, :no_executable} end)
|
||||||
|
|
||||||
|
assert :ok = perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
|
media_item = Repo.reload!(media_item)
|
||||||
|
|
||||||
|
assert media_item.media_filepath
|
||||||
|
refute media_item.prevent_download
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calls the media_downloaded user script runner", %{media_item: media_item} do
|
||||||
|
expect(UserScriptRunnerMock, :run, fn :media_pre_download, _ -> {:ok, "", 0} end)
|
||||||
|
|
||||||
|
expect(UserScriptRunnerMock, :run, fn :media_downloaded, data ->
|
||||||
|
assert data.id == media_item.id
|
||||||
|
|
||||||
|
{:ok, "", 0}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
import Pinchflat.SourcesFixtures
|
import Pinchflat.SourcesFixtures
|
||||||
import Pinchflat.ProfilesFixtures
|
import Pinchflat.ProfilesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Downloading.MediaDownloader
|
alias Pinchflat.Downloading.MediaDownloader
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@@ -14,29 +15,30 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
[:metadata, source: :media_profile]
|
[:metadata, source: :media_profile]
|
||||||
)
|
)
|
||||||
|
|
||||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||||
{:ok, ""}
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, %{media_item: media_item}}
|
{:ok, %{media_item: media_item}}
|
||||||
end
|
end
|
||||||
|
|
||||||
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 "calls the backend runner", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn url, _opts, ot, addl ->
|
expect(YtDlpRunnerMock, :run, 1, 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 [{:output_filepath, filepath} | _] = addl
|
||||||
assert is_binary(filepath)
|
assert is_binary(filepath)
|
||||||
|
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
|
||||||
|
|
||||||
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 filepath to the database", %{media_item: media_item} do
|
test "saves the metadata filepath to the database", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||||
|
|
||||||
assert updated_media_item.metadata.metadata_filepath =~ "media_items/#{media_item.id}/metadata.json.gz"
|
assert updated_media_item.metadata.metadata_filepath =~ "media_items/#{media_item.id}/metadata.json.gz"
|
||||||
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}/thumbnail.jpg"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-recoverable errors are passed through", %{media_item: media_item} do
|
test "non-recoverable errors are passed through", %{media_item: media_item} do
|
||||||
@@ -67,19 +69,51 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
|
|
||||||
describe "download_for_media_item/3 when testing override options" do
|
describe "download_for_media_item/3 when testing override options" do
|
||||||
test "includes override opts if specified", %{media_item: media_item} do
|
test "includes override opts if specified", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
|
||||||
refute :force_overwrites in opts
|
refute :force_overwrites in opts
|
||||||
assert :no_force_overwrites in opts
|
assert :no_force_overwrites in opts
|
||||||
|
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
|
|
||||||
override_opts = [overwrite_behaviour: :no_force_overwrites]
|
override_opts = [overwrite_behaviour: :no_force_overwrites]
|
||||||
|
|
||||||
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item, override_opts)
|
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item, override_opts)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "download_for_media_item/3 when testing cookie usage" do
|
||||||
|
test "sets use_cookies if the source uses cookies" do
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl ->
|
||||||
|
assert {:use_cookies, true} in addl
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
|
||||||
|
|
||||||
|
source = source_fixture(%{use_cookies: true})
|
||||||
|
media_item = media_item_fixture(%{source_id: source.id})
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not set use_cookies if the source does not use cookies" do
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl ->
|
||||||
|
assert {:use_cookies, false} in addl
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
|
||||||
|
|
||||||
|
source = source_fixture(%{use_cookies: false})
|
||||||
|
media_item = media_item_fixture(%{source_id: source.id})
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "download_for_media_item/3 when testing retries" do
|
describe "download_for_media_item/3 when testing retries" do
|
||||||
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
|
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
|
||||||
message = "Unable to communicate with SponsorBlock"
|
message = "Unable to communicate with SponsorBlock"
|
||||||
@@ -94,13 +128,15 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
|
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
|
||||||
message = "Unable to communicate with SponsorBlock"
|
message = "Unable to communicate with SponsorBlock"
|
||||||
|
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl ->
|
||||||
[{:output_filepath, filepath}] = addl
|
[{:output_filepath, filepath} | _] = addl
|
||||||
File.write(filepath, render_metadata(:media_metadata))
|
File.write(filepath, render_metadata(:media_metadata))
|
||||||
|
|
||||||
{:error, message, 1}
|
{:error, message, 1}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
|
||||||
|
|
||||||
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
|
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
|
||||||
media_item = Repo.reload(media_item)
|
media_item = Repo.reload(media_item)
|
||||||
|
|
||||||
@@ -118,42 +154,48 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it sets the media_downloaded_at", %{media_item: media_item} do
|
test "sets the media_downloaded_at", %{media_item: media_item} do
|
||||||
assert media_item.media_downloaded_at == nil
|
assert media_item.media_downloaded_at == nil
|
||||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||||
assert DateTime.diff(DateTime.utc_now(), updated_media_item.media_downloaded_at) < 2
|
assert DateTime.diff(DateTime.utc_now(), updated_media_item.media_downloaded_at) < 2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it extracts the title", %{media_item: media_item} do
|
test "sets the culled_at to nil", %{media_item: media_item} do
|
||||||
|
Media.update_media_item(media_item, %{culled_at: DateTime.utc_now()})
|
||||||
|
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||||
|
assert updated_media_item.culled_at == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extracts the title", %{media_item: media_item} do
|
||||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||||
assert updated_media_item.title == "Pinchflat Example Video"
|
assert updated_media_item.title == "Pinchflat Example Video"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it extracts the description", %{media_item: media_item} do
|
test "extracts the description", %{media_item: media_item} do
|
||||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||||
assert is_binary(updated_media_item.description)
|
assert is_binary(updated_media_item.description)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it extracts the media_filepath", %{media_item: media_item} do
|
test "extracts the media_filepath", %{media_item: media_item} do
|
||||||
assert media_item.media_filepath == nil
|
assert media_item.media_filepath == nil
|
||||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||||
assert String.ends_with?(updated_media_item.media_filepath, ".mkv")
|
assert String.ends_with?(updated_media_item.media_filepath, ".mkv")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it extracts the subtitle_filepaths", %{media_item: media_item} do
|
test "extracts the subtitle_filepaths", %{media_item: media_item} do
|
||||||
assert media_item.subtitle_filepaths == []
|
assert media_item.subtitle_filepaths == []
|
||||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||||
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
|
test "extracts the duration_seconds", %{media_item: media_item} do
|
||||||
assert media_item.duration_seconds == nil
|
assert media_item.duration_seconds == nil
|
||||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||||
assert is_integer(updated_media_item.duration_seconds)
|
assert is_integer(updated_media_item.duration_seconds)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it extracts the thumbnail_filepath", %{media_item: media_item} do
|
test "extracts the thumbnail_filepath", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
|
||||||
metadata = render_parsed_metadata(:media_metadata)
|
metadata = render_parsed_metadata(:media_metadata)
|
||||||
|
|
||||||
thumbnail_filepath =
|
thumbnail_filepath =
|
||||||
@@ -176,8 +218,8 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
File.rm(updated_media_item.thumbnail_filepath)
|
File.rm(updated_media_item.thumbnail_filepath)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it extracts the metadata_filepath", %{media_item: media_item} do
|
test "extracts the metadata_filepath", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, 2, 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"]
|
||||||
@@ -196,14 +238,14 @@ 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, _addl ->
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it generates an NFO file if the source is set to download NFOs" do
|
test "generates an NFO file if the source is set to download NFOs" do
|
||||||
profile = media_profile_fixture(%{download_nfo: true})
|
profile = media_profile_fixture(%{download_nfo: true})
|
||||||
source = source_fixture(%{media_profile_id: profile.id})
|
source = source_fixture(%{media_profile_id: profile.id})
|
||||||
media_item = media_item_fixture(%{source_id: source.id})
|
media_item = media_item_fixture(%{source_id: source.id})
|
||||||
@@ -216,7 +258,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
File.rm!(updated_media_item.nfo_filepath)
|
File.rm!(updated_media_item.nfo_filepath)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it does not generate an NFO file if the source is set to not download NFOs" do
|
test "does not generate an NFO file if the source is set to not download NFOs" do
|
||||||
profile = media_profile_fixture(%{download_nfo: false})
|
profile = media_profile_fixture(%{download_nfo: false})
|
||||||
source = source_fixture(%{media_profile_id: profile.id})
|
source = source_fixture(%{media_profile_id: profile.id})
|
||||||
media_item = media_item_fixture(%{source_id: source.id})
|
media_item = media_item_fixture(%{source_id: source.id})
|
||||||
|
|||||||
@@ -7,15 +7,36 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Downloading.MediaRetentionWorker
|
alias Pinchflat.Downloading.MediaRetentionWorker
|
||||||
|
|
||||||
describe "perform/1" do
|
|
||||||
setup do
|
setup do
|
||||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "sets deleted media to not re-download" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
refute Repo.reload!(new_media_item).prevent_download
|
||||||
|
assert Repo.reload!(old_media_item).prevent_download
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets culled_at timestamp on deleted media" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
refute Repo.reload!(new_media_item).culled_at
|
||||||
|
assert Repo.reload!(old_media_item).culled_at
|
||||||
|
assert DateTime.diff(now(), Repo.reload!(old_media_item).culled_at) < 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1 when testing retention_period-based culling" do
|
||||||
test "deletes media files that are past their retention date" do
|
test "deletes media files that are past their retention date" do
|
||||||
{_source, old_media_item, new_media_item} = prepare_records()
|
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
@@ -25,27 +46,33 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
refute Repo.reload!(old_media_item).media_filepath
|
refute Repo.reload!(old_media_item).media_filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets deleted media to not re-download" do
|
test "sets culled_at and prevent_download" do
|
||||||
{_source, old_media_item, new_media_item} = prepare_records()
|
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
|
||||||
|
|
||||||
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, %{})
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
refute Repo.reload!(new_media_item).culled_at
|
refute Repo.reload!(new_media_item).culled_at
|
||||||
assert Repo.reload!(old_media_item).culled_at
|
assert Repo.reload!(old_media_item).culled_at
|
||||||
assert DateTime.diff(now(), Repo.reload!(old_media_item).culled_at) < 1
|
refute Repo.reload!(new_media_item).prevent_download
|
||||||
|
assert Repo.reload!(old_media_item).prevent_download
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't cull if the source doesn't have a retention period" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date(nil)
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
assert File.exists?(new_media_item.media_filepath)
|
||||||
|
assert File.exists?(old_media_item.media_filepath)
|
||||||
|
assert Repo.reload!(new_media_item).media_filepath
|
||||||
|
assert Repo.reload!(old_media_item).media_filepath
|
||||||
|
|
||||||
|
refute Repo.reload!(new_media_item).culled_at
|
||||||
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
end
|
end
|
||||||
|
|
||||||
test "doesn't cull media items that have prevent_culling set" do
|
test "doesn't cull media items that have prevent_culling set" do
|
||||||
{_source, old_media_item, _new_media_item} = prepare_records()
|
{_source, old_media_item, _new_media_item} = prepare_records_for_retention_date()
|
||||||
|
|
||||||
Media.update_media_item(old_media_item, %{prevent_culling: true})
|
Media.update_media_item(old_media_item, %{prevent_culling: true})
|
||||||
|
|
||||||
@@ -53,11 +80,99 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
|
|
||||||
assert File.exists?(old_media_item.media_filepath)
|
assert File.exists?(old_media_item.media_filepath)
|
||||||
assert Repo.reload!(old_media_item).media_filepath
|
assert Repo.reload!(old_media_item).media_filepath
|
||||||
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't cull if the media item has no media_filepath" do
|
||||||
|
{_source, old_media_item, _new_media_item} = prepare_records_for_retention_date()
|
||||||
|
|
||||||
|
Media.update_media_item(old_media_item, %{media_filepath: nil})
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp prepare_records do
|
describe "perform/1 when testing source cutoff-based culling" do
|
||||||
source = source_fixture(%{retention_period_days: 2})
|
test "culls media from before the cutoff date" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date()
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
assert File.exists?(new_media_item.media_filepath)
|
||||||
|
refute File.exists?(old_media_item.media_filepath)
|
||||||
|
assert Repo.reload!(new_media_item).media_filepath
|
||||||
|
refute Repo.reload!(old_media_item).media_filepath
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets culled_at but not prevent_download" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date()
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
refute Repo.reload!(new_media_item).culled_at
|
||||||
|
assert Repo.reload!(old_media_item).culled_at
|
||||||
|
refute Repo.reload!(new_media_item).prevent_download
|
||||||
|
refute Repo.reload!(old_media_item).prevent_download
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't cull media if the source doesn't have a cutoff date" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(nil)
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
assert File.exists?(new_media_item.media_filepath)
|
||||||
|
assert File.exists?(old_media_item.media_filepath)
|
||||||
|
assert Repo.reload!(new_media_item).media_filepath
|
||||||
|
assert Repo.reload!(old_media_item).media_filepath
|
||||||
|
|
||||||
|
refute Repo.reload!(new_media_item).culled_at
|
||||||
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't cull media from on or after the cutoff date" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records_for_source_cutoff_date(2)
|
||||||
|
|
||||||
|
Media.update_media_item(old_media_item, %{uploaded_at: now_minus(2, :days)})
|
||||||
|
Media.update_media_item(new_media_item, %{uploaded_at: now_minus(1, :day)})
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
assert File.exists?(new_media_item.media_filepath)
|
||||||
|
assert File.exists?(old_media_item.media_filepath)
|
||||||
|
assert Repo.reload!(new_media_item).media_filepath
|
||||||
|
assert Repo.reload!(old_media_item).media_filepath
|
||||||
|
|
||||||
|
refute Repo.reload!(new_media_item).culled_at
|
||||||
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't cull media items that have prevent_culling set" do
|
||||||
|
{_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date()
|
||||||
|
|
||||||
|
Media.update_media_item(old_media_item, %{prevent_culling: true})
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
assert File.exists?(old_media_item.media_filepath)
|
||||||
|
assert Repo.reload!(old_media_item).media_filepath
|
||||||
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't cull if the media item has no media_filepath" do
|
||||||
|
{_source, old_media_item, _new_media_item} = prepare_records_for_source_cutoff_date()
|
||||||
|
|
||||||
|
Media.update_media_item(old_media_item, %{media_filepath: nil})
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
refute Repo.reload!(old_media_item).culled_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare_records_for_retention_date(retention_period_days \\ 2) do
|
||||||
|
source = source_fixture(%{retention_period_days: retention_period_days})
|
||||||
|
|
||||||
old_media_item =
|
old_media_item =
|
||||||
media_item_with_attachments(%{
|
media_item_with_attachments(%{
|
||||||
@@ -73,4 +188,23 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
|
|
||||||
{source, old_media_item, new_media_item}
|
{source, old_media_item, new_media_item}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp prepare_records_for_source_cutoff_date(download_cutoff_date_days_ago \\ 2) do
|
||||||
|
cutoff_date = if download_cutoff_date_days_ago, do: now_minus(download_cutoff_date_days_ago, :days), else: nil
|
||||||
|
source = source_fixture(%{download_cutoff_date: cutoff_date})
|
||||||
|
|
||||||
|
old_media_item =
|
||||||
|
media_item_with_attachments(%{
|
||||||
|
source_id: source.id,
|
||||||
|
uploaded_at: now_minus(3, :days)
|
||||||
|
})
|
||||||
|
|
||||||
|
new_media_item =
|
||||||
|
media_item_with_attachments(%{
|
||||||
|
source_id: source.id,
|
||||||
|
uploaded_at: now_minus(1, :day)
|
||||||
|
})
|
||||||
|
|
||||||
|
{source, old_media_item, new_media_item}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,19 +6,20 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
import Pinchflat.ProfilesFixtures
|
import Pinchflat.ProfilesFixtures
|
||||||
|
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||||
|
|
||||||
describe "kickoff_download_tasks_from_youtube_rss_feed/1" do
|
|
||||||
setup do
|
setup do
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, media_attributes_return_fixture()}
|
{:ok, media_attributes_return_fixture()}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:ok, [source: source_fixture()]}
|
{:ok, [source: source_fixture()]}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "kickoff_download_tasks_from_youtube_rss_feed/1" 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)
|
||||||
|
|
||||||
@@ -60,19 +61,47 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker")
|
assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sets use_cookies if the source uses cookies" do
|
||||||
|
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||||
|
|
||||||
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
|
||||||
|
assert {:use_cookies, true} in addl
|
||||||
|
|
||||||
|
{:ok, media_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
source = source_fixture(%{use_cookies: true})
|
||||||
|
|
||||||
|
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not set use_cookies if the source does not use cookies" do
|
||||||
|
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||||
|
|
||||||
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
|
||||||
|
assert {:use_cookies, false} in addl
|
||||||
|
|
||||||
|
{:ok, media_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
source = source_fixture(%{use_cookies: false})
|
||||||
|
|
||||||
|
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||||
|
end
|
||||||
|
|
||||||
test "does not enqueue a download job if the media item does not match the format rules" do
|
test "does not enqueue a download job if the media item does not match the format rules" 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)
|
||||||
|
|
||||||
profile = media_profile_fixture(%{shorts_behaviour: :exclude})
|
profile = media_profile_fixture(%{shorts_behaviour: :exclude})
|
||||||
source = source_fixture(%{media_profile_id: profile.id})
|
source = source_fixture(%{media_profile_id: profile.id})
|
||||||
|
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
output =
|
output =
|
||||||
Phoenix.json_library().encode!(%{
|
Phoenix.json_library().encode!(%{
|
||||||
id: "video2",
|
id: "video2",
|
||||||
title: "Video 2",
|
title: "Video 2",
|
||||||
webpage_url: "https://example.com/shorts/video2",
|
webpage_url: "https://example.com/shorts/video2",
|
||||||
was_live: true,
|
live_status: "is_live",
|
||||||
description: "desc2",
|
description: "desc2",
|
||||||
aspect_ratio: 1.67,
|
aspect_ratio: 1.67,
|
||||||
duration: 345.67,
|
duration: 345.67,
|
||||||
@@ -90,7 +119,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
test "does not blow up if a media item cannot be created", %{source: source} do
|
test "does not blow up if a media item cannot be created", %{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)
|
||||||
|
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, "{}"}
|
{:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -100,11 +129,56 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
test "does not blow up if a media item causes a yt-dlp error", %{source: source} do
|
test "does not blow up if a media item causes a yt-dlp error", %{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)
|
||||||
|
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:error, "message", 1}
|
{:error, "message", 1}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "kickoff_download_tasks_from_youtube_rss_feed/1 when testing backends" do
|
||||||
|
test "uses the YouTube API if it is enabled", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn url, _headers ->
|
||||||
|
assert url =~ "https://youtube.googleapis.com/youtube/v3/playlistItems"
|
||||||
|
|
||||||
|
{:ok, "{}"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Settings.set(youtube_api_key: "test_key")
|
||||||
|
|
||||||
|
assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the YouTube API creates records as expected", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn _url, _headers ->
|
||||||
|
{:ok, ~s({ "items": [ {"contentDetails": {"videoId": "test_1"}} ] })}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Settings.set(youtube_api_key: "test_key")
|
||||||
|
|
||||||
|
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "RSS is used as a backup if the API fails", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn _url, _headers -> {:error, ""} end)
|
||||||
|
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||||
|
|
||||||
|
Settings.set(youtube_api_key: "test_key")
|
||||||
|
|
||||||
|
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "RSS is used if the API is not enabled", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn url ->
|
||||||
|
assert url =~ "https://www.youtube.com/feeds/videos.xml"
|
||||||
|
|
||||||
|
{:ok, "<yt:videoId>test_1</yt:videoId>"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Settings.set(youtube_api_key: nil)
|
||||||
|
|
||||||
|
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
|
|||||||
source = source_fixture(fast_index: true)
|
source = source_fixture(fast_index: true)
|
||||||
|
|
||||||
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)
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, render_metadata(:media_metadata)} end)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
|
||||||
|
|
||||||
expect(AppriseRunnerMock, :run, fn servers, opts ->
|
expect(AppriseRunnerMock, :run, fn servers, opts ->
|
||||||
assert "server_1" = servers
|
assert "server_1" = servers
|
||||||
@@ -110,7 +110,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
|
|||||||
source = source_fixture(fast_index: true, download_media: false)
|
source = source_fixture(fast_index: true, download_media: false)
|
||||||
|
|
||||||
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)
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, render_metadata(:media_metadata)} end)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
|
||||||
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
|
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
|
||||||
|
|
||||||
perform_job(FastIndexingWorker, %{id: source.id})
|
perform_job(FastIndexingWorker, %{id: source.id})
|
||||||
@@ -120,7 +120,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
|
|||||||
source = source_fixture(fast_index: true, title_filter_regex: "foobar")
|
source = source_fixture(fast_index: true, title_filter_regex: "foobar")
|
||||||
|
|
||||||
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)
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, render_metadata(:media_metadata)} end)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
|
||||||
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
|
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
|
||||||
|
|
||||||
perform_job(FastIndexingWorker, %{id: source.id})
|
perform_job(FastIndexingWorker, %{id: source.id})
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
defmodule Pinchflat.FastIndexing.YoutubeApiTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Settings
|
||||||
|
alias Pinchflat.FastIndexing.YoutubeApi
|
||||||
|
|
||||||
|
describe "enabled?/0" do
|
||||||
|
test "returns true if the user has set a YouTube API key" do
|
||||||
|
Settings.set(youtube_api_key: "test_key")
|
||||||
|
|
||||||
|
assert YoutubeApi.enabled?()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false if the user has not set an API key" do
|
||||||
|
Settings.set(youtube_api_key: nil)
|
||||||
|
|
||||||
|
refute YoutubeApi.enabled?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_recent_media_ids/1" do
|
||||||
|
setup do
|
||||||
|
source = source_fixture()
|
||||||
|
Settings.set(youtube_api_key: "test_key")
|
||||||
|
|
||||||
|
{:ok, source: source}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calls the expected URL", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn url, headers ->
|
||||||
|
api_base = "https://youtube.googleapis.com/youtube/v3/playlistItems"
|
||||||
|
request_url = "#{api_base}?part=contentDetails&maxResults=50&playlistId=#{source.collection_id}&key=test_key"
|
||||||
|
|
||||||
|
assert url == request_url
|
||||||
|
assert headers == [accept: "application/json"]
|
||||||
|
|
||||||
|
{:ok, "{}"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, _} = YoutubeApi.get_recent_media_ids(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "replaces channel IDs with playlist IDs if needed" do
|
||||||
|
source = source_fixture(collection_id: "UC_ABC123")
|
||||||
|
|
||||||
|
expect(HTTPClientMock, :get, fn url, _headers ->
|
||||||
|
assert url =~ "playlistId=UU_ABC123&"
|
||||||
|
|
||||||
|
{:ok, "{}"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, _} = YoutubeApi.get_recent_media_ids(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns an empty list if no media is returned", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn _url, _headers -> {:ok, "{}"} end)
|
||||||
|
|
||||||
|
assert {:ok, []} = YoutubeApi.get_recent_media_ids(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns media IDs if present", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn _url, _headers ->
|
||||||
|
{:ok,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{"contentDetails": {"videoId": "test_1"}},
|
||||||
|
{"contentDetails": {"videoId": "test_2"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, ["test_1", "test_2"]} = YoutubeApi.get_recent_media_ids(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns an error if the HTTP request fails", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn _url, _headers -> {:error, "error"} end)
|
||||||
|
|
||||||
|
assert {:error, "error"} = YoutubeApi.get_recent_media_ids(source)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,7 +11,13 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, source: source}
|
{:ok, source: source}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_recent_media_ids_from_rss/1" do
|
describe "enabled?/0" do
|
||||||
|
test "returns true" do
|
||||||
|
assert YoutubeRss.enabled?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_recent_media_ids/1" do
|
||||||
test "calls the expected URL for channel sources" do
|
test "calls the expected URL for channel sources" do
|
||||||
source = source_fixture(collection_type: :channel, collection_id: "channel_id")
|
source = source_fixture(collection_type: :channel, collection_id: "channel_id")
|
||||||
|
|
||||||
@@ -21,7 +27,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, ""}
|
{:ok, ""}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calls the expected URL for playlist sources" do
|
test "calls the expected URL for playlist sources" do
|
||||||
@@ -33,13 +39,13 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, ""}
|
{:ok, ""}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns an error if the HTTP request fails", %{source: source} do
|
test "returns an error if the HTTP request fails", %{source: source} do
|
||||||
expect(HTTPClientMock, :get, fn _url -> {:error, ""} end)
|
expect(HTTPClientMock, :get, fn _url -> {:error, ""} end)
|
||||||
|
|
||||||
assert {:error, "Failed to fetch RSS feed"} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
assert {:error, "Failed to fetch RSS feed"} = YoutubeRss.get_recent_media_ids(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns the media IDs from the RSS feed", %{source: source} do
|
test "returns the media IDs from the RSS feed", %{source: source} do
|
||||||
@@ -47,7 +53,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_2</yt:videoId>"}
|
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_2</yt:videoId>"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "strips whitespace from media IDs", %{source: source} do
|
test "strips whitespace from media IDs", %{source: source} do
|
||||||
@@ -55,7 +61,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, "<yt:videoId> test_1 </yt:videoId><yt:videoId> test_2 </yt:videoId>"}
|
{:ok, "<yt:videoId> test_1 </yt:videoId><yt:videoId> test_2 </yt:videoId>"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "removes empty media IDs", %{source: source} do
|
test "removes empty media IDs", %{source: source} do
|
||||||
@@ -63,7 +69,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId></yt:videoId>"}
|
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId></yt:videoId>"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "removes duplicate media IDs", %{source: source} do
|
test "removes duplicate media IDs", %{source: source} do
|
||||||
@@ -71,7 +77,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_1</yt:videoId>"}
|
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_1</yt:videoId>"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids(source)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunnerTest do
|
|||||||
File.write(filepath(), "#!/bin/bash\ntouch #{filename}\n")
|
File.write(filepath(), "#!/bin/bash\ntouch #{filename}\n")
|
||||||
|
|
||||||
refute File.exists?(filename)
|
refute File.exists?(filename)
|
||||||
assert :ok = Runner.run(:media_downloaded, %{})
|
assert {:ok, _, _} = Runner.run(:media_downloaded, %{})
|
||||||
assert File.exists?(filename)
|
assert File.exists?(filename)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunnerTest do
|
|||||||
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
File.write(filepath(), "#!/bin/bash\necho $1 > #{tmp_dir}/event_name\n")
|
File.write(filepath(), "#!/bin/bash\necho $1 > #{tmp_dir}/event_name\n")
|
||||||
|
|
||||||
assert :ok = Runner.run(:media_downloaded, %{})
|
assert {:ok, _, _} = Runner.run(:media_downloaded, %{})
|
||||||
assert File.read!("#{tmp_dir}/event_name") == "media_downloaded\n"
|
assert File.read!("#{tmp_dir}/event_name") == "media_downloaded\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -35,26 +35,32 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunnerTest do
|
|||||||
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
File.write(filepath(), "#!/bin/bash\necho $2 > #{tmp_dir}/encoded_data\n")
|
File.write(filepath(), "#!/bin/bash\necho $2 > #{tmp_dir}/encoded_data\n")
|
||||||
|
|
||||||
assert :ok = Runner.run(:media_downloaded, %{foo: "bar"})
|
assert {:ok, _, _} = Runner.run(:media_downloaded, %{foo: "bar"})
|
||||||
assert File.read!("#{tmp_dir}/encoded_data") == "{\"foo\":\"bar\"}\n"
|
assert File.read!("#{tmp_dir}/encoded_data") == "{\"foo\":\"bar\"}\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does nothing if the lifecycle file is not present" do
|
test "does nothing if the lifecycle file is not present" do
|
||||||
:ok = File.rm(filepath())
|
:ok = File.rm(filepath())
|
||||||
|
|
||||||
assert :ok = Runner.run(:media_downloaded, %{})
|
assert {:ok, :no_executable} = Runner.run(:media_downloaded, %{})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does nothing if the lifecycle file is empty" do
|
test "does nothing if the lifecycle file is empty" do
|
||||||
File.write(filepath(), "")
|
File.write(filepath(), "")
|
||||||
|
|
||||||
assert :ok = Runner.run(:media_downloaded, %{})
|
assert {:ok, :no_executable} = Runner.run(:media_downloaded, %{})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns :ok if the command exits with a non-zero status" do
|
test "returns :ok if the command exits with a non-zero status" do
|
||||||
File.write(filepath(), "#!/bin/bash\nexit 1\n")
|
File.write(filepath(), "#!/bin/bash\nexit 1\n")
|
||||||
|
|
||||||
assert :ok = Runner.run(:media_downloaded, %{})
|
assert {:ok, _, 1} = Runner.run(:media_downloaded, %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns the output of the command" do
|
||||||
|
File.write(filepath(), "#!/bin/bash\necho 'hello'\n")
|
||||||
|
|
||||||
|
assert {:ok, "hello\n", 0} = Runner.run(:media_downloaded, %{})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "gets upset if you pass an invalid event type" do
|
test "gets upset if you pass an invalid event type" do
|
||||||
|
|||||||
+136
-120
@@ -41,99 +41,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_cullable_media_items/0" do
|
describe "list_upgradeable_media_items/0" do
|
||||||
test "returns media items where the source has a retention period" do
|
|
||||||
source_one = source_fixture(%{retention_period_days: 2})
|
|
||||||
source_two = source_fixture(%{retention_period_days: 0})
|
|
||||||
source_three = source_fixture(%{retention_period_days: nil})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source_two.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source_three.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
expected_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source_one.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert Media.list_cullable_media_items() == [expected_media_item]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns media_items with a media_filepath" do
|
|
||||||
source = source_fixture(%{retention_period_days: 2})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: nil,
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
expected_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert Media.list_cullable_media_items() == [expected_media_item]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns items that have passed their retention period" do
|
|
||||||
source = source_fixture(%{retention_period_days: 2})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(2, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
expected_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert Media.list_cullable_media_items() == [expected_media_item]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "doesn't return items that are set to prevent culling" do
|
|
||||||
source = source_fixture(%{retention_period_days: 2})
|
|
||||||
|
|
||||||
_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days),
|
|
||||||
prevent_culling: true
|
|
||||||
})
|
|
||||||
|
|
||||||
expected_media_item =
|
|
||||||
media_item_fixture(%{
|
|
||||||
source_id: source.id,
|
|
||||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
|
||||||
media_downloaded_at: now_minus(3, :days)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert Media.list_cullable_media_items() == [expected_media_item]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "list_redownloadable_media_items/0" do
|
|
||||||
setup do
|
setup do
|
||||||
media_profile = media_profile_fixture(%{redownload_delay_days: 4})
|
media_profile = media_profile_fixture(%{redownload_delay_days: 4})
|
||||||
source = source_fixture(%{media_profile_id: media_profile.id, inserted_at: now_minus(10, :days)})
|
source = source_fixture(%{media_profile_id: media_profile.id, inserted_at: now_minus(10, :days)})
|
||||||
@@ -149,7 +57,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
media_downloaded_at: now_minus(5, :days)
|
media_downloaded_at: now_minus(5, :days)
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == [media_item]
|
assert Media.list_upgradeable_media_items() == [media_item]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns media items that were downloaded in past but still meet redownload delay", %{source: source} do
|
test "returns media items that were downloaded in past but still meet redownload delay", %{source: source} do
|
||||||
@@ -160,7 +68,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
media_downloaded_at: now_minus(19, :days)
|
media_downloaded_at: now_minus(19, :days)
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == [media_item]
|
assert Media.list_upgradeable_media_items() == [media_item]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return media items without a media_downloaded_at", %{source: source} do
|
test "does not return media items without a media_downloaded_at", %{source: source} do
|
||||||
@@ -171,7 +79,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
media_downloaded_at: nil
|
media_downloaded_at: nil
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == []
|
assert Media.list_upgradeable_media_items() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return media items that are set to prevent download", %{source: source} do
|
test "does not return media items that are set to prevent download", %{source: source} do
|
||||||
@@ -183,7 +91,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
prevent_download: true
|
prevent_download: true
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == []
|
assert Media.list_upgradeable_media_items() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return media items that have been culled", %{source: source} do
|
test "does not return media items that have been culled", %{source: source} do
|
||||||
@@ -195,7 +103,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
culled_at: now()
|
culled_at: now()
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == []
|
assert Media.list_upgradeable_media_items() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return media items before the download delay", %{source: source} do
|
test "does not return media items before the download delay", %{source: source} do
|
||||||
@@ -206,7 +114,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
media_downloaded_at: now_minus(3, :days)
|
media_downloaded_at: now_minus(3, :days)
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == []
|
assert Media.list_upgradeable_media_items() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return media items that have already been redownloaded", %{source: source} do
|
test "does not return media items that have already been redownloaded", %{source: source} do
|
||||||
@@ -218,7 +126,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
media_redownloaded_at: now()
|
media_redownloaded_at: now()
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == []
|
assert Media.list_upgradeable_media_items() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return media items that were first downloaded well after the uploaded_at", %{source: source} do
|
test "does not return media items that were first downloaded well after the uploaded_at", %{source: source} do
|
||||||
@@ -229,7 +137,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
uploaded_at: now_minus(20, :days)
|
uploaded_at: now_minus(20, :days)
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == []
|
assert Media.list_upgradeable_media_items() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return media items that were recently uploaded", %{source: source} do
|
test "does not return media items that were recently uploaded", %{source: source} do
|
||||||
@@ -240,7 +148,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
uploaded_at: now_minus(2, :days)
|
uploaded_at: now_minus(2, :days)
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == []
|
assert Media.list_upgradeable_media_items() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return media items without a redownload delay" do
|
test "does not return media items without a redownload delay" do
|
||||||
@@ -254,7 +162,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
media_downloaded_at: now_minus(5, :days)
|
media_downloaded_at: now_minus(5, :days)
|
||||||
})
|
})
|
||||||
|
|
||||||
assert Media.list_redownloadable_media_items() == []
|
assert Media.list_upgradeable_media_items() == []
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -447,6 +355,48 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "list_pending_media_items_for/1 when min and max durations" do
|
||||||
|
test "returns media items that meet the min and max duration" do
|
||||||
|
source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: 20})
|
||||||
|
|
||||||
|
_short_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5})
|
||||||
|
normal_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15})
|
||||||
|
_long_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25})
|
||||||
|
|
||||||
|
assert Media.list_pending_media_items_for(source) == [normal_media_item]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not apply a min duration if none is specified" do
|
||||||
|
source = source_fixture(%{min_duration_seconds: nil, max_duration_seconds: 20})
|
||||||
|
|
||||||
|
short_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5})
|
||||||
|
normal_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15})
|
||||||
|
_long_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25})
|
||||||
|
|
||||||
|
assert Media.list_pending_media_items_for(source) == [short_media_item, normal_media_item]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not apply a max duration if none is specified" do
|
||||||
|
source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: nil})
|
||||||
|
|
||||||
|
_short_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5})
|
||||||
|
normal_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15})
|
||||||
|
long_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25})
|
||||||
|
|
||||||
|
assert Media.list_pending_media_items_for(source) == [normal_media_item, long_media_item]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not apply a min or max duration if none are specified" do
|
||||||
|
source = source_fixture(%{min_duration_seconds: nil, max_duration_seconds: nil})
|
||||||
|
|
||||||
|
short_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5})
|
||||||
|
normal_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15})
|
||||||
|
long_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25})
|
||||||
|
|
||||||
|
assert Media.list_pending_media_items_for(source) == [short_media_item, normal_media_item, long_media_item]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "list_pending_media_items_for/1 when testing download prevention" do
|
describe "list_pending_media_items_for/1 when testing download prevention" do
|
||||||
test "returns only media items that are not prevented from downloading" do
|
test "returns only media items that are not prevented from downloading" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
@@ -526,6 +476,34 @@ defmodule Pinchflat.MediaTest do
|
|||||||
assert Media.pending_download?(media_item)
|
assert Media.pending_download?(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns true if the duration is between the min and max" do
|
||||||
|
source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: 20})
|
||||||
|
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15})
|
||||||
|
|
||||||
|
assert Media.pending_download?(media_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false if the duration is below the min" do
|
||||||
|
source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: 20})
|
||||||
|
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5})
|
||||||
|
|
||||||
|
refute Media.pending_download?(media_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false if the duration is above the max" do
|
||||||
|
source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: 20})
|
||||||
|
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25})
|
||||||
|
|
||||||
|
refute Media.pending_download?(media_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns true if there is no min or max duration" do
|
||||||
|
source = source_fixture(%{min_duration_seconds: nil, max_duration_seconds: nil})
|
||||||
|
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15})
|
||||||
|
|
||||||
|
assert Media.pending_download?(media_item)
|
||||||
|
end
|
||||||
|
|
||||||
test "returns true if the media item is not prevented from downloading" do
|
test "returns true if the media item is not prevented from downloading" do
|
||||||
media_item = media_item_fixture(%{media_filepath: nil, prevent_download: false})
|
media_item = media_item_fixture(%{media_filepath: nil, prevent_download: false})
|
||||||
|
|
||||||
@@ -689,7 +667,24 @@ defmodule Pinchflat.MediaTest do
|
|||||||
assert {:ok, %MediaItem{} = media_item_2} = Media.create_media_item_from_backend_attrs(source, different_attrs)
|
assert {:ok, %MediaItem{} = media_item_2} = Media.create_media_item_from_backend_attrs(source, different_attrs)
|
||||||
|
|
||||||
assert media_item_1.id == media_item_2.id
|
assert media_item_1.id == media_item_2.id
|
||||||
assert media_item_2.title == different_attrs.title
|
assert Repo.reload(media_item_2).title == different_attrs.title
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't update fields like playlist_index" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
media_attrs =
|
||||||
|
media_attributes_return_fixture()
|
||||||
|
|> Phoenix.json_library().decode!()
|
||||||
|
|> Map.put("playlist_index", 1)
|
||||||
|
|> YtDlpMedia.response_to_struct()
|
||||||
|
|
||||||
|
different_attrs = %YtDlpMedia{media_attrs | playlist_index: 9999}
|
||||||
|
|
||||||
|
assert {:ok, %MediaItem{} = _media_item_1} = Media.create_media_item_from_backend_attrs(source, media_attrs)
|
||||||
|
assert {:ok, %MediaItem{} = media_item_2} = Media.create_media_item_from_backend_attrs(source, different_attrs)
|
||||||
|
|
||||||
|
assert Repo.reload(media_item_2).playlist_index == media_attrs.playlist_index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -740,14 +735,13 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "does delete the media item's metadata files" do
|
test "does delete the media item's metadata files" do
|
||||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
|
||||||
|
|
||||||
update_attrs = %{
|
update_attrs = %{
|
||||||
metadata: %{
|
metadata: %{
|
||||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||||
thumbnail_filepath:
|
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,7 +754,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
describe "delete_media_item/2 when testing file deletion" do
|
describe "delete_media_item/2 when testing file deletion" do
|
||||||
setup do
|
setup do
|
||||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
@@ -773,14 +767,13 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "deletes the media item's metadata files" do
|
test "deletes the media item's metadata files" do
|
||||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
|
||||||
|
|
||||||
update_attrs = %{
|
update_attrs = %{
|
||||||
metadata: %{
|
metadata: %{
|
||||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||||
thumbnail_filepath:
|
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,7 +815,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
||||||
assert data.id == media_item.id
|
assert data.id == media_item.id
|
||||||
|
|
||||||
:ok
|
{:ok, "", 0}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _} = Media.delete_media_item(media_item, delete_files: true)
|
assert {:ok, _} = Media.delete_media_item(media_item, delete_files: true)
|
||||||
@@ -831,7 +824,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
describe "delete_media_files/2" do
|
describe "delete_media_files/2" do
|
||||||
setup do
|
setup do
|
||||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
@@ -860,14 +853,13 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "does not delete the media item's metadata files" do
|
test "does not delete the media item's metadata files" do
|
||||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
|
||||||
|
|
||||||
update_attrs = %{
|
update_attrs = %{
|
||||||
metadata: %{
|
metadata: %{
|
||||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||||
thumbnail_filepath:
|
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,7 +887,7 @@ defmodule Pinchflat.MediaTest do
|
|||||||
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
||||||
assert data.id == media_item.id
|
assert data.id == media_item.id
|
||||||
|
|
||||||
:ok
|
{:ok, "", 0}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _} = Media.delete_media_files(media_item)
|
assert {:ok, _} = Media.delete_media_files(media_item)
|
||||||
@@ -970,6 +962,18 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
assert updated_media_item.upload_date_index == 99
|
assert updated_media_item.upload_date_index == 99
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "upload_date_index doesn't increment if the a video's upload_date is changed to the same day" do
|
||||||
|
source = source_fixture(%{collection_type: :channel})
|
||||||
|
|
||||||
|
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
|
||||||
|
_media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
|
||||||
|
|
||||||
|
{:ok, updated_media_item} =
|
||||||
|
Media.update_media_item(media_item_one, %{uploaded_at: now_plus(1, :minute), title: "New title"})
|
||||||
|
|
||||||
|
assert updated_media_item.upload_date_index == 99
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "change_media_item/1 when testing upload_date_index and source is a playlist" do
|
describe "change_media_item/1 when testing upload_date_index and source is a playlist" do
|
||||||
@@ -1033,5 +1037,17 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
assert updated_media_item.upload_date_index == 0
|
assert updated_media_item.upload_date_index == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "upload_date_index doesn't increment if the a video's upload_date is changed to the same day" do
|
||||||
|
source = source_fixture(%{collection_type: :playlist})
|
||||||
|
|
||||||
|
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
|
||||||
|
_media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
|
||||||
|
|
||||||
|
{:ok, updated_media_item} =
|
||||||
|
Media.update_media_item(media_item_one, %{uploaded_at: now_plus(1, :minute), title: "New title"})
|
||||||
|
|
||||||
|
assert updated_media_item.upload_date_index == 0
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,15 +2,26 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
|||||||
use Pinchflat.DataCase
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
import Pinchflat.MediaFixtures
|
import Pinchflat.MediaFixtures
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers, as: Helpers
|
alias Pinchflat.Metadata.MetadataFileHelpers, as: Helpers
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
media_item = media_item_fixture()
|
media_item = Repo.preload(media_item_fixture(), :source)
|
||||||
|
|
||||||
{:ok, %{media_item: media_item}}
|
{:ok, %{media_item: media_item}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "metadata_directory_for/1" do
|
||||||
|
test "returns the metadata directory for the given record", %{media_item: media_item} do
|
||||||
|
base_metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
|
||||||
|
|
||||||
|
metadata_directory = Helpers.metadata_directory_for(media_item)
|
||||||
|
|
||||||
|
assert metadata_directory == Path.join([base_metadata_directory, "media_items", "#{media_item.id}"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "compress_and_store_metadata_for/2" do
|
describe "compress_and_store_metadata_for/2" do
|
||||||
test "returns the filepath", %{media_item: media_item} do
|
test "returns the filepath", %{media_item: media_item} do
|
||||||
metadata_map = %{"foo" => "bar"}
|
metadata_map = %{"foo" => "bar"}
|
||||||
@@ -32,7 +43,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
|||||||
metadata_map = %{"foo" => "bar"}
|
metadata_map = %{"foo" => "bar"}
|
||||||
|
|
||||||
filepath = Helpers.compress_and_store_metadata_for(media_item, metadata_map)
|
filepath = Helpers.compress_and_store_metadata_for(media_item, metadata_map)
|
||||||
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all))
|
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :eof))
|
||||||
|
|
||||||
assert json == Phoenix.json_library().encode!(metadata_map)
|
assert json == Phoenix.json_library().encode!(metadata_map)
|
||||||
end
|
end
|
||||||
@@ -50,82 +61,61 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "download_and_store_thumbnail_for/2" do
|
describe "download_and_store_thumbnail_for/2" do
|
||||||
setup do
|
test "returns the filepath", %{media_item: media_item} do
|
||||||
# This tests that the HTTP endpoint is being called with every test
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
|
||||||
expect(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
|
||||||
{:ok, "thumbnail data"}
|
filepath = Helpers.download_and_store_thumbnail_for(media_item)
|
||||||
|
|
||||||
|
assert filepath =~ ~r{/media_items/#{media_item.id}/thumbnail.jpg}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calls yt-dlp with the expected options", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn url, opts, ot, _addl ->
|
||||||
|
assert url == media_item.original_url
|
||||||
|
assert ot == "after_move:%()j"
|
||||||
|
|
||||||
|
assert opts == [
|
||||||
|
:no_simulate,
|
||||||
|
:skip_download,
|
||||||
|
:write_thumbnail,
|
||||||
|
convert_thumbnail: "jpg",
|
||||||
|
output: "/tmp/test/metadata/media_items/1/thumbnail.%(ext)s"
|
||||||
|
]
|
||||||
|
|
||||||
|
{:ok, ""}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
metadata = render_parsed_metadata(:media_metadata)
|
Helpers.download_and_store_thumbnail_for(media_item)
|
||||||
|
|
||||||
{:ok, %{metadata: metadata}}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns the filepath", %{media_item: media_item, metadata: metadata} do
|
test "sets use_cookies if the source uses cookies" do
|
||||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
|
||||||
|
assert {:use_cookies, true} in addl
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
assert filepath =~ ~r{/media_items/#{media_item.id}/maxresdefault.jpg}
|
source = source_fixture(%{use_cookies: true})
|
||||||
|
media_item = Repo.preload(media_item_fixture(%{source_id: source.id}), :source)
|
||||||
|
|
||||||
|
Helpers.download_and_store_thumbnail_for(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creates folder structure based on passed record", %{media_item: media_item, metadata: metadata} do
|
test "does not set use_cookies if the source does not use cookies" do
|
||||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
|
||||||
|
assert {:use_cookies, false} in addl
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
assert File.exists?(Path.dirname(filepath))
|
source = source_fixture(%{use_cookies: false})
|
||||||
|
media_item = Repo.preload(media_item_fixture(%{source_id: source.id}), :source)
|
||||||
|
|
||||||
|
Helpers.download_and_store_thumbnail_for(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "chooses the highest preference jpg thumbnail available", %{media_item: media_item} do
|
test "returns nil if yt-dlp fails", %{media_item: media_item} do
|
||||||
metadata = %{
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error"} end)
|
||||||
"thumbnails" => [
|
|
||||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.jpg", "preference" => -1},
|
|
||||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_2.jpg", "preference" => 1},
|
|
||||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_3.jpg", "preference" => -10},
|
|
||||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_4.webp", "preference" => 10}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
filepath = Helpers.download_and_store_thumbnail_for(media_item)
|
||||||
|
|
||||||
assert filepath =~ ~r{/media_items/#{media_item.id}/img_2.jpg}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "will fall back to a non-jpg if it has to", %{media_item: media_item} do
|
|
||||||
metadata = %{
|
|
||||||
"thumbnails" => [
|
|
||||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.webp", "preference" => -1}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
|
||||||
|
|
||||||
assert filepath =~ ~r{/media_items/#{media_item.id}/img_1.webp}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not require a preference field", %{media_item: media_item} do
|
|
||||||
metadata = %{
|
|
||||||
"thumbnails" => [
|
|
||||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.webp"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
|
||||||
|
|
||||||
assert filepath =~ ~r{/media_items/#{media_item.id}/img_1.webp}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "download_and_store_thumbnail_for/2 when not downloading thumbnails" do
|
|
||||||
test "returns nil if there are no thumbnails", %{media_item: media_item} do
|
|
||||||
metadata = %{"thumbnails" => []}
|
|
||||||
|
|
||||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
|
||||||
|
|
||||||
assert filepath == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil if there is no thumbnail field", %{media_item: media_item} do
|
|
||||||
metadata = %{}
|
|
||||||
|
|
||||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
|
||||||
|
|
||||||
assert filepath == nil
|
assert filepath == nil
|
||||||
end
|
end
|
||||||
@@ -187,13 +177,20 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "metadata_directory_for/1" do
|
describe "season_and_episode_from_media_filepath/1" do
|
||||||
test "returns the metadata directory for the given record", %{media_item: media_item} do
|
test "returns a season and episode if one can be determined" do
|
||||||
base_metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
|
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/s1e2 - test.mp4")
|
||||||
|
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/S1E2 - test.mp4")
|
||||||
|
assert {:ok, {"001", "002"}} = Helpers.season_and_episode_from_media_filepath("/foo/s001e002 - test.mp4")
|
||||||
|
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/s1e2bar - test.mp4")
|
||||||
|
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/bar s1e2 - test.mp4")
|
||||||
|
end
|
||||||
|
|
||||||
metadata_directory = Helpers.metadata_directory_for(media_item)
|
test "returns an error if a season and episode can't be determined" do
|
||||||
|
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/test.mp4")
|
||||||
assert metadata_directory == Path.join([base_metadata_directory, "media_items", "#{media_item.id}"])
|
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1 - test.mp4")
|
||||||
|
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1e - test.mp4")
|
||||||
|
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1etest.mp4")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
|||||||
test "it extracts the livestream flag", %{metadata: metadata} do
|
test "it extracts the livestream flag", %{metadata: metadata} do
|
||||||
result = Parser.parse_for_media_item(metadata)
|
result = Parser.parse_for_media_item(metadata)
|
||||||
|
|
||||||
assert result.livestream == metadata["was_live"]
|
assert metadata["live_status"] == "not_live"
|
||||||
|
refute result.livestream
|
||||||
end
|
end
|
||||||
|
|
||||||
test "the livestream flag defaults to false", %{metadata: metadata} do
|
test "the livestream flag defaults to false", %{metadata: metadata} do
|
||||||
metadata = Map.put(metadata, "was_live", nil)
|
metadata = Map.put(metadata, "live_status", nil)
|
||||||
|
|
||||||
result = Parser.parse_for_media_item(metadata)
|
result = Parser.parse_for_media_item(metadata)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
|
|||||||
alias Pinchflat.Utils.FilesystemUtils
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
filepath = FilesystemUtils.generate_metadata_tmpfile(:nfo)
|
||||||
|
|
||||||
on_exit(fn -> File.rm!(filepath) end)
|
on_exit(fn -> File.rm!(filepath) end)
|
||||||
|
|
||||||
@@ -45,6 +45,43 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
|
|||||||
|
|
||||||
assert String.contains?(nfo, "hello' & <world>")
|
assert String.contains?(nfo, "hello' & <world>")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "uses the season and episode number from the filepath if it can be determined" do
|
||||||
|
metadata = %{
|
||||||
|
"title" => "title",
|
||||||
|
"uploader" => "uploader",
|
||||||
|
"id" => "id",
|
||||||
|
"description" => "description",
|
||||||
|
"upload_date" => "20210101"
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
|
filepath = Path.join([tmpfile_directory, "foo/s0123e456.nfo"])
|
||||||
|
|
||||||
|
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
|
||||||
|
nfo = File.read!(result)
|
||||||
|
|
||||||
|
assert String.contains?(nfo, "<season>0123</season>")
|
||||||
|
assert String.contains?(nfo, "<episode>456</episode>")
|
||||||
|
|
||||||
|
File.rm!(filepath)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses the upload date if the season and episode number can't be determined", %{filepath: filepath} do
|
||||||
|
metadata = %{
|
||||||
|
"title" => "title",
|
||||||
|
"uploader" => "uploader",
|
||||||
|
"id" => "id",
|
||||||
|
"description" => "description",
|
||||||
|
"upload_date" => "20210101"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
|
||||||
|
nfo = File.read!(result)
|
||||||
|
|
||||||
|
assert String.contains?(nfo, "<season>2021</season>")
|
||||||
|
assert String.contains?(nfo, "<episode>0101</episode>")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "build_and_store_for_source/2" do
|
describe "build_and_store_for_source/2" do
|
||||||
|
|||||||
@@ -75,4 +75,51 @@ defmodule Pinchflat.Metadata.SourceImageParserTest do
|
|||||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "store_source_images/2 when testing fallbacks" do
|
||||||
|
test "uses the entries list for a fallback poster if needed" do
|
||||||
|
metadata = %{
|
||||||
|
"thumbnails" => [],
|
||||||
|
"entries" => [
|
||||||
|
%{
|
||||||
|
"thumbnails" => [%{"filepath" => "/app/test/support/files/channel_photos/a.0.jpg"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
expected = %{
|
||||||
|
poster_filepath: "#{@base_dir}/poster.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't blow up if the entries list doesn't have any suitable thumbnails" do
|
||||||
|
metadata = %{
|
||||||
|
"thumbnails" => [],
|
||||||
|
"entries" => [
|
||||||
|
%{"thumbnails" => [%{"id" => "1"}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't use the entries list if it's empty" do
|
||||||
|
metadata = %{
|
||||||
|
"thumbnails" => [],
|
||||||
|
"entries" => []
|
||||||
|
}
|
||||||
|
|
||||||
|
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't use the entries list if it's not present" do
|
||||||
|
metadata = %{
|
||||||
|
"thumbnails" => []
|
||||||
|
}
|
||||||
|
|
||||||
|
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
test "won't call itself in an infinite loop" do
|
test "won't call itself in an infinite loop" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||||
_url, _opts, ot when ot == @metadata_ot -> {:ok, "{}"}
|
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
@@ -51,8 +51,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
describe "perform/1 when testing attribute updates" do
|
describe "perform/1 when testing attribute updates" do
|
||||||
test "the source description is saved" do
|
test "the source description is saved" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||||
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
|
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
source = source_fixture(%{description: nil})
|
source = source_fixture(%{description: nil})
|
||||||
@@ -68,8 +68,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
describe "perform/1 when testing metadata storage" do
|
describe "perform/1 when testing metadata storage" do
|
||||||
test "sets metadata location for source" do
|
test "sets metadata location for source" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||||
_url, _opts, ot when ot == @metadata_ot -> {:ok, "{}"}
|
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
source = Repo.preload(source_fixture(), :metadata)
|
source = Repo.preload(source_fixture(), :metadata)
|
||||||
@@ -87,8 +87,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
file_contents = Phoenix.json_library().encode!(%{"title" => "test"})
|
file_contents = Phoenix.json_library().encode!(%{"title" => "test"})
|
||||||
|
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||||
_url, _opts, ot when ot == @metadata_ot -> {:ok, file_contents}
|
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, file_contents}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
@@ -100,8 +100,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
|
|
||||||
test "sets metadata image location for source" do
|
test "sets metadata image location for source" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||||
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
|
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
@@ -118,8 +118,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
|
|
||||||
test "stores metadata images for source" do
|
test "stores metadata images for source" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
|
||||||
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
|
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
@@ -138,12 +138,14 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
describe "perform/1 when testing source image downloading" do
|
describe "perform/1 when testing source image downloading" do
|
||||||
test "downloads and stores source images" do
|
test "downloads and stores source images" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot ->
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||||
|
|
||||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
|
|
||||||
_url, _opts, ot when ot == @metadata_ot ->
|
_url, opts, ot, _addl when ot == @metadata_ot ->
|
||||||
|
assert {:convert_thumbnails, "jpg"} in opts
|
||||||
|
|
||||||
{:ok, render_metadata(:channel_source_metadata)}
|
{:ok, render_metadata(:channel_source_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -164,14 +166,50 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
Sources.delete_source(source, delete_files: true)
|
Sources.delete_source(source, delete_files: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "calls one set of yt-dlp metadata opts for channels" do
|
||||||
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
|
{:ok, source_details_return_fixture()}
|
||||||
|
|
||||||
|
_url, opts, ot, _addl when ot == @metadata_ot ->
|
||||||
|
assert {:playlist_items, 0} in opts
|
||||||
|
assert :write_all_thumbnails in opts
|
||||||
|
|
||||||
|
{:ok, render_metadata(:channel_source_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
profile = media_profile_fixture(%{download_source_images: true})
|
||||||
|
source = source_fixture(media_profile_id: profile.id, collection_type: :channel)
|
||||||
|
|
||||||
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calls another set of yt-dlp metadata opts for playlists" do
|
||||||
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
|
{:ok, source_details_return_fixture()}
|
||||||
|
|
||||||
|
_url, opts, ot, _addl when ot == @metadata_ot ->
|
||||||
|
assert {:playlist_items, 1} in opts
|
||||||
|
assert :write_thumbnail in opts
|
||||||
|
|
||||||
|
{:ok, render_metadata(:channel_source_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
profile = media_profile_fixture(%{download_source_images: true})
|
||||||
|
source = source_fixture(media_profile_id: profile.id, collection_type: :playlist)
|
||||||
|
|
||||||
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
|
end
|
||||||
|
|
||||||
test "does not store source images if the profile is not set to" do
|
test "does not store source images if the profile is not set to" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot ->
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||||
|
|
||||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
|
|
||||||
_url, _opts, ot when ot == @metadata_ot ->
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
{:ok, render_metadata(:channel_source_metadata)}
|
{:ok, render_metadata(:channel_source_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -188,12 +226,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
|
|
||||||
test "does not store source images if the series directory cannot be determined" do
|
test "does not store source images if the series directory cannot be determined" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot ->
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
||||||
|
|
||||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
|
|
||||||
_url, _opts, ot when ot == @metadata_ot ->
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
{:ok, render_metadata(:channel_source_metadata)}
|
{:ok, render_metadata(:channel_source_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -207,17 +245,49 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
refute source.poster_filepath
|
refute source.poster_filepath
|
||||||
refute source.banner_filepath
|
refute source.banner_filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sets use_cookies if the source uses cookies" do
|
||||||
|
expect(YtDlpRunnerMock, :run, 2, fn
|
||||||
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
|
{:ok, source_details_return_fixture()}
|
||||||
|
|
||||||
|
_url, _opts, ot, addl when ot == @metadata_ot ->
|
||||||
|
assert {:use_cookies, true} in addl
|
||||||
|
{:ok, render_metadata(:channel_source_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
profile = media_profile_fixture(%{download_source_images: true})
|
||||||
|
source = source_fixture(media_profile_id: profile.id, use_cookies: true)
|
||||||
|
|
||||||
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not set use_cookies if the source does not use cookies" do
|
||||||
|
expect(YtDlpRunnerMock, :run, 2, fn
|
||||||
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
|
{:ok, source_details_return_fixture()}
|
||||||
|
|
||||||
|
_url, _opts, ot, addl when ot == @metadata_ot ->
|
||||||
|
assert {:use_cookies, false} in addl
|
||||||
|
{:ok, render_metadata(:channel_source_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
profile = media_profile_fixture(%{download_source_images: true})
|
||||||
|
source = source_fixture(media_profile_id: profile.id, use_cookies: false)
|
||||||
|
|
||||||
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1 when determining the series_directory" do
|
describe "perform/1 when determining the series_directory" do
|
||||||
test "sets the series directory based on the returned media filepath" do
|
test "sets the series directory based on the returned media filepath" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot ->
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||||
|
|
||||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
|
|
||||||
_url, _opts, ot when ot == @metadata_ot ->
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
{:ok, "{}"}
|
{:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -230,12 +300,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
|
|
||||||
test "does not set the series directory if it cannot be determined" do
|
test "does not set the series directory if it cannot be determined" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot ->
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
||||||
|
|
||||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
|
|
||||||
_url, _opts, ot when ot == @metadata_ot ->
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
{:ok, "{}"}
|
{:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -245,17 +315,45 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
|
|
||||||
refute source.series_directory
|
refute source.series_directory
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sets use_cookies if the source is set to use cookies" do
|
||||||
|
expect(YtDlpRunnerMock, :run, 2, fn
|
||||||
|
_url, _opts, ot, addl when ot == @source_details_ot ->
|
||||||
|
assert {:use_cookies, true} in addl
|
||||||
|
{:ok, source_details_return_fixture()}
|
||||||
|
|
||||||
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
|
{:ok, "{}"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
source = source_fixture(%{series_directory: nil, use_cookies: true})
|
||||||
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not set use_cookies if the source is not set to use cookies" do
|
||||||
|
expect(YtDlpRunnerMock, :run, 2, fn
|
||||||
|
_url, _opts, ot, addl when ot == @source_details_ot ->
|
||||||
|
assert {:use_cookies, false} in addl
|
||||||
|
{:ok, source_details_return_fixture()}
|
||||||
|
|
||||||
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
|
{:ok, "{}"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
source = source_fixture(%{series_directory: nil, use_cookies: false})
|
||||||
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1 when storing the series NFO" do
|
describe "perform/1 when storing the series NFO" do
|
||||||
test "stores the NFO if specified" do
|
test "stores the NFO if specified" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot ->
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||||
|
|
||||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
|
|
||||||
_url, _opts, ot when ot == @metadata_ot ->
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
{:ok, "{}"}
|
{:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -273,12 +371,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
|
|
||||||
test "does not store the NFO if not specified" do
|
test "does not store the NFO if not specified" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot ->
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
|
||||||
|
|
||||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
|
|
||||||
_url, _opts, ot when ot == @metadata_ot ->
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
{:ok, "{}"}
|
{:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -292,12 +390,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
|
|
||||||
test "does not store the NFO if the series directory cannot be determined" do
|
test "does not store the NFO if the series directory cannot be determined" do
|
||||||
stub(YtDlpRunnerMock, :run, fn
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
_url, _opts, ot when ot == @source_details_ot ->
|
_url, _opts, ot, _addl when ot == @source_details_ot ->
|
||||||
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
||||||
|
|
||||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
|
|
||||||
_url, _opts, ot when ot == @metadata_ot ->
|
_url, _opts, ot, _addl when ot == @metadata_ot ->
|
||||||
{:ok, "{}"}
|
{:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
defmodule Pinchflat.Profiles.MediaProfileDeletionWorkerTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Pinchflat.MediaFixtures
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
import Pinchflat.ProfilesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Profiles.MediaProfileDeletionWorker
|
||||||
|
|
||||||
|
setup do
|
||||||
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||||
|
|
||||||
|
{:ok, %{profile: media_profile_fixture()}}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "kickoff/3" do
|
||||||
|
test "starts the worker", %{profile: profile} do
|
||||||
|
assert [] = all_enqueued(worker: MediaProfileDeletionWorker)
|
||||||
|
assert {:ok, _} = MediaProfileDeletionWorker.kickoff(profile)
|
||||||
|
assert [_] = all_enqueued(worker: MediaProfileDeletionWorker)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job arguments", %{profile: profile} do
|
||||||
|
job_args = %{"delete_files" => true}
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaProfileDeletionWorker.kickoff(profile, job_args)
|
||||||
|
|
||||||
|
assert_enqueued(worker: MediaProfileDeletionWorker, args: %{"id" => profile.id, "delete_files" => true})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "deletes the profile, sources, and media but leaves the files", %{profile: profile} do
|
||||||
|
source = source_fixture(%{media_profile_id: profile.id})
|
||||||
|
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|
||||||
|
perform_job(MediaProfileDeletionWorker, %{"id" => profile.id})
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(profile) end
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
|
||||||
|
assert File.exists?(media_item.media_filepath)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes the profile, sources, and media and files if specified", %{profile: profile} do
|
||||||
|
source = source_fixture(%{media_profile_id: profile.id})
|
||||||
|
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|
||||||
|
perform_job(MediaProfileDeletionWorker, %{"id" => profile.id, "delete_files" => true})
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(profile) end
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
|
||||||
|
refute File.exists?(media_item.media_filepath)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -113,7 +113,7 @@ defmodule Pinchflat.ProfilesTest do
|
|||||||
|
|
||||||
describe "delete_media_profile/2 when deleting files" do
|
describe "delete_media_profile/2 when deleting files" do
|
||||||
setup do
|
setup do
|
||||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -78,64 +78,4 @@ defmodule Pinchflat.SettingsTest do
|
|||||||
assert %Ecto.Changeset{} = Settings.change_setting(setting, %{onboarding: true})
|
assert %Ecto.Changeset{} = Settings.change_setting(setting, %{onboarding: true})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "change_setting/2 when testing codec preferences" do
|
|
||||||
test "converts (video|audio)_codec_preference_string to an array" do
|
|
||||||
setting = Settings.record()
|
|
||||||
|
|
||||||
new_setting = %{
|
|
||||||
video_codec_preference_string: "avc>vp9",
|
|
||||||
audio_codec_preference_string: "aac>opus"
|
|
||||||
}
|
|
||||||
|
|
||||||
changeset = Settings.change_setting(setting, new_setting)
|
|
||||||
|
|
||||||
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
|
|
||||||
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
|
|
||||||
end
|
|
||||||
|
|
||||||
test "removes whitespace from (video|audio)_codec_preference" do
|
|
||||||
setting = Settings.record()
|
|
||||||
|
|
||||||
new_setting = %{
|
|
||||||
video_codec_preference_string: " avc > > vp9 ",
|
|
||||||
audio_codec_preference_string: "aac> opus "
|
|
||||||
}
|
|
||||||
|
|
||||||
changeset = Settings.change_setting(setting, new_setting)
|
|
||||||
|
|
||||||
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
|
|
||||||
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
|
|
||||||
end
|
|
||||||
|
|
||||||
test "downcases (video|audio)_codec_preference" do
|
|
||||||
setting = Settings.record()
|
|
||||||
|
|
||||||
new_setting = %{
|
|
||||||
video_codec_preference_string: "AVC>VP9",
|
|
||||||
audio_codec_preference_string: "AAC>OPUS"
|
|
||||||
}
|
|
||||||
|
|
||||||
changeset = Settings.change_setting(setting, new_setting)
|
|
||||||
|
|
||||||
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
|
|
||||||
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
|
|
||||||
end
|
|
||||||
|
|
||||||
test "an empty value will remove the codec settings" do
|
|
||||||
Settings.set(video_codec_preference: ["avc", "vp9"])
|
|
||||||
Settings.set(audio_codec_preference: ["aac", "opus"])
|
|
||||||
setting = Settings.record()
|
|
||||||
|
|
||||||
new_setting = %{
|
|
||||||
video_codec_preference_string: "",
|
|
||||||
audio_codec_preference_string: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
changeset = Settings.change_setting(setting, new_setting)
|
|
||||||
|
|
||||||
assert [] = changeset.changes.video_codec_preference
|
|
||||||
assert [] = changeset.changes.audio_codec_preference
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
||||||
|
|
||||||
describe "kickoff_indexing_task/3" do
|
describe "kickoff_indexing_task/3" do
|
||||||
test "it schedules a job" do
|
test "schedules a job" do
|
||||||
source = source_fixture(index_frequency_minutes: 1)
|
source = source_fixture(index_frequency_minutes: 1)
|
||||||
|
|
||||||
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source)
|
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source)
|
||||||
@@ -23,7 +23,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it creates and attaches a task" do
|
test "creates and attaches a task" do
|
||||||
source = source_fixture(index_frequency_minutes: 1)
|
source = source_fixture(index_frequency_minutes: 1)
|
||||||
|
|
||||||
assert {:ok, %Task{} = task} = SlowIndexingHelpers.kickoff_indexing_task(source)
|
assert {:ok, %Task{} = task} = SlowIndexingHelpers.kickoff_indexing_task(source)
|
||||||
@@ -31,7 +31,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert task.source_id == source.id
|
assert task.source_id == source.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it deletes any pending media collection tasks for the source" do
|
test "deletes any pending media collection tasks for the source" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
|
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
|
||||||
task = task_fixture(source_id: source.id, job_id: job.id)
|
task = task_fixture(source_id: source.id, job_id: job.id)
|
||||||
@@ -41,7 +41,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it deletes any pending media tasks for the source" do
|
test "deletes any pending media tasks for the source" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
|
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
|
||||||
task = task_fixture(source_id: source.id, job_id: job.id)
|
task = task_fixture(source_id: source.id, job_id: job.id)
|
||||||
@@ -51,7 +51,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it deletes any fast indexing tasks for the source" do
|
test "deletes any fast indexing tasks for the source" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
|
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
|
||||||
task = task_fixture(source_id: source.id, job_id: job.id)
|
task = task_fixture(source_id: source.id, job_id: job.id)
|
||||||
@@ -90,7 +90,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
{:ok, [source: source_fixture()]}
|
{:ok, [source: source_fixture()]}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it creates a media_item record for each media ID returned", %{source: source} do
|
test "creates a media_item record for each media ID returned", %{source: source} do
|
||||||
assert media_items = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
assert media_items = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
|
||||||
assert Enum.count(media_items) == 3
|
assert Enum.count(media_items) == 3
|
||||||
@@ -101,7 +101,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert Enum.all?(media_items, fn %MediaItem{} -> true end)
|
assert Enum.all?(media_items, fn %MediaItem{} -> true end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it attaches all media_items to the given source", %{source: source} do
|
test "attaches all media_items to the given source", %{source: source} do
|
||||||
source_id = source.id
|
source_id = source.id
|
||||||
assert media_items = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
assert media_items = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert Enum.all?(media_items, fn %MediaItem{source_id: ^source_id} -> true end)
|
assert Enum.all?(media_items, fn %MediaItem{source_id: ^source_id} -> true end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it won't duplicate media_items based on media_id and source", %{source: source} do
|
test "won't duplicate media_items based on media_id and source", %{source: source} do
|
||||||
_first_run = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
_first_run = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
_duplicate_run = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
_duplicate_run = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert Enum.count(media_items) == 3
|
assert Enum.count(media_items) == 3
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it can duplicate media_ids for different sources", %{source: source} do
|
test "can duplicate media_ids for different sources", %{source: source} do
|
||||||
other_source = source_fixture()
|
other_source = source_fixture()
|
||||||
|
|
||||||
media_items = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
media_items = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
@@ -130,7 +130,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
Enum.map(media_items_other_source, & &1.media_id)
|
Enum.map(media_items_other_source, & &1.media_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it returns a list of media_items", %{source: source} do
|
test "returns a list of media_items", %{source: source} do
|
||||||
first_run = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
first_run = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
duplicate_run = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
duplicate_run = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert first_ids == duplicate_ids
|
assert first_ids == duplicate_ids
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it updates the source's last_indexed_at field", %{source: source} do
|
test "updates the source's last_indexed_at field", %{source: source} do
|
||||||
assert source.last_indexed_at == nil
|
assert source.last_indexed_at == nil
|
||||||
|
|
||||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
@@ -149,7 +149,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert DateTime.diff(DateTime.utc_now(), source.last_indexed_at) < 2
|
assert DateTime.diff(DateTime.utc_now(), source.last_indexed_at) < 2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it enqueues a job for each pending media item" do
|
test "enqueues a job for each pending media item" do
|
||||||
source = source_fixture()
|
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: nil)
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it does not attach tasks if the source is set to not download" do
|
test "does not attach tasks if the source is set to not download" do
|
||||||
source = source_fixture(download_media: false)
|
source = source_fixture(download_media: false)
|
||||||
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
|
media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
|
||||||
|
|
||||||
@@ -167,13 +167,13 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert [] = Tasks.list_tasks_for(media_item)
|
assert [] = Tasks.list_tasks_for(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it doesn't blow up if a media item cannot be coerced into a struct", %{source: source} do
|
test "doesn't blow up if a media item cannot be coerced into a struct", %{source: source} do
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
|
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
|
||||||
response =
|
response =
|
||||||
Phoenix.json_library().encode!(%{
|
Phoenix.json_library().encode!(%{
|
||||||
id: "video3",
|
id: "video3",
|
||||||
title: "Video 3",
|
title: "Video 3",
|
||||||
was_live: false,
|
live_status: "not_live",
|
||||||
description: "desc3",
|
description: "desc3",
|
||||||
# Only focusing on these because these are passed to functions that
|
# Only focusing on these because these are passed to functions that
|
||||||
# could fail if they're not present
|
# could fail if they're not present
|
||||||
@@ -190,6 +190,28 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
|
|
||||||
assert %Ecto.Changeset{} = changeset
|
assert %Ecto.Changeset{} = changeset
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sets use_cookies if the source uses cookies" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
|
||||||
|
assert {:use_cookies, true} in addl_opts
|
||||||
|
{:ok, source_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
source = source_fixture(%{use_cookies: true})
|
||||||
|
|
||||||
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't set use_cookies if the source doesn't use cookies" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
|
||||||
|
assert {:use_cookies, false} in addl_opts
|
||||||
|
{:ok, source_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
source = source_fixture(%{use_cookies: false})
|
||||||
|
|
||||||
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "index_and_enqueue_download_for_media_items/1 when testing file watcher" do
|
describe "index_and_enqueue_download_for_media_items/1 when testing file watcher" do
|
||||||
@@ -267,7 +289,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
id: "video2",
|
id: "video2",
|
||||||
title: "Video 2",
|
title: "Video 2",
|
||||||
webpage_url: "https://example.com/shorts/video2",
|
webpage_url: "https://example.com/shorts/video2",
|
||||||
was_live: true,
|
live_status: "is_live",
|
||||||
description: "desc2",
|
description: "desc2",
|
||||||
aspect_ratio: 1.67,
|
aspect_ratio: 1.67,
|
||||||
duration: 345.67,
|
duration: 345.67,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
defmodule Pinchflat.Sources.SourceDeletionWorkerTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Pinchflat.MediaFixtures
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Sources.SourceDeletionWorker
|
||||||
|
|
||||||
|
setup do
|
||||||
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||||
|
|
||||||
|
{:ok, %{source: source_fixture()}}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "kickoff/3" do
|
||||||
|
test "starts the worker", %{source: source} do
|
||||||
|
assert [] = all_enqueued(worker: SourceDeletionWorker)
|
||||||
|
assert {:ok, _} = SourceDeletionWorker.kickoff(source)
|
||||||
|
assert [_] = all_enqueued(worker: SourceDeletionWorker)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job arguments", %{source: source} do
|
||||||
|
job_args = %{"delete_files" => true}
|
||||||
|
|
||||||
|
assert {:ok, _} = SourceDeletionWorker.kickoff(source, job_args)
|
||||||
|
|
||||||
|
assert_enqueued(worker: SourceDeletionWorker, args: %{"id" => source.id, "delete_files" => true})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "deletes the source but leaves the files", %{source: source} do
|
||||||
|
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|
||||||
|
perform_job(SourceDeletionWorker, %{"id" => source.id})
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
|
||||||
|
assert File.exists?(media_item.media_filepath)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes the source and files if specified", %{source: source} do
|
||||||
|
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|
||||||
|
perform_job(SourceDeletionWorker, %{"id" => source.id, "delete_files" => true})
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(source) end
|
||||||
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
|
||||||
|
refute File.exists?(media_item.media_filepath)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user