Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5bf51dfb3 | |||
| 5d624f545b | |||
| 1305a485a0 |
@@ -16,7 +16,7 @@
|
|||||||
{:compiler, env: %{"MIX_ENV" => "test"}},
|
{:compiler, env: %{"MIX_ENV" => "test"}},
|
||||||
{:formatter, env: %{"MIX_ENV" => "test"}},
|
{:formatter, env: %{"MIX_ENV" => "test"}},
|
||||||
{:sobelow, "mix sobelow --config"},
|
{:sobelow, "mix sobelow --config"},
|
||||||
{:prettier_formatting, "yarn run lint:check", fix: "yarn run lint:fix"},
|
{:prettier_formatting, "yarn run prettier . --check", fix: "yarn run prettier . --write"},
|
||||||
{:npm_test, false}
|
{:npm_test, false}
|
||||||
|
|
||||||
## curated tools may be disabled (e.g. the check for compilation warnings)
|
## curated tools may be disabled (e.g. the check for compilation warnings)
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# 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,12 +11,6 @@ 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
|
||||||
@@ -47,8 +41,7 @@ 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' && github.event_name != 'workflow_dispatch' }}
|
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
|
||||||
type=raw,value=${{ inputs.docker_tags }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=auto
|
latest=auto
|
||||||
|
|
||||||
@@ -75,7 +68,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/selfhosted.Dockerfile
|
file: ./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,28 +16,24 @@ 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@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Pull prebuilt images
|
- name: Pull prebuilt images
|
||||||
run: docker compose pull
|
run: docker compose pull
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Setup Docker layer caching
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: jpribyl/action-docker-layer-caching@v0.1.1
|
||||||
|
continue-on-error: true
|
||||||
- name: Build docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
with:
|
||||||
context: .
|
key: ci-docker-cache-{hash}
|
||||||
file: ./docker/dev.Dockerfile
|
restore-keys: |
|
||||||
load: true
|
ci-docker-cache-
|
||||||
cache-from: type=gha
|
layer-ci-docker-cache-
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Run Docker image
|
- name: Build and 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
|
||||||
@@ -47,7 +43,8 @@ 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 mix deps.get && yarn install && cd assets && yarn install && cd ..
|
docker compose exec -T phx 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: |
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
assets/vendor/
|
||||||
|
deps/
|
||||||
|
_build/
|
||||||
@@ -71,8 +71,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.jpg" alt="Pinchflat screenshot" width="700" />
|
<img src="priv/static/images/app-form-screenshot.png" alt="Pinchflat screenshot" width="700" />
|
||||||
<img src="priv/static/images/app-screenshot.jpg" alt="Pinchflat screenshot" width="700" />
|
<img src="priv/static/images/app-screenshot.png" alt="Pinchflat screenshot" width="700" />
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ Docker Compose file:
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
pinchflat:
|
pinchflat:
|
||||||
image: ghcr.io/kieraneglin/pinchflat:latest
|
image: keglin/pinchflat:latest
|
||||||
environment:
|
environment:
|
||||||
# Set the timezone to your local timezone
|
# Set the timezone to your local timezone
|
||||||
- TZ=America/New_York
|
- TZ=America/New_York
|
||||||
@@ -108,7 +108,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 docker/selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest`
|
- **Building locally:** `docker build . --file selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest`
|
||||||
3. Run the container:
|
3. Run the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -153,7 +153,6 @@ If you change this setting and it works well for you, please leave a comment on
|
|||||||
| EXPOSE_FEED_ENDPOINTS | No | | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) |
|
| EXPOSE_FEED_ENDPOINTS | No | | 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) |
|
| JOURNAL_MODE | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) |
|
||||||
| TZ_DATA_DIR | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
|
| TZ_DATA_DIR | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
|
||||||
| BASE_ROUTE_PATH | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies |
|
|
||||||
|
|
||||||
## EFF donations
|
## EFF donations
|
||||||
|
|
||||||
|
|||||||
+18
-21
@@ -22,8 +22,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-Light.woff2'), url('../fonts/satoshi/Satoshi-Light.woff'),
|
src: url('/fonts/satoshi/Satoshi-Light.woff2'), url('/fonts/satoshi/Satoshi-Light.woff'),
|
||||||
url('../fonts/satoshi/Satoshi-Light.ttf');
|
url('/fonts/satoshi/Satoshi-Light.ttf');
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -31,9 +31,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-LightItalic.woff2'),
|
src: url('/fonts/satoshi/Satoshi-LightItalic.woff2'),
|
||||||
url('../fonts/satoshi/Satoshi-LightItalic.woff'),
|
url('/fonts/satoshi/Satoshi-LightItalic.woff'), url('/fonts/satoshi/Satoshi-LightItalic.ttf');
|
||||||
url('../fonts/satoshi/Satoshi-LightItalic.ttf');
|
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -41,8 +40,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-Regular.woff2'), url('../fonts/satoshi/Satoshi-Regular.woff'),
|
src: url('/fonts/satoshi/Satoshi-Regular.woff2'), url('/fonts/satoshi/Satoshi-Regular.woff'),
|
||||||
url('../fonts/satoshi/Satoshi-Regular.ttf');
|
url('/fonts/satoshi/Satoshi-Regular.ttf');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -50,8 +49,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-Italic.woff2'), url('../fonts/satoshi/Satoshi-Italic.woff'),
|
src: url('/fonts/satoshi/Satoshi-Italic.woff2'), url('/fonts/satoshi/Satoshi-Italic.woff'),
|
||||||
url('../fonts/satoshi/Satoshi-Italic.ttf');
|
url('/fonts/satoshi/Satoshi-Italic.ttf');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -59,8 +58,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-Medium.woff2'), url('../fonts/satoshi/Satoshi-Medium.woff'),
|
src: url('/fonts/satoshi/Satoshi-Medium.woff2'), url('/fonts/satoshi/Satoshi-Medium.woff'),
|
||||||
url('../fonts/satoshi/Satoshi-Medium.ttf');
|
url('/fonts/satoshi/Satoshi-Medium.ttf');
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -68,9 +67,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-MediumItalic.woff2'),
|
src: url('/fonts/satoshi/Satoshi-MediumItalic.woff2'),
|
||||||
url('../fonts/satoshi/Satoshi-MediumItalic.woff'),
|
url('/fonts/satoshi/Satoshi-MediumItalic.woff'), url('/fonts/satoshi/Satoshi-MediumItalic.ttf');
|
||||||
url('../fonts/satoshi/Satoshi-MediumItalic.ttf');
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -78,8 +76,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-Bold.woff2'), url('../fonts/satoshi/Satoshi-Bold.woff'),
|
src: url('/fonts/satoshi/Satoshi-Bold.woff2'), url('/fonts/satoshi/Satoshi-Bold.woff'),
|
||||||
url('../fonts/satoshi/Satoshi-Bold.ttf');
|
url('/fonts/satoshi/Satoshi-Bold.ttf');
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -87,8 +85,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-BoldItalic.woff2'),
|
src: url('/fonts/satoshi/Satoshi-BoldItalic.woff2'), url('/fonts/satoshi/Satoshi-BoldItalic.woff'),
|
||||||
url('../fonts/satoshi/Satoshi-BoldItalic.woff'), url('../fonts/satoshi/Satoshi-BoldItalic.ttf');
|
url('/fonts/satoshi/Satoshi-BoldItalic.ttf');
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -96,9 +94,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../fonts/satoshi/Satoshi-BlackItalic.woff2'),
|
src: url('/fonts/satoshi/Satoshi-BlackItalic.woff2'),
|
||||||
url('../fonts/satoshi/Satoshi-BlackItalic.woff'),
|
url('/fonts/satoshi/Satoshi-BlackItalic.woff'), url('/fonts/satoshi/Satoshi-BlackItalic.ttf');
|
||||||
url('../fonts/satoshi/Satoshi-BlackItalic.ttf');
|
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ window.Alpine = Alpine
|
|||||||
Alpine.start()
|
Alpine.start()
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
|
||||||
let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
|
let liveSocket = new LiveSocket('/live', Socket, {
|
||||||
params: { _csrf_token: csrfToken },
|
params: { _csrf_token: csrfToken },
|
||||||
dom: {
|
dom: {
|
||||||
onBeforeElUpdated(from, to) {
|
onBeforeElUpdated(from, to) {
|
||||||
|
|||||||
+3
-4
@@ -26,8 +26,7 @@ config :pinchflat,
|
|||||||
basic_auth_password: "",
|
basic_auth_password: "",
|
||||||
expose_feed_endpoints: false,
|
expose_feed_endpoints: false,
|
||||||
file_watcher_poll_interval: 1000,
|
file_watcher_poll_interval: 1000,
|
||||||
timezone: "UTC",
|
timezone: "UTC"
|
||||||
base_route_path: "/"
|
|
||||||
|
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
journal_mode: :wal,
|
journal_mode: :wal,
|
||||||
@@ -65,7 +64,7 @@ config :pinchflat, Oban,
|
|||||||
media_indexing: 2,
|
media_indexing: 2,
|
||||||
media_collection_indexing: 2,
|
media_collection_indexing: 2,
|
||||||
media_fetching: 2,
|
media_fetching: 2,
|
||||||
local_data: 8,
|
local_metadata: 8,
|
||||||
remote_metadata: 4
|
remote_metadata: 4
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -102,7 +101,7 @@ config :tailwind,
|
|||||||
|
|
||||||
# Configures Elixir's Logger
|
# Configures Elixir's Logger
|
||||||
config :logger, :console,
|
config :logger, :console,
|
||||||
format: "$date $time $metadata[$level] | $message\n",
|
format: "$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
|
||||||
|
|||||||
+3
-5
@@ -46,12 +46,12 @@ if config_env() == :prod do
|
|||||||
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
|
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
|
||||||
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
||||||
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
||||||
|
|
||||||
# For running PF as a podcast host on self-hosted environments
|
# For running PF as a podcast host on self-hosted environments
|
||||||
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
||||||
|
|
||||||
# For testing alternate journal modes (see issue #137)
|
# For testing alternate journal modes (see issue #137)
|
||||||
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
|
|
||||||
base_route_path = System.get_env("BASE_ROUTE_PATH", "/")
|
|
||||||
|
|
||||||
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"))
|
||||||
|
|
||||||
@@ -65,8 +65,7 @@ if config_env() == :prod do
|
|||||||
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
|
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
|
||||||
expose_feed_endpoints: expose_feed_endpoints,
|
expose_feed_endpoints: expose_feed_endpoints,
|
||||||
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC",
|
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC",
|
||||||
log_path: log_path,
|
log_path: log_path
|
||||||
base_route_path: base_route_path
|
|
||||||
|
|
||||||
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
|
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
|
||||||
|
|
||||||
@@ -109,7 +108,6 @@ if config_env() == :prod do
|
|||||||
ip: {0, 0, 0, 0},
|
ip: {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],
|
|
||||||
secret_key_base: secret_key_base
|
secret_key_base: secret_key_base
|
||||||
|
|
||||||
config :pinchflat, :logger, [
|
config :pinchflat, :logger, [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ARG ELIXIR_VERSION=1.17.0
|
ARG ELIXIR_VERSION=1.16.2
|
||||||
ARG OTP_VERSION=26.2.5
|
ARG OTP_VERSION=26.2.2
|
||||||
ARG DEBIAN_VERSION=bookworm-20240612-slim
|
ARG DEBIAN_VERSION=bookworm-20240130
|
||||||
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
|
||||||
apt-get install -y inotify-tools curl git openssh-client jq \
|
RUN 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 zsh
|
python3-mutagen locales procps build-essential graphviz
|
||||||
|
|
||||||
# Install ffmpeg
|
# Install ffmpeg
|
||||||
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
@@ -23,40 +23,42 @@ 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 and Yarn
|
# Install nodejs
|
||||||
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh && \
|
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
|
||||||
bash nodesource_setup.sh && \
|
RUN bash nodesource_setup.sh
|
||||||
apt-get install -y nodejs && \
|
RUN apt-get install nodejs
|
||||||
npm install -g yarn && \
|
RUN npm install -g yarn
|
||||||
|
|
||||||
# Install baseline Elixir packages
|
# Install baseline Elixir packages
|
||||||
mix local.hex --force && \
|
RUN mix local.hex --force
|
||||||
mix local.rebar --force && \
|
RUN mix local.rebar --force
|
||||||
|
|
||||||
# Download and update YT-DLP
|
# Download and update YT-DLP
|
||||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
|
||||||
chmod a+rx /usr/local/bin/yt-dlp && \
|
RUN chmod a+rx /usr/local/bin/yt-dlp
|
||||||
yt-dlp -U && \
|
RUN yt-dlp -U
|
||||||
|
|
||||||
# Install Apprise
|
# Install Apprise
|
||||||
export PIPX_HOME=/opt/pipx && \
|
RUN 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_ENV=dev mix deps.get && MIX_ENV=dev mix deps.compile
|
RUN mix deps.get
|
||||||
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"
|
||||||
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
|
version: '3'
|
||||||
services:
|
services:
|
||||||
phx:
|
phx:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./docker/dev.Dockerfile
|
dockerfile: 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
|
||||||
|
|||||||
+4
-4
@@ -1,14 +1,14 @@
|
|||||||
|
version: '3'
|
||||||
services:
|
services:
|
||||||
phx:
|
phx:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./docker/dev.Dockerfile
|
dockerfile: dev.Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- '.:/app'
|
- '.:/app'
|
||||||
ports:
|
ports:
|
||||||
- '4008:4008'
|
- '4008:4008'
|
||||||
command: bash -c "chmod +x docker/docker-run.dev.sh && docker/docker-run.dev.sh"
|
command:
|
||||||
|
- ./docker-run.dev.sh
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "\nInstalling Elixir deps..."
|
# Ensure the app's deps are installed
|
||||||
mix deps.get
|
mix deps.get
|
||||||
|
|
||||||
# Install both project-level and assets-level JS dependencies
|
# Install JS deps
|
||||||
echo "\nInstalling JS deps..."
|
echo "\nInstalling JS..."
|
||||||
yarn install && cd assets && yarn install
|
cd assets && yarn install
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Potentially Set up the database
|
# Potentially Set up the database
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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,9 +74,6 @@ 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]
|
||||||
|
|
||||||
@@ -125,13 +122,13 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp quality_options(media_profile) do
|
defp quality_options(media_profile) do
|
||||||
vcodec = Settings.get!(:video_codec_preference)
|
vcodec_string = CodecParser.generate_vcodec_string_from_settings()
|
||||||
acodec = Settings.get!(:audio_codec_preference)
|
acodec_string = CodecParser.generate_acodec_string_from_settings()
|
||||||
|
|
||||||
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_sort: "+acodec:#{acodec}"]
|
[:extract_audio, format: "#{acodec_string}/best"]
|
||||||
|
|
||||||
resolution_atom ->
|
resolution_atom ->
|
||||||
{resolution_string, _} =
|
{resolution_string, _} =
|
||||||
@@ -140,9 +137,10 @@ 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_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}"
|
format: "((#{vcodec_string})+(#{acodec_string}))/best"
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -206,8 +204,10 @@ 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_playlist_index" => pad_int(media_item_with_preloads.playlist_index),
|
"media_upload_date_index" =>
|
||||||
"media_upload_date_index" => pad_int(media_item_with_preloads.upload_date_index)
|
media_item_with_preloads.upload_date_index
|
||||||
|
|> to_string()
|
||||||
|
|> String.pad_leading(2, "0")
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -224,12 +224,6 @@ 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
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
|||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
|
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts tasks for downloading media for any of a sources _pending_ media items.
|
Starts tasks for downloading media for any of a sources _pending_ media items.
|
||||||
Jobs are not enqueued if the source is set to not download media. This will return :ok.
|
Jobs are not enqueued if the source is set to not download media. This will return :ok.
|
||||||
|
|
||||||
|
You can optionally set the `kickoff_delay` option to delay when the jobs are enqueued.
|
||||||
|
|
||||||
NOTE: this starts a download for each media item that is pending,
|
NOTE: this starts a download for each media item that is pending,
|
||||||
not just the ones that were indexed in this job run. This should ensure
|
not just the ones that were indexed in this job run. This should ensure
|
||||||
that any stragglers are caught if, for some reason, they weren't enqueued
|
that any stragglers are caught if, for some reason, they weren't enqueued
|
||||||
@@ -27,13 +31,17 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
|||||||
|
|
||||||
Returns :ok
|
Returns :ok
|
||||||
"""
|
"""
|
||||||
def enqueue_pending_download_tasks(%Source{download_media: true} = source) do
|
def enqueue_pending_download_tasks(source, opts \\ [])
|
||||||
|
|
||||||
|
def enqueue_pending_download_tasks(%Source{download_media: true} = source, opts) do
|
||||||
|
kickoff_delay = Keyword.get(opts, :kickoff_delay, 0)
|
||||||
|
|
||||||
source
|
source
|
||||||
|> Media.list_pending_media_items_for()
|
|> Media.list_pending_media_items_for()
|
||||||
|> Enum.each(&MediaDownloadWorker.kickoff_with_task/1)
|
|> Enum.each(&MediaDownloadWorker.kickoff_with_task(&1, %{}, schedule_in: kickoff_delay))
|
||||||
end
|
end
|
||||||
|
|
||||||
def enqueue_pending_download_tasks(%Source{download_media: false}) do
|
def enqueue_pending_download_tasks(%Source{download_media: false}, _opts) do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -53,15 +61,18 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
|||||||
downloaded, based on the source's download settings and whether media is
|
downloaded, based on the source's download settings and whether media is
|
||||||
considered pending.
|
considered pending.
|
||||||
|
|
||||||
|
You can optionally set the `kickoff_delay` option to delay when the jobs are enqueued.
|
||||||
|
|
||||||
Returns {:ok, %Task{}} | {:error, :should_not_download} | {:error, any()}
|
Returns {:ok, %Task{}} | {:error, :should_not_download} | {:error, any()}
|
||||||
"""
|
"""
|
||||||
def kickoff_download_if_pending(%MediaItem{} = media_item) do
|
def kickoff_download_if_pending(%MediaItem{} = media_item, opts \\ []) do
|
||||||
|
kickoff_delay = Keyword.get(opts, :kickoff_delay, 0)
|
||||||
media_item = Repo.preload(media_item, :source)
|
media_item = Repo.preload(media_item, :source)
|
||||||
|
|
||||||
if media_item.source.download_media && Media.pending_download?(media_item) do
|
if media_item.source.download_media && Media.pending_download?(media_item) do
|
||||||
Logger.info("Kicking off download for media item ##{media_item.id} (#{media_item.media_id})")
|
Logger.info("Kicking off download for media item ##{media_item.id} (#{media_item.media_id})")
|
||||||
|
|
||||||
MediaDownloadWorker.kickoff_with_task(media_item)
|
MediaDownloadWorker.kickoff_with_task(media_item, %{}, schedule_in: kickoff_delay)
|
||||||
else
|
else
|
||||||
{:error, :should_not_download}
|
{:error, :should_not_download}
|
||||||
end
|
end
|
||||||
@@ -91,10 +102,39 @@ 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())
|
not (^MediaQuery.download_prevented()) and
|
||||||
|
not (^MediaQuery.culled())
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Enum.map(&MediaDownloadWorker.kickoff_with_task/1)
|
|> Enum.map(&MediaDownloadWorker.kickoff_with_task/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a media item from the attributes returned by the video backend
|
||||||
|
(read: yt-dlp) and runs the user script with a `media_indexed` event type.
|
||||||
|
|
||||||
|
Only runs the user script if the media item was created successfully and the media item
|
||||||
|
doesn't already exist in the database.
|
||||||
|
|
||||||
|
Returns {:ok, %MediaItem{}} | {:error, any()}
|
||||||
|
"""
|
||||||
|
def create_media_item_and_run_script(%Source{} = source, media_attrs_struct) do
|
||||||
|
media_already_exists =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.media_id(media_attrs_struct.media_id)))
|
||||||
|
|> Repo.exists?()
|
||||||
|
|
||||||
|
case Media.create_media_item_from_backend_attrs(source, media_attrs_struct) do
|
||||||
|
{:ok, media_item} ->
|
||||||
|
if !media_already_exists do
|
||||||
|
UserScriptRunner.run(:media_indexed, media_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, media_item}
|
||||||
|
|
||||||
|
err ->
|
||||||
|
err
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -39,14 +39,17 @@ 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 | {:error, any, ...any}
|
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => media_item_id} = 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 = fetch_and_run_prevent_download_user_script(media_item_id)
|
media_item =
|
||||||
|
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
|
||||||
@@ -59,20 +62,6 @@ 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]
|
||||||
@@ -85,9 +74,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
|
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
|
||||||
})
|
})
|
||||||
|
|
||||||
run_user_script(:media_downloaded, updated_media_item)
|
:ok = run_user_script(updated_media_item)
|
||||||
|
|
||||||
:ok
|
{:ok, updated_media_item}
|
||||||
|
|
||||||
{:recovered, _} ->
|
{:recovered, _} ->
|
||||||
{:error, :retry}
|
{:error, :retry}
|
||||||
@@ -123,9 +112,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(event, media_item) do
|
defp run_user_script(media_item) do
|
||||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||||
|
|
||||||
runner.run(event, media_item)
|
runner.run(:media_downloaded, media_item)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -79,13 +79,12 @@ 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)
|
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads, parsed_json)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
upgradable_media = Media.list_upgradeable_media_items()
|
redownloadable_media = Media.list_redownloadable_media_items()
|
||||||
Logger.info("Redownloading #{length(upgradable_media)} media items")
|
Logger.info("Redownloading #{length(redownloadable_media)} media items")
|
||||||
|
|
||||||
Enum.each(upgradable_media, fn media_item ->
|
Enum.each(redownloadable_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,15 +2,12 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :local_data,
|
queue: :local_metadata,
|
||||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||||
tags: ["media_item", "local_data"]
|
tags: ["media_item", "local_metadata"]
|
||||||
|
|
||||||
use Pinchflat.Media.MediaQuery
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -23,51 +20,14 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
|||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{}) do
|
def perform(%Oban.Job{}) do
|
||||||
cull_cullable_media_items()
|
cullable_media = Media.list_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,12 +54,7 @@ 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
|
||||||
|
|||||||
@@ -10,16 +10,14 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
use Pinchflat.Media.MediaQuery
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
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 for a source from YT's API or RSS, indexes them, and kicks off downloading
|
Fetches new media IDs from a source's YouTube RSS feed, 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.
|
||||||
|
|
||||||
@@ -27,7 +25,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} = get_recent_media_ids(source)
|
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(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)
|
||||||
|
|
||||||
@@ -43,22 +41,12 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
DownloadingHelpers.enqueue_pending_download_tasks(source)
|
# Wait 5s before enqueuing downloads to give the post-indexing user script a chance to run
|
||||||
|
DownloadingHelpers.enqueue_pending_download_tasks(source, kickoff_delay: 5)
|
||||||
|
|
||||||
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))
|
||||||
@@ -69,8 +57,8 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers 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) do
|
||||||
{:ok, media_attrs} ->
|
{:ok, media_attrs_struct} ->
|
||||||
Media.create_media_item_from_backend_attrs(source, media_attrs)
|
DownloadingHelpers.create_media_item_and_run_script(source, media_attrs_struct)
|
||||||
|
|
||||||
err ->
|
err ->
|
||||||
err
|
err
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,31 +1,18 @@
|
|||||||
defmodule Pinchflat.FastIndexing.YoutubeRss do
|
defmodule Pinchflat.FastIndexing.YoutubeRss do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Methods for interacting with YouTube RSS feeds for fast indexing
|
Methods for interacting with YouTube RSS feeds
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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()}
|
||||||
"""
|
"""
|
||||||
@impl YoutubeBehaviour
|
def get_recent_media_ids_from_rss(%Source{} = source) do
|
||||||
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,11 +21,9 @@ 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, to_string(body)}
|
{:ok, 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}"}
|
||||||
@@ -34,8 +32,4 @@ 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,7 +12,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
@behaviour UserScriptCommandRunner
|
@behaviour UserScriptCommandRunner
|
||||||
|
|
||||||
@event_types [
|
@event_types [
|
||||||
:media_pre_download,
|
:media_indexed,
|
||||||
:media_downloaded,
|
:media_downloaded,
|
||||||
:media_deleted
|
:media_deleted
|
||||||
]
|
]
|
||||||
@@ -23,25 +23,24 @@ 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 and returned BUT the tuple will always
|
If something fails, it'll be logged.
|
||||||
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, :no_executable} | {:ok, output, exit_code}
|
Returns :ok
|
||||||
"""
|
"""
|
||||||
@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, :no_executable}
|
:ok
|
||||||
|
|
||||||
{: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],
|
||||||
@@ -49,7 +48,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
logging_arg_override: "[suppressed]"
|
logging_arg_override: "[suppressed]"
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, output, exit_code}
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -64,7 +63,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.info("User scripts lifecyle file either not present or is empty. Skipping.")
|
Logger.warning("User scripts lifecyle file either not present or is empty. Skipping.")
|
||||||
|
|
||||||
{:ok, :no_executable}
|
{:ok, :no_executable}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,15 +25,25 @@ defmodule Pinchflat.Media do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of media_items that are upgradeable based on the redownload delay
|
Returns a list of media_items that are cullable based on the retention period
|
||||||
of the media_profile their source belongs to. In this context, upgradeable means
|
of the source they belong to.
|
||||||
that it's been long enough since upload that the video may be in a higher quality
|
|
||||||
or have better sponsorblock segments (or similar).
|
|
||||||
|
|
||||||
The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
|
Returns [%MediaItem{}, ...]
|
||||||
at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
|
"""
|
||||||
is before the media_item's `uploaded_at`.
|
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
|
||||||
|
upload_date is at least redownload_delay_days ago AND
|
||||||
|
`media_downloaded_at` - `redownload_delay_days` is before the media_item's `upload_date`.
|
||||||
This logic grabs media that we've recently downloaded AND is recently uploaded, but
|
This logic grabs media that we've recently downloaded AND is recently uploaded, but
|
||||||
doesn't grab media that we've recently downloaded and was uploaded a long time ago.
|
doesn't grab media that we've recently downloaded and was uploaded a long time ago.
|
||||||
This also makes things work as expected when downloading media from a source for the
|
This also makes things work as expected when downloading media from a source for the
|
||||||
@@ -41,10 +51,10 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
Returns [%MediaItem{}, ...]
|
Returns [%MediaItem{}, ...]
|
||||||
"""
|
"""
|
||||||
def list_upgradeable_media_items do
|
def list_redownloadable_media_items do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.require_assoc(:media_profile)
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|> where(^MediaQuery.upgradeable())
|
|> where(^MediaQuery.redownloadable())
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -125,23 +135,18 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
Unlike `create_media_item`, this will attempt an update if the media_item
|
Unlike `create_media_item`, this will attempt an update if the media_item
|
||||||
already exists. This is so that future indexing can pick up attributes that
|
already exists. This is so that future indexing can pick up attributes that
|
||||||
we may not have asked for in the past (eg: uploaded_at)
|
we may not have asked for in the past (eg: upload_date)
|
||||||
|
|
||||||
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
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:
|
set: Map.to_list(attrs)
|
||||||
attrs
|
|
||||||
|> Map.drop(fields_to_drop_on_update)
|
|
||||||
|> Map.to_list()
|
|
||||||
],
|
],
|
||||||
conflict_target: [:source_id, :media_id]
|
conflict_target: [:source_id, :media_id]
|
||||||
)
|
)
|
||||||
@@ -171,7 +176,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)
|
||||||
run_user_script(:media_deleted, media_item)
|
:ok = run_user_script(:media_deleted, media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Should delete these no matter what
|
# Should delete these no matter what
|
||||||
@@ -194,7 +199,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)
|
||||||
run_user_script(:media_deleted, media_item)
|
:ok = 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,8 +18,6 @@ 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,
|
||||||
@@ -28,7 +26,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
:livestream,
|
:livestream,
|
||||||
:source_id,
|
:source_id,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:uploaded_at,
|
:upload_date,
|
||||||
:upload_date_index,
|
:upload_date_index,
|
||||||
:duration_seconds,
|
:duration_seconds,
|
||||||
# these fields are captured only on download
|
# these fields are captured only on download
|
||||||
@@ -53,7 +51,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
livestream
|
livestream
|
||||||
media_id
|
media_id
|
||||||
source_id
|
source_id
|
||||||
uploaded_at
|
upload_date
|
||||||
short_form_content
|
short_form_content
|
||||||
)a
|
)a
|
||||||
|
|
||||||
@@ -71,10 +69,9 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
field :short_form_content, :boolean, default: false
|
field :short_form_content, :boolean, default: false
|
||||||
field :media_downloaded_at, :utc_datetime
|
field :media_downloaded_at, :utc_datetime
|
||||||
field :media_redownloaded_at, :utc_datetime
|
field :media_redownloaded_at, :utc_datetime
|
||||||
field :uploaded_at, :utc_datetime
|
field :upload_date, :date
|
||||||
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
|
||||||
@@ -133,31 +130,7 @@ 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
|
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :upload_date) do
|
||||||
# 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
|
|
||||||
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
|
||||||
@@ -169,7 +142,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
|
|
||||||
current_max =
|
current_max =
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> where(^dynamic([mi], ^MediaQuery.upload_date_matches(changes.uploaded_at) and ^MediaQuery.for_source(source)))
|
|> where(^dynamic([mi], mi.upload_date == ^changes.upload_date and ^MediaQuery.for_source(source)))
|
||||||
|> Repo.aggregate(aggregator, :upload_date_index)
|
|> Repo.aggregate(aggregator, :upload_date_index)
|
||||||
|
|
||||||
case current_max do
|
case current_max do
|
||||||
@@ -178,7 +151,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_update_upload_date_index(changeset), do: changeset
|
defp 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,15 +33,13 @@ 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 media_id(nil), do: dynamic(false)
|
||||||
|
def media_id(media_id), do: dynamic([mi], mi.media_id == ^media_id)
|
||||||
|
|
||||||
def upload_date_after_source_cutoff do
|
def upload_date_after_source_cutoff do
|
||||||
dynamic(
|
dynamic([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
|
||||||
[mi, source],
|
|
||||||
is_nil(source.download_cutoff_date) or
|
|
||||||
fragment("date(?) >= ?", mi.uploaded_at, source.download_cutoff_date)
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_matching_profile_preference do
|
def format_matching_profile_preference do
|
||||||
@@ -88,12 +86,12 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
def past_redownload_delay do
|
def past_redownload_delay do
|
||||||
dynamic(
|
dynamic(
|
||||||
[mi, source, media_profile],
|
[mi, source, media_profile],
|
||||||
# Returns media items where the uploaded_at is at least redownload_delay_days ago AND
|
# Returns media items where the upload_date is at least redownload_delay_days ago AND
|
||||||
# downloaded_at minus the redownload_delay_days is before the upload date
|
# downloaded_at minus the redownload_delay_days is before the upload date
|
||||||
fragment("""
|
fragment("""
|
||||||
IFNULL(redownload_delay_days, 0) > 0 AND
|
IFNULL(redownload_delay_days, 0) > 0 AND
|
||||||
DATETIME('now', '-' || redownload_delay_days || ' day') > uploaded_at AND
|
DATETIME('now', '-' || redownload_delay_days || ' day') > upload_date AND
|
||||||
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < uploaded_at
|
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < upload_date
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -107,15 +105,6 @@ 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],
|
||||||
@@ -127,11 +116,12 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def upgradeable do
|
def redownloadable 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,8 +11,6 @@ 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.
|
||||||
|
|
||||||
@@ -49,42 +47,55 @@ 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, :eof))
|
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all))
|
||||||
|
|
||||||
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 and converts it to a JPG
|
Chooses the highest quality thumbnail available (preferring jpg). Returns
|
||||||
|
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) do
|
def download_and_store_thumbnail_for(database_record, metadata_map) do
|
||||||
yt_dlp_filepath = generate_filepath_for(database_record, "thumbnail.%(ext)s")
|
thumbnails =
|
||||||
real_filepath = generate_filepath_for(database_record, "thumbnail.jpg")
|
(metadata_map["thumbnails"] || [])
|
||||||
|
# Give it a low preference if the `preference` key doesn't exist
|
||||||
|
|> Enum.map(&Map.put_new(&1, "preference", -1000))
|
||||||
|
# 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
|
||||||
|
|
||||||
case YtDlpMedia.download_thumbnail(database_record.original_url, output: yt_dlp_filepath) do
|
Map.put(t, "preference", preference_weight)
|
||||||
{:ok, _} -> real_filepath
|
end)
|
||||||
_ -> 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
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Parses an upload date from the YYYYMMDD string returned in yt-dlp metadata
|
Parses an upload date from the YYYYMMDD string returned in yt-dlp metadata
|
||||||
and returns a DateTime struct, appending a time of 00:00:00Z.
|
and returns a Date struct.
|
||||||
|
|
||||||
Returns DateTime.t()
|
Returns Date.t()
|
||||||
"""
|
"""
|
||||||
def parse_upload_date(upload_date) do
|
def parse_upload_date(upload_date) do
|
||||||
<<year::binary-size(4)>> <> <<month::binary-size(2)>> <> <<day::binary-size(2)>> = upload_date
|
<<year::binary-size(4)>> <> <<month::binary-size(2)>> <> <<day::binary-size(2)>> = upload_date
|
||||||
|
|
||||||
case DateTime.from_iso8601("#{year}-#{month}-#{day}T00:00:00Z") do
|
Date.from_iso8601!("#{year}-#{month}-#{day}")
|
||||||
{:ok, datetime, _} -> datetime
|
|
||||||
_ -> raise "Invalid upload date: #{upload_date}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -124,19 +135,11 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
defp fetch_thumbnail_from_url(url) do
|
||||||
Attempts to determine the season and episode number from a media filepath.
|
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
||||||
|
{:ok, body} = http_client.get(url, [], body_format: :binary)
|
||||||
|
|
||||||
Returns {:ok, {binary(), binary()}} | {:error, :indeterminable}
|
body
|
||||||
"""
|
|
||||||
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
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
|||||||
and not have it, ya know?
|
and not have it, ya know?
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Parses the given JSON response from yt-dlp and returns a map of
|
Parses the given JSON response from yt-dlp and returns a map of
|
||||||
the needful media_item attributes, along with anything needed for
|
the needful media_item attributes, along with anything needed for
|
||||||
@@ -26,12 +24,15 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp parse_media_metadata(metadata) do
|
defp parse_media_metadata(metadata) do
|
||||||
Map.merge(
|
|
||||||
Map.from_struct(YtDlpMedia.response_to_struct(metadata)),
|
|
||||||
%{
|
%{
|
||||||
media_filepath: metadata["filepath"]
|
media_id: metadata["id"],
|
||||||
|
title: metadata["title"],
|
||||||
|
original_url: metadata["original_url"],
|
||||||
|
description: metadata["description"],
|
||||||
|
media_filepath: metadata["filepath"],
|
||||||
|
livestream: !!metadata["was_live"],
|
||||||
|
duration_seconds: metadata["duration"] && round(metadata["duration"])
|
||||||
}
|
}
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_subtitle_metadata(metadata) do
|
defp parse_subtitle_metadata(metadata) 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.Utils.FilesystemUtils
|
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
@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(nfo_filepath, metadata) do
|
def build_and_store_for_media_item(filepath, metadata) do
|
||||||
nfo = build_for_media_item(nfo_filepath, metadata)
|
nfo = build_for_media_item(metadata)
|
||||||
|
|
||||||
FilesystemUtils.write_p!(nfo_filepath, nfo)
|
FilesystemUtils.write_p!(filepath, nfo)
|
||||||
|
|
||||||
nfo_filepath
|
filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -37,15 +37,10 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
filepath
|
filepath
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_for_media_item(nfo_filepath, metadata) do
|
defp build_for_media_item(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>
|
||||||
@@ -54,8 +49,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(season)}</season>
|
<season>#{safe(upload_date.year)}</season>
|
||||||
<episode>#{episode}</episode>
|
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
|
||||||
<genre>YouTube</genre>
|
<genre>YouTube</genre>
|
||||||
</episodedetails>
|
</episodedetails>
|
||||||
"""
|
"""
|
||||||
@@ -72,11 +67,4 @@ 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,49 +20,29 @@ 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(source_metadata)
|
|> select_useful_images()
|
||||||
|> 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, source_metadata) do
|
defp select_useful_images(images) 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"} -> put_image_key(acc, :poster, image_map["filepath"])
|
%{"id" => "avatar_uncropped"} ->
|
||||||
%{"id" => "banner_uncropped"} -> put_image_key(acc, :fanart, image_map["filepath"])
|
acc ++ [{:poster, :poster_filepath, image_map["filepath"]}]
|
||||||
_ -> acc
|
|
||||||
|
%{"id" => "banner_uncropped"} ->
|
||||||
|
acc ++ [{:fanart, :fanart_filepath, image_map["filepath"]}]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
acc
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
labelled_images
|
labelled_images
|
||||||
|> add_fallback_poster(source_metadata)
|
|> Enum.concat([{:banner, :banner_filepath, determine_best_banner(images)}])
|
||||||
|> put_image_key(:banner, determine_best_banner(images))
|
|> Enum.filter(fn {_, _, tmp_filepath} -> tmp_filepath end)
|
||||||
|> 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
|
||||||
@@ -78,22 +58,12 @@ defmodule Pinchflat.Metadata.SourceImageParser do
|
|||||||
Map.get(best_candidate || %{}, "filepath")
|
Map.get(best_candidate || %{}, "filepath")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp move_image({_key, attrs}, base_directory) do
|
defp move_image({filename, source_attr_name, tmp_filepath}, base_directory) do
|
||||||
extension = Path.extname(attrs.current_filepath)
|
extension = Path.extname(tmp_filepath)
|
||||||
final_filepath = Path.join([base_directory, "#{attrs.final_filename}#{extension}"])
|
final_filepath = Path.join([base_directory, "#{filename}#{extension}"])
|
||||||
|
|
||||||
FilesystemUtils.cp_p!(attrs.current_filepath, final_filepath)
|
FilesystemUtils.cp_p!(tmp_filepath, final_filepath)
|
||||||
|
|
||||||
{attrs.attribute_name, final_filepath}
|
{source_attr_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,8 +77,10 @@ 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} = fetch_metadata_for_source(source)
|
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts)
|
||||||
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
|
||||||
@@ -108,20 +110,6 @@ 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)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp tmp_directory do
|
defp tmp_directory do
|
||||||
Application.get_env(:pinchflat, :tmpfile_directory)
|
Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||||
|> order_by(desc: :uploaded_at)
|
|> order_by(desc: :upload_date)
|
||||||
|> Repo.maybe_limit(limit)
|
|> Repo.maybe_limit(limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
|
|
||||||
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
||||||
|
|
||||||
|
alias Pinchflat.Utils.DatetimeUtils
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
alias PinchflatWeb.Router.Helpers, as: Routes
|
alias PinchflatWeb.Router.Helpers, as: Routes
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
<title>#{safe(media_item.title)}</title>
|
<title>#{safe(media_item.title)}</title>
|
||||||
<link>#{safe(media_item.original_url)}</link>
|
<link>#{safe(media_item.original_url)}</link>
|
||||||
<description>#{safe(media_item.description)}</description>
|
<description>#{safe(media_item.description)}</description>
|
||||||
<pubDate>#{Calendar.strftime(media_item.uploaded_at, @datetime_format)}</pubDate>
|
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
||||||
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
||||||
<enclosure
|
<enclosure
|
||||||
url="#{media_stream_path(url_base, media_item)}"
|
url="#{media_stream_path(url_base, media_item)}"
|
||||||
@@ -132,6 +133,12 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp generate_upload_date(media_item) do
|
||||||
|
media_item.upload_date
|
||||||
|
|> DatetimeUtils.date_to_datetime()
|
||||||
|
|> Calendar.strftime(@datetime_format)
|
||||||
|
end
|
||||||
|
|
||||||
defp podcast_route(action, params) do
|
defp podcast_route(action, params) do
|
||||||
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
|
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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
|
||||||
@@ -66,8 +65,6 @@ 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)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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,15 +13,17 @@ 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
|
||||||
@@ -30,16 +32,43 @@ 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, :string
|
field :video_codec_preference, {:array, :string}, default: []
|
||||||
field :audio_codec_preference, :string
|
field :audio_codec_preference, {:array, :string}, default: []
|
||||||
|
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
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
@@ -39,6 +38,9 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
item belonging to the source. You can't tell me the method name isn't descriptive!
|
item belonging to the source. You can't tell me the method name isn't descriptive!
|
||||||
Returns a list of media items or changesets (if the media item couldn't be created).
|
Returns a list of media items or changesets (if the media item couldn't be created).
|
||||||
|
|
||||||
|
For each new media item, the method will also run a user script with the `media_indexed`
|
||||||
|
event, if the script is present.
|
||||||
|
|
||||||
Indexing is slow and usually returns a list of all media data at once for record creation.
|
Indexing is slow and usually returns a list of all media data at once for record creation.
|
||||||
To help with this, we use a file follower to watch the file that yt-dlp writes to
|
To help with this, we use a file follower to watch the file that yt-dlp writes to
|
||||||
so we can create media items as they come in. This parallelizes the process and adds
|
so we can create media items as they come in. This parallelizes the process and adds
|
||||||
@@ -64,15 +66,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
source = Repo.reload!(source)
|
source = Repo.reload!(source)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Enum.map(media_attributes, fn media_attrs ->
|
Enum.map(media_attributes, fn media_attrs_struct ->
|
||||||
case Media.create_media_item_from_backend_attrs(source, media_attrs) do
|
case DownloadingHelpers.create_media_item_and_run_script(source, media_attrs_struct) do
|
||||||
{:ok, media_item} -> media_item
|
{:ok, media_item} -> media_item
|
||||||
{:error, changeset} -> changeset
|
{:error, changeset} -> changeset
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Sources.update_source(source, %{last_indexed_at: DateTime.utc_now()})
|
Sources.update_source(source, %{last_indexed_at: DateTime.utc_now()})
|
||||||
DownloadingHelpers.enqueue_pending_download_tasks(source)
|
# Wait 5s before enqueuing downloads to give the post-indexing user script a chance to run
|
||||||
|
DownloadingHelpers.enqueue_pending_download_tasks(source, kickoff_delay: 5)
|
||||||
|
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
@@ -118,14 +121,15 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_media_item_and_enqueue_download(source, media_attrs) do
|
defp create_media_item_and_enqueue_download(source, media_attrs_struct) do
|
||||||
# Reload because the source may have been updated during the (long-running) indexing process
|
# Reload because the source may have been updated during the (long-running) indexing process
|
||||||
# and important settings like `download_media` may have changed.
|
# and important settings like `download_media` may have changed.
|
||||||
source = Repo.reload!(source)
|
source = Repo.reload!(source)
|
||||||
|
|
||||||
case Media.create_media_item_from_backend_attrs(source, media_attrs) do
|
case DownloadingHelpers.create_media_item_and_run_script(source, media_attrs_struct) do
|
||||||
{:ok, %MediaItem{} = media_item} ->
|
{:ok, %MediaItem{} = media_item} ->
|
||||||
DownloadingHelpers.kickoff_download_if_pending(media_item)
|
# Wait 5s before enqueuing downloads to give the post-indexing user script a chance to run
|
||||||
|
DownloadingHelpers.kickoff_download_if_pending(media_item, kickoff_delay: 5)
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
changeset
|
changeset
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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
|
|
||||||
)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
|
||||||
@@ -88,8 +87,6 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
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
|
||||||
@@ -115,11 +112,9 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|
||||||
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
||||||
|> validate_required(required_fields)
|
|> validate_required(required_fields)
|
||||||
|> validate_title_regex()
|
|
||||||
|> 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
|
||||||
@@ -133,7 +128,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
@doc false
|
@doc false
|
||||||
def fast_index_frequency do
|
def fast_index_frequency do
|
||||||
# minutes
|
# minutes
|
||||||
10
|
15
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@@ -146,22 +141,6 @@ 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
|
|
||||||
|
|
||||||
defimpl Jason.Encoder, for: Source do
|
defimpl Jason.Encoder, for: Source do
|
||||||
def encode(value, opts) do
|
def encode(value, opts) do
|
||||||
value
|
value
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
defmodule Pinchflat.Utils.DatetimeUtils do
|
||||||
|
@moduledoc """
|
||||||
|
Utility methods for working with dates and datetimes
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Converts a Date to a DateTime
|
||||||
|
|
||||||
|
Returns %DateTime{}
|
||||||
|
"""
|
||||||
|
def date_to_datetime(date) do
|
||||||
|
date
|
||||||
|
|> Date.to_gregorian_days()
|
||||||
|
|> Kernel.*(86_400)
|
||||||
|
|> DateTime.from_gregorian_seconds()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,7 +10,7 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:original_url,
|
:original_url,
|
||||||
:livestream,
|
:livestream,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:uploaded_at,
|
:upload_date,
|
||||||
:duration_seconds
|
:duration_seconds
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -21,9 +21,8 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:original_url,
|
:original_url,
|
||||||
:livestream,
|
:livestream,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:uploaded_at,
|
:upload_date,
|
||||||
:duration_seconds,
|
:duration_seconds
|
||||||
:playlist_index
|
|
||||||
]
|
]
|
||||||
|
|
||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
@@ -47,24 +46,10 @@ 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 \\ []) do
|
|
||||||
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, opts, "after_move:%()j")
|
|
||||||
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, %Media{}} | {:error, any, ...}.
|
Returns {:ok, [map()]} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_media_attributes(url) do
|
def get_media_attributes(url) do
|
||||||
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
|
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||||
@@ -85,11 +70,9 @@ 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,playlist_index})j"
|
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date})j"
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -107,8 +90,7 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
livestream: !!response["was_live"],
|
livestream: !!response["was_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),
|
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||||
playlist_index: response["playlist_index"] || 0
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -128,18 +110,6 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_uploaded_at(%{"timestamp" => ts} = response) when is_number(ts) do
|
|
||||||
case DateTime.from_unix(ts) do
|
|
||||||
{:ok, datetime} -> datetime
|
|
||||||
_ -> MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# This field is needed before inserting into the database, but absence
|
|
||||||
# of this field should fail at insert-time rather than here
|
|
||||||
defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
|
|
||||||
defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
|
||||||
|
|
||||||
defp backend_runner do
|
defp backend_runner do
|
||||||
# This approach lets us mock the command for testing
|
# This approach lets us mock the command for testing
|
||||||
Application.get_env(:pinchflat, :yt_dlp_runner)
|
Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||||
|
|||||||
@@ -99,21 +99,10 @@ 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 \\ [playlist_items: 0]) do
|
def get_source_metadata(source_url, addl_opts \\ []) do
|
||||||
# This only validates that the `playlist_items` key is present. It's otherwise unused
|
opts = [playlist_items: 0] ++ addl_opts
|
||||||
_playlist_items = Keyword.fetch!(addl_opts, :playlist_items)
|
|
||||||
|
|
||||||
opts = [:skip_download] ++ addl_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, opts, output_template),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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.
|
||||||
@@ -645,11 +644,7 @@ 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>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
|
|
||||||
import PinchflatWeb.CoreComponents
|
alias PinchflatWeb.CoreComponents
|
||||||
import PinchflatWeb.CustomComponents.TextComponents
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a table component with the given rows and columns.
|
Renders a table component with the given rows and columns.
|
||||||
@@ -77,13 +76,11 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
phx-click={@page_number != 1 && "page_change"}
|
phx-click={@page_number != 1 && "page_change"}
|
||||||
phx-value-direction="dec"
|
phx-value-direction="dec"
|
||||||
>
|
>
|
||||||
<.icon name="hero-chevron-left" />
|
<CoreComponents.icon name="hero-chevron-left" />
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="mx-2">
|
<span class="mx-2">Page <%= @page_number %> of <%= @total_pages %></span>
|
||||||
Page <.localized_number number={@page_number} /> of <.localized_number number={@total_pages} />
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span
|
<span
|
||||||
@@ -95,7 +92,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
phx-click={@page_number != @total_pages && "page_change"}
|
phx-click={@page_number != @total_pages && "page_change"}
|
||||||
phx-value-direction="inc"
|
phx-value-direction="inc"
|
||||||
>
|
>
|
||||||
<.icon name="hero-chevron-right" />
|
<CoreComponents.icon name="hero-chevron-right" />
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -94,28 +94,4 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
|
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Renders a localized number using the Intl.NumberFormat API, falling back to the raw number if needed
|
|
||||||
"""
|
|
||||||
attr :number, :any, required: true
|
|
||||||
|
|
||||||
def localized_number(assigns) do
|
|
||||||
~H"""
|
|
||||||
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}><%= @number %></span>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Renders a word with a suffix if the count is not 1
|
|
||||||
"""
|
|
||||||
attr :word, :string, required: true
|
|
||||||
attr :count, :integer, required: true
|
|
||||||
attr :suffix, :string, default: "s"
|
|
||||||
|
|
||||||
def pluralize(assigns) do
|
|
||||||
~H"""
|
|
||||||
<%= @word %><%= if @count == 1, do: "", else: @suffix %>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
onboarding: #{Settings.get!(:onboarding)}
|
onboarding: #{Settings.get!(:onboarding)}
|
||||||
}"}
|
}"}
|
||||||
class="dark text-bodydark bg-boxdark-2"
|
class="dark text-bodydark bg-boxdark-2"
|
||||||
data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")}
|
|
||||||
>
|
>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<.media_preview media_item={@media_item} />
|
<.media_preview media_item={@media_item} />
|
||||||
</div>
|
</div>
|
||||||
<aside class="mt-4 xl:mt-0">
|
<aside class="mt-4 xl:mt-0">
|
||||||
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
|
<div>Uploaded: <%= @media_item.upload_date %></div>
|
||||||
<div>
|
<div>
|
||||||
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
||||||
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
|
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div :if={@media_item.description} class="mt-4 text-bodydark">
|
<div class="mt-4 text-bodydark">
|
||||||
<.break_on_newline text={@media_item.description} />
|
<.break_on_newline text={@media_item.description} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ 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()
|
||||||
|
|
||||||
@@ -72,15 +70,19 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete(conn, %{"id" => id} = params) do
|
def delete(conn, %{"id" => id} = params) do
|
||||||
# This awkward comparison converts the string to a boolean
|
delete_files = Map.get(params, "delete_files", false)
|
||||||
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)
|
||||||
|
|
||||||
{:ok, _} = Profiles.update_media_profile(media_profile, %{marked_for_deletion_at: DateTime.utc_now()})
|
flash_message =
|
||||||
MediaProfileDeletionWorker.kickoff(media_profile, %{delete_files: delete_files})
|
if delete_files do
|
||||||
|
"Media profile, its sources, and its files deleted successfully."
|
||||||
|
else
|
||||||
|
"Media profile and its sources deleted successfully. Files were not deleted."
|
||||||
|
end
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, "Media Profile deletion started. This may take a while to complete.")
|
|> put_flash(:info, flash_message)
|
||||||
|> redirect(to: ~p"/media_profiles")
|
|> redirect(to: ~p"/media_profiles")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -53,24 +53,12 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def media_center_custom_output_template_options do
|
def 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 <code>YYYY-MM-DD</code>",
|
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD",
|
||||||
source_custom_name: "the name of the sources that use this profile",
|
source_custom_name: "the name of the sources that use this profile",
|
||||||
source_collection_id: "the YouTube ID of the sources that use this profile",
|
source_collection_id: "the YouTube ID of the sources that use this profile",
|
||||||
source_collection_name:
|
source_collection_name:
|
||||||
@@ -78,11 +66,9 @@ 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 <code>sYYYYeMMDD</code>",
|
season_episode_from_date: "the upload date formatted as sYYYYeMMDD",
|
||||||
season_episode_index_from_date:
|
season_episode_index_from_date:
|
||||||
"the upload date formatted as <code>sYYYYeMMDDII</code> where <code>II</code> is an index to prevent date collisions",
|
"the upload date formatted as sYYYYeMMDDII where II 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
|
||||||
|
|
||||||
@@ -91,6 +77,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
id
|
id
|
||||||
ext
|
ext
|
||||||
title
|
title
|
||||||
|
fulltitle
|
||||||
uploader
|
uploader
|
||||||
channel
|
channel
|
||||||
upload_date
|
upload_date
|
||||||
@@ -112,7 +99,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_by_year__episode_by_date_and_index }} - {{ title }}.{{ ext }}"
|
"/shows/{{ source_custom_name }}/Season {{ season_from_date }}/{{ season_episode_index_from_date }} - {{ 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'"
|
help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles' (recommended)"
|
||||||
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>
|
||||||
|
|||||||
+7
-14
@@ -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 overflow-auto">
|
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
|
||||||
<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 overflow-auto">
|
<section class="ml-2 md:ml-4 mb-4">
|
||||||
<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,23 +56,16 @@
|
|||||||
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 my-2">Media Center Custom Aliases</h3>
|
<h3 class="text-lg font-bold mb-2">Custom Aliases</h3>
|
||||||
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||||
<li :for={{k, v} <- media_center_custom_output_template_options()} class="mt-1">
|
<li :for={{k, v} <- custom_output_template_options()}>
|
||||||
<.inline_code>{{ <%= k %> }}</.inline_code>
|
<.inline_code>{{ <%= k %> }}</.inline_code>
|
||||||
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
|
<span :if={v}>- <%= 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()} class="mt-1">
|
<li :for={opt <- common_output_template_options()}>
|
||||||
<.inline_code>{{ <%= opt %> }}</.inline_code>
|
<.inline_code>{{ <%= opt %> }}</.inline_code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,19 +5,9 @@ defmodule PinchflatWeb.Pages.PageHTML do
|
|||||||
|
|
||||||
embed_templates "page_html/*"
|
embed_templates "page_html/*"
|
||||||
|
|
||||||
attr :media_filesize, :integer, required: true
|
def readable_media_filesize(media_filesize) do
|
||||||
|
{num, suffix} = NumberUtils.human_byte_size(media_filesize, precision: 1)
|
||||||
|
|
||||||
def readable_media_filesize(assigns) do
|
"#{Float.round(num)} #{suffix}"
|
||||||
{num, suffix} = NumberUtils.human_byte_size(assigns.media_filesize, precision: 2)
|
|
||||||
|
|
||||||
assigns =
|
|
||||||
Map.merge(assigns, %{
|
|
||||||
num: num,
|
|
||||||
suffix: suffix
|
|
||||||
})
|
|
||||||
|
|
||||||
~H"""
|
|
||||||
<.localized_number number={@num} /> <%= @suffix %>
|
|
||||||
"""
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
|||||||
<div>
|
<div>
|
||||||
<span class="mb-4 flex items-center">
|
<span class="mb-4 flex items-center">
|
||||||
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
|
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">Showing <%= length(@records) %> of <%= @total_record_count %></span>
|
||||||
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@total_record_count} />
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.table rows={@records} table_class="text-white">
|
<.table rows={@records} table_class="text-white">
|
||||||
@@ -34,7 +32,7 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
|||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Upload Date">
|
<:col :let={media_item} label="Upload Date">
|
||||||
<%= DateTime.to_date(media_item.uploaded_at) %>
|
<%= media_item.upload_date %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Indexed At">
|
<:col :let={media_item} label="Indexed At">
|
||||||
<%= format_datetime(media_item.inserted_at) %>
|
<%= format_datetime(media_item.inserted_at) %>
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
<a href={~p"/media_profiles"} class="flex flex-col items-center py-2">
|
<a href={~p"/media_profiles"} class="mt-4 flex flex-col items-center justify-center">
|
||||||
<span class="text-md font-medium">
|
<span class="text-md font-medium">Media Profile(s)</span>
|
||||||
Media <.pluralize count={@media_profile_count} word="Profile" />
|
|
||||||
</span>
|
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<.localized_number number={@media_profile_count} />
|
<%= @media_profile_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
<a href={~p"/sources"} class="flex flex-col items-center py-2">
|
<a href={~p"/sources"} class="mt-4 flex flex-col items-center justify-center">
|
||||||
<span class="text-md font-medium">
|
<span class="text-md font-medium">Source(s)</span>
|
||||||
<.pluralize count={@source_count} word="Source" />
|
|
||||||
</span>
|
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<.localized_number number={@source_count} />
|
<%= @source_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
<span class="flex flex-col items-center py-2">
|
<span class="mt-4 flex flex-col items-center justify-center">
|
||||||
<span class="text-md font-medium">
|
<span class="text-md font-medium">Downloaded Media</span>
|
||||||
<.pluralize count={@media_item_count} word="Download" />
|
|
||||||
</span>
|
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<.localized_number number={@media_item_count} />
|
<%= @media_item_count %>
|
||||||
</h4>
|
</h4>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
<span class="flex flex-col items-center py-2">
|
<span class="mt-4 flex flex-col items-center justify-center">
|
||||||
<span class="text-md font-medium">Library Size</span>
|
<span class="text-md font-medium">Library Size</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<.readable_media_filesize media_filesize={@media_item_size} />
|
<%= readable_media_filesize(@media_item_size) %>
|
||||||
</h4>
|
</h4>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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 """
|
||||||
@@ -14,14 +16,9 @@ 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="#{help_link_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="#{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
|
||||||
@@ -33,8 +30,4 @@ 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 Preference</h2>
|
<h2 class="text-xl font-bold mb-2">Codec Preferences</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">
|
||||||
<p>
|
<section>
|
||||||
Some users may want to specify the audio codec that <code class="text-sm">yt-dlp</code>
|
Available video codecs:
|
||||||
searches for when downloading content. For these users, you can specify one audio and video
|
<ul class="list-disc ml-8">
|
||||||
codec to prefer above all others.
|
<li :for={{codec, _} <- CodecParser.video_codec_map()}><%= codec %></li>
|
||||||
</p>
|
</ul>
|
||||||
<p class="mt-4">
|
</section>
|
||||||
NOTE: this is a <em>soft</em>
|
<section class="mt-4">
|
||||||
preference. If the codec you specify is not available, <code class="text-sm">yt-dlp</code>
|
Available audio codecs:
|
||||||
will download the next best available codec. This is expected and intended behavior.
|
<ul class="list-disc ml-8">
|
||||||
<.inline_link href="https://github.com/yt-dlp/yt-dlp#sorting-formats">See here</.inline_link>
|
<li :for={{codec, _} <- CodecParser.audio_codec_map()}><%= codec %></li>
|
||||||
for more information and a list of available codecs.
|
</ul>
|
||||||
</p>
|
</section>
|
||||||
</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' : 'Standard'"></span>
|
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -26,25 +26,7 @@
|
|||||||
) %>
|
) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-10" x-show="advancedMode">
|
||||||
<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
|
||||||
@@ -55,20 +37,24 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:video_codec_preference]}
|
id="video_codec_preference_string"
|
||||||
placeholder="avc"
|
name="setting[video_codec_preference_string]"
|
||||||
|
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="Video codec preference. Will be remuxed into an MP4 container. See below for more details"
|
help="Order of preference for video codecs. Separate with >. Will be remuxed into an MP4 container. See below for available codecs"
|
||||||
inputclass="font-mono text-sm mr-4"
|
inputclass="font-mono text-sm mr-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:audio_codec_preference]}
|
id="audio_codec_preference_string"
|
||||||
placeholder="m4a"
|
name="setting[audio_codec_preference_string]"
|
||||||
|
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="Audio codec preference. See below for more details"
|
help="Order of preference for audio codecs. Separate with >. See below for available codecs"
|
||||||
inputclass="font-mono text-sm mr-4"
|
inputclass="font-mono text-sm mr-4"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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
|
||||||
@@ -18,7 +17,6 @@ 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)),
|
||||||
@@ -43,30 +41,13 @@ 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
|
||||||
# This lets me preload the settings from another source for more efficient creation
|
changeset = Sources.change_source(%Source{})
|
||||||
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,
|
|
||||||
collection_name: nil,
|
|
||||||
collection_id: nil,
|
|
||||||
collection_type: nil,
|
|
||||||
original_url: nil
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -126,15 +107,19 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete(conn, %{"id" => id} = params) do
|
def delete(conn, %{"id" => id} = params) do
|
||||||
# This awkward comparison converts the string to a boolean
|
delete_files = Map.get(params, "delete_files", false)
|
||||||
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)
|
||||||
|
|
||||||
{:ok, _} = Sources.update_source(source, %{marked_for_deletion_at: DateTime.utc_now()})
|
flash_message =
|
||||||
SourceDeletionWorker.kickoff(source, %{delete_files: delete_files})
|
if delete_files do
|
||||||
|
"Source and files deleted successfully."
|
||||||
|
else
|
||||||
|
"Source deleted successfully. Files were not deleted."
|
||||||
|
end
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, "Source deletion started. This may take a while to complete.")
|
|> put_flash(:info, flash_message)
|
||||||
|> redirect(to: ~p"/sources")
|
|> redirect(to: ~p"/sources")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ 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,11 +26,6 @@
|
|||||||
<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,12 +10,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
|
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources/#{@source}"} />
|
||||||
changeset={@changeset}
|
|
||||||
media_profiles={@media_profiles}
|
|
||||||
action={~p"/sources/#{@source}"}
|
|
||||||
method="patch"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,21 @@
|
|||||||
<aside>
|
<aside>
|
||||||
<h2 class="text-2xl font-bold mb-2">What is fast indexing?</h2>
|
<h2 class="text-xl font-bold mb-2">What is fast indexing (experimental)?</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 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.
|
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.
|
||||||
</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 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.
|
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.
|
||||||
</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-4">
|
<p class="mt-2">
|
||||||
<h4 class="font-bold text-xl">TL;DR</h4>
|
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.
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -19,12 +19,11 @@
|
|||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||||
<:col :let={source} label="Pending"><.localized_number number={source.pending_count} /></:col>
|
<:col :let={source} label="Pending"><%= source.pending_count %></:col>
|
||||||
<:col :let={source} label="Downloaded"><.localized_number number={source.downloaded_count} /></:col>
|
<:col :let={source} label="Downloaded"><%= source.downloaded_count %></:col>
|
||||||
<:col :let={source} label="Retention">
|
<:col :let={source} label="Retention">
|
||||||
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
||||||
<.localized_number number={source.retention_period_days} />
|
<%= source.retention_period_days %> day(s)
|
||||||
<.pluralize count={source.retention_period_days} word="day" />
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-lg">∞</span>
|
<span class="text-lg">∞</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
|||||||
<header class="flex justify-between items-center mb-4">
|
<header class="flex justify-between items-center mb-4">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
|
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">Showing <%= length(@records) %> of <%= @filtered_record_count %></span>
|
||||||
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@filtered_record_count} />
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<div class="bg-meta-4 rounded-md">
|
<div class="bg-meta-4 rounded-md">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -51,12 +49,12 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
|||||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
|
<:col :let={media_item} label="Upload Date">
|
||||||
|
<%= media_item.upload_date %>
|
||||||
|
</:col>
|
||||||
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
|
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
|
||||||
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
|
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Upload Date">
|
|
||||||
<%= DateTime.to_date(media_item.uploaded_at) %>
|
|
||||||
</:col>
|
|
||||||
<:col :let={media_item} label="" class="flex justify-end">
|
<:col :let={media_item} label="" class="flex justify-end">
|
||||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||||
</:col>
|
</:col>
|
||||||
@@ -119,40 +117,14 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
|||||||
{:noreply, assign(socket, new_assigns)}
|
{:noreply, assign(socket, new_assigns)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_pagination_attributes(base_query, page, ""), do: fetch_pagination_attributes(base_query, page, nil)
|
|
||||||
|
|
||||||
defp fetch_pagination_attributes(base_query, page, nil) do
|
|
||||||
total_record_count = Repo.aggregate(base_query, :count, :id)
|
|
||||||
total_pages = max(ceil(total_record_count / @limit), 1)
|
|
||||||
page = NumberUtils.clamp(page, 1, total_pages)
|
|
||||||
|
|
||||||
records =
|
|
||||||
fetch_records(base_query, page)
|
|
||||||
|> order_by(desc: :uploaded_at)
|
|
||||||
|> Repo.all()
|
|
||||||
|
|
||||||
%{
|
|
||||||
page: page,
|
|
||||||
total_pages: total_pages,
|
|
||||||
records: records,
|
|
||||||
search_term: nil,
|
|
||||||
total_record_count: total_record_count,
|
|
||||||
filtered_record_count: total_record_count
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp fetch_pagination_attributes(base_query, page, search_term) do
|
defp fetch_pagination_attributes(base_query, page, search_term) do
|
||||||
filtered_base_query = filtered_base_query(base_query, search_term)
|
filtered_base_query = filter_base_query(base_query, search_term)
|
||||||
|
|
||||||
total_record_count = Repo.aggregate(base_query, :count, :id)
|
total_record_count = Repo.aggregate(base_query, :count, :id)
|
||||||
filtered_record_count = Repo.aggregate(filtered_base_query, :count, :id)
|
filtered_record_count = Repo.aggregate(filtered_base_query, :count, :id)
|
||||||
total_pages = max(ceil(filtered_record_count / @limit), 1)
|
total_pages = max(ceil(filtered_record_count / @limit), 1)
|
||||||
page = NumberUtils.clamp(page, 1, total_pages)
|
page = NumberUtils.clamp(page, 1, total_pages)
|
||||||
|
records = fetch_records(filtered_base_query, page)
|
||||||
records =
|
|
||||||
fetch_records(filtered_base_query, page)
|
|
||||||
|> order_by(desc: fragment("rank"), desc: :uploaded_at)
|
|
||||||
|> Repo.all()
|
|
||||||
|
|
||||||
%{
|
%{
|
||||||
page: page,
|
page: page,
|
||||||
@@ -170,41 +142,39 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
|||||||
base_query
|
base_query
|
||||||
|> limit(^@limit)
|
|> limit(^@limit)
|
||||||
|> offset(^offset)
|
|> offset(^offset)
|
||||||
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_base_query(source, "pending") do
|
defp generate_base_query(source, "pending") do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> select(^select_fields())
|
|
||||||
|> MediaQuery.require_assoc(:media_profile)
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|
|> MediaQuery.require_assoc(:media_items_search_index)
|
||||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||||
|
|> order_by(desc: fragment("rank"), desc: :upload_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_base_query(source, "downloaded") do
|
defp generate_base_query(source, "downloaded") do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> select(^select_fields())
|
|> MediaQuery.require_assoc(:media_items_search_index)
|
||||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||||
|
|> order_by(desc: fragment("rank"), desc: :upload_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_base_query(source, "other") do
|
defp generate_base_query(source, "other") do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> select(^select_fields())
|
|
||||||
|> MediaQuery.require_assoc(:media_profile)
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|
|> MediaQuery.require_assoc(:media_items_search_index)
|
||||||
|> where(
|
|> where(
|
||||||
^dynamic(
|
^dynamic(
|
||||||
^MediaQuery.for_source(source) and
|
^MediaQuery.for_source(source) and
|
||||||
(not (^MediaQuery.downloaded()) and not (^MediaQuery.pending()))
|
(not (^MediaQuery.downloaded()) and not (^MediaQuery.pending()))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|> order_by(desc: fragment("rank"), desc: :upload_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp filtered_base_query(base_query, search_term) do
|
defp filter_base_query(base_query, search_term) do
|
||||||
base_query
|
base_query
|
||||||
|> MediaQuery.require_assoc(:media_items_search_index)
|
|
||||||
|> where(^MediaQuery.matches_search_term(search_term))
|
|> where(^MediaQuery.matches_search_term(search_term))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Selecting only what we need GREATLY speeds up queries on large tables
|
|
||||||
defp select_fields do
|
|
||||||
[:id, :title, :uploaded_at, :prevent_download]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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"} method="post" />
|
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
: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)))"
|
||||||
>
|
>
|
||||||
@@ -11,23 +10,15 @@
|
|||||||
</.error>
|
</.error>
|
||||||
|
|
||||||
<section x-data="{ mediaProfileId: null }">
|
<section x-data="{ mediaProfileId: null }">
|
||||||
<section class="flex justify-between items-center mt-4">
|
<section class="flex justify-between items-center mt-8">
|
||||||
<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' : 'Standard'"></span>
|
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></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"
|
||||||
@@ -35,6 +26,8 @@
|
|||||||
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})}
|
||||||
@@ -56,7 +49,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. For best results, set this to the longest delay you can tolerate for this source"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div phx-click={show_modal("upgrade-modal")}>
|
<div phx-click={show_modal("upgrade-modal")}>
|
||||||
@@ -65,7 +58,7 @@
|
|||||||
type="toggle"
|
type="toggle"
|
||||||
label="Use Fast Indexing"
|
label="Use Fast Indexing"
|
||||||
label_suffix="(pro)"
|
label_suffix="(pro)"
|
||||||
help="Not recommended for playlists. Overrides 'Index Frequency'. See below for more details (seriously, there's a TL;DR that's worth reading)"
|
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. Does not work with private playlists. See below for more info"
|
||||||
x-init="
|
x-init="
|
||||||
// `enabled` is the data attribute that the toggle uses internally
|
// `enabled` is the data attribute that the toggle uses internally
|
||||||
fastIndexingEnabled = enabled
|
fastIndexingEnabled = enabled
|
||||||
@@ -94,7 +87,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. Old media may be deleted or downloaded if you change this date"
|
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
@@ -102,8 +95,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
label="Retention Period (days)"
|
label="Retention Period (days)"
|
||||||
min="0"
|
min="0"
|
||||||
help="Days between when media is <em>downloaded</em> and when it's deleted. Leave blank to keep media indefinitely"
|
help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely"
|
||||||
html_help={true}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section x-show="advancedMode">
|
<section x-show="advancedMode">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ defmodule PinchflatWeb.Router do
|
|||||||
plug :fetch_session
|
plug :fetch_session
|
||||||
plug :fetch_live_flash
|
plug :fetch_live_flash
|
||||||
plug :put_root_layout, html: {PinchflatWeb.Layouts, :root}
|
plug :put_root_layout, html: {PinchflatWeb.Layouts, :root}
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery, with: :clear_session
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
plug :allow_iframe_embed
|
plug :allow_iframe_embed
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "2024.8.6",
|
version: "2024.5.24",
|
||||||
elixir: "~> 1.17",
|
elixir: "~> 1.16",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
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.7", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.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,8 +87,6 @@ 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"],
|
||||||
@@ -103,8 +101,7 @@ 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.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": {: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_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"},
|
||||||
|
|||||||
+1
-3
@@ -5,9 +5,7 @@
|
|||||||
"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=tooling/.prettierrc.js --ignore-path=tooling/.prettierignore --ignore-path=.gitignore",
|
|
||||||
"lint:fix": "prettier . --write --config=tooling/.prettierrc.js --ignore-path=tooling/.prettierignore --ignore-path=.gitignore"
|
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 498 KiB After Width: | Height: | Size: 449 KiB |
@@ -1,21 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.RenameUploadDateToUploadedAt do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def up do
|
|
||||||
rename table(:media_items), :upload_date, to: :uploaded_at
|
|
||||||
|
|
||||||
execute """
|
|
||||||
UPDATE media_items
|
|
||||||
SET uploaded_at = uploaded_at || 'T00:00:00'
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
def down do
|
|
||||||
rename table(:media_items), :uploaded_at, to: :upload_date
|
|
||||||
|
|
||||||
execute """
|
|
||||||
UPDATE media_items
|
|
||||||
SET upload_date = DATE(upload_date)
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.ModifyUploadDateIndex do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def change do
|
|
||||||
drop index("media_items", [:upload_date])
|
|
||||||
create index("media_items", [:uploaded_at])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.AddIndexesForLargeCollections do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def change do
|
|
||||||
create index(
|
|
||||||
"media_items",
|
|
||||||
[
|
|
||||||
:source_id,
|
|
||||||
:media_filepath,
|
|
||||||
:uploaded_at,
|
|
||||||
:prevent_download,
|
|
||||||
:livestream,
|
|
||||||
:short_form_content,
|
|
||||||
:title
|
|
||||||
],
|
|
||||||
name: "media_items_pending_and_downloaded_index"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.AddYoutubeApiKeySetting do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def change do
|
|
||||||
alter table(:settings) do
|
|
||||||
add :youtube_api_key, :string
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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
@@ -1,13 +0,0 @@
|
|||||||
defmodule Pinchflat.Repo.Migrations.AddMarkedForDeletionAtToSources do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def change do
|
|
||||||
alter table(:sources) do
|
|
||||||
add :marked_for_deletion_at, :utc_datetime
|
|
||||||
end
|
|
||||||
|
|
||||||
alter table(:media_profiles) do
|
|
||||||
add :marked_for_deletion_at, :utc_datetime
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -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.17.0
|
ARG ELIXIR_VERSION=1.16.2
|
||||||
ARG OTP_VERSION=26.2.5
|
ARG OTP_VERSION=26.2.2
|
||||||
ARG DEBIAN_VERSION=bookworm-20240612-slim
|
ARG DEBIAN_VERSION=bookworm-20240130-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}"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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,23 +93,15 @@ 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_1 = update_media_profile_attribute(media_item, %{download_subs: true, download_auto_subs: true})
|
media_item = 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_1} = DownloadOptionBuilder.build(media_item_1)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
assert {:ok, res_2} = DownloadOptionBuilder.build(media_item_2)
|
|
||||||
|
|
||||||
assert :write_auto_subs in res_1
|
assert :write_auto_subs in res
|
||||||
assert :write_auto_subs in res_2
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "doesn't include :write_auto_subs option when download_subs and embed_subs is false", %{media_item: media_item} do
|
test "doesn't include :write_auto_subs option when download_subs is false", %{media_item: media_item} do
|
||||||
media_item =
|
media_item = update_media_profile_attribute(media_item, %{download_subs: false, download_auto_subs: true})
|
||||||
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)
|
||||||
|
|
||||||
@@ -264,8 +256,10 @@ 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
|
||||||
@@ -276,20 +270,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_sort, "+acodec:m4a"} in res
|
assert {:format, "bestaudio[acodec~='^mp4a']/bestaudio/best"} 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_sort, "res:1080,+codec:av01:aac"} in res
|
assert {:format, "((bestvideo[vcodec~='^av01']/bestvideo)+(bestaudio[acodec~='^aac']/bestaudio))/best"} in res
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
|||||||
import Pinchflat.ProfilesFixtures
|
import Pinchflat.ProfilesFixtures
|
||||||
|
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
|
alias Pinchflat.Media.MediaItem
|
||||||
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
|
|
||||||
describe "enqueue_pending_download_tasks/1" do
|
describe "enqueue_pending_download_tasks/1" do
|
||||||
test "it enqueues a job for each pending media item" do
|
test "it enqueues a job for each pending media item" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
@@ -19,6 +23,16 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
|||||||
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it can optionally delay when those jobs are enqueued" do
|
||||||
|
source = source_fixture()
|
||||||
|
_media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
|
||||||
|
|
||||||
|
assert :ok = DownloadingHelpers.enqueue_pending_download_tasks(source, kickoff_delay: 60)
|
||||||
|
[job] = all_enqueued(worker: MediaDownloadWorker)
|
||||||
|
|
||||||
|
assert_in_delta DateTime.diff(job.scheduled_at, now()), 60, 1
|
||||||
|
end
|
||||||
|
|
||||||
test "it does not enqueue a job for media items with a filepath" do
|
test "it does not enqueue a job for media items with a filepath" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
_media_item = media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4")
|
_media_item = media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4")
|
||||||
@@ -84,6 +98,13 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
|||||||
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it can optionally delay when those jobs are enqueued", %{media_item: media_item} do
|
||||||
|
assert {:ok, _} = DownloadingHelpers.kickoff_download_if_pending(media_item, kickoff_delay: 60)
|
||||||
|
[job] = all_enqueued(worker: MediaDownloadWorker)
|
||||||
|
|
||||||
|
assert_in_delta DateTime.diff(job.scheduled_at, now()), 60, 1
|
||||||
|
end
|
||||||
|
|
||||||
test "creates and returns a download task record", %{media_item: media_item} do
|
test "creates and returns a download task record", %{media_item: media_item} do
|
||||||
assert {:ok, task} = DownloadingHelpers.kickoff_download_if_pending(media_item)
|
assert {:ok, task} = DownloadingHelpers.kickoff_download_if_pending(media_item)
|
||||||
|
|
||||||
@@ -130,9 +151,75 @@ 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)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "create_media_item_and_run_script/2" do
|
||||||
|
setup do
|
||||||
|
FilesystemUtils.write_p!(filepath(), "")
|
||||||
|
File.chmod(filepath(), 0o755)
|
||||||
|
|
||||||
|
on_exit(fn -> File.rm(filepath()) end)
|
||||||
|
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
media_attrs =
|
||||||
|
media_attributes_return_fixture()
|
||||||
|
|> Phoenix.json_library().decode!()
|
||||||
|
|> YtDlpMedia.response_to_struct()
|
||||||
|
|
||||||
|
{:ok, source: source, media_attrs: media_attrs}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates a media item for a given source and attributes", %{source: source, media_attrs: media_attrs} do
|
||||||
|
assert {:ok, %MediaItem{} = media_item} = DownloadingHelpers.create_media_item_and_run_script(source, media_attrs)
|
||||||
|
|
||||||
|
assert media_item.source_id == source.id
|
||||||
|
assert media_item.title == media_attrs.title
|
||||||
|
assert media_item.media_id == media_attrs.media_id
|
||||||
|
assert media_item.original_url == media_attrs.original_url
|
||||||
|
assert media_item.description == media_attrs.description
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns an error if the media item cannot be created", %{source: source, media_attrs: media_attrs} do
|
||||||
|
media_attrs = %YtDlpMedia{media_attrs | media_id: nil}
|
||||||
|
|
||||||
|
assert {:error, %Ecto.Changeset{}} = DownloadingHelpers.create_media_item_and_run_script(source, media_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "runs a script if the media item is created", %{source: source, media_attrs: media_attrs} do
|
||||||
|
# We *love* indirectly testing side effects
|
||||||
|
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
|
filename = "#{tmp_dir}/test_file-#{Enum.random(1..1000)}"
|
||||||
|
File.write(filepath(), "#!/bin/bash\ntouch #{filename}\n")
|
||||||
|
|
||||||
|
refute File.exists?(filename)
|
||||||
|
assert {:ok, %MediaItem{}} = DownloadingHelpers.create_media_item_and_run_script(source, media_attrs)
|
||||||
|
assert File.exists?(filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not run a script if the media item already exists", %{source: source, media_attrs: media_attrs} do
|
||||||
|
{:ok, %MediaItem{}} = DownloadingHelpers.create_media_item_and_run_script(source, media_attrs)
|
||||||
|
|
||||||
|
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
|
filename = "#{tmp_dir}/test_file-#{Enum.random(1..1000)}"
|
||||||
|
File.write(filepath(), "#!/bin/bash\ntouch #{filename}\n")
|
||||||
|
|
||||||
|
refute File.exists?(filename)
|
||||||
|
assert {:ok, %MediaItem{}} = DownloadingHelpers.create_media_item_and_run_script(source, media_attrs)
|
||||||
|
refute File.exists?(filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filepath do
|
||||||
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
|
|
||||||
|
Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
|
|
||||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||||
|
|
||||||
media_item =
|
media_item =
|
||||||
@@ -162,6 +161,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
|
||||||
@@ -223,59 +236,4 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
||||||
end
|
end
|
||||||
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,7 +5,6 @@ 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
|
||||||
@@ -15,8 +14,9 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
[:metadata, source: :media_profile]
|
[:metadata, source: :media_profile]
|
||||||
)
|
)
|
||||||
|
|
||||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
{:ok, %{media_item: media_item}}
|
{:ok, %{media_item: media_item}}
|
||||||
end
|
end
|
||||||
@@ -44,7 +44,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}/thumbnail.jpg"
|
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.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
|
||||||
@@ -124,12 +124,6 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
|||||||
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 sets the culled_at to nil", %{media_item: media_item} do
|
|
||||||
Media.update_media_item(media_item, %{culled_at: DateTime.utc_now()})
|
|
||||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
|
||||||
assert updated_media_item.culled_at == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "it extracts the title", %{media_item: media_item} do
|
test "it 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"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorkerTest do
|
|||||||
media_item =
|
media_item =
|
||||||
media_item_fixture(%{
|
media_item_fixture(%{
|
||||||
source_id: source.id,
|
source_id: source.id,
|
||||||
uploaded_at: now_minus(6, :days),
|
upload_date: now_minus(6, :days),
|
||||||
media_downloaded_at: now_minus(5, :days)
|
media_downloaded_at: now_minus(5, :days)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorkerTest do
|
|||||||
_media_item =
|
_media_item =
|
||||||
media_item_fixture(%{
|
media_item_fixture(%{
|
||||||
source_id: source.id,
|
source_id: source.id,
|
||||||
uploaded_at: now_minus(6, :days),
|
upload_date: now_minus(6, :days),
|
||||||
media_downloaded_at: now_minus(1, :day)
|
media_downloaded_at: now_minus(1, :day)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,26 @@ 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, "", 0} end)
|
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1" do
|
test "deletes media files that are past their retention date" do
|
||||||
|
{_source, old_media_item, new_media_item} = prepare_records()
|
||||||
|
|
||||||
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
|
assert File.exists?(new_media_item.media_filepath)
|
||||||
|
refute File.exists?(old_media_item.media_filepath)
|
||||||
|
assert Repo.reload!(new_media_item).media_filepath
|
||||||
|
refute Repo.reload!(old_media_item).media_filepath
|
||||||
|
end
|
||||||
|
|
||||||
test "sets deleted media to not re-download" do
|
test "sets deleted media to not re-download" do
|
||||||
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
{_source, old_media_item, new_media_item} = prepare_records()
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
@@ -24,7 +35,7 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "sets culled_at timestamp on deleted media" do
|
test "sets culled_at timestamp on deleted media" do
|
||||||
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
{_source, old_media_item, new_media_item} = prepare_records()
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
perform_job(MediaRetentionWorker, %{})
|
||||||
|
|
||||||
@@ -32,47 +43,9 @@ defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
|||||||
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
|
assert DateTime.diff(now(), Repo.reload!(old_media_item).culled_at) < 1
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
describe "perform/1 when testing retention_period-based culling" do
|
|
||||||
test "deletes media files that are past their retention date" do
|
|
||||||
{_source, old_media_item, new_media_item} = prepare_records_for_retention_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 and prevent_download" do
|
|
||||||
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date()
|
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
|
||||||
|
|
||||||
refute Repo.reload!(new_media_item).culled_at
|
|
||||||
assert Repo.reload!(old_media_item).culled_at
|
|
||||||
refute Repo.reload!(new_media_item).prevent_download
|
|
||||||
assert Repo.reload!(old_media_item).prevent_download
|
|
||||||
end
|
|
||||||
|
|
||||||
test "doesn't cull if the source doesn't have a retention period" do
|
|
||||||
{_source, old_media_item, new_media_item} = prepare_records_for_retention_date(nil)
|
|
||||||
|
|
||||||
perform_job(MediaRetentionWorker, %{})
|
|
||||||
|
|
||||||
assert File.exists?(new_media_item.media_filepath)
|
|
||||||
assert File.exists?(old_media_item.media_filepath)
|
|
||||||
assert Repo.reload!(new_media_item).media_filepath
|
|
||||||
assert Repo.reload!(old_media_item).media_filepath
|
|
||||||
|
|
||||||
refute Repo.reload!(new_media_item).culled_at
|
|
||||||
refute Repo.reload!(old_media_item).culled_at
|
|
||||||
end
|
|
||||||
|
|
||||||
test "doesn't cull media items that have prevent_culling set" do
|
test "doesn't cull media items that have prevent_culling set" do
|
||||||
{_source, old_media_item, _new_media_item} = prepare_records_for_retention_date()
|
{_source, old_media_item, _new_media_item} = prepare_records()
|
||||||
|
|
||||||
Media.update_media_item(old_media_item, %{prevent_culling: true})
|
Media.update_media_item(old_media_item, %{prevent_culling: true})
|
||||||
|
|
||||||
@@ -80,99 +53,11 @@ 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
|
||||||
|
|
||||||
describe "perform/1 when testing source cutoff-based culling" do
|
defp prepare_records do
|
||||||
test "culls media from before the cutoff date" do
|
source = source_fixture(%{retention_period_days: 2})
|
||||||
{_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(%{
|
||||||
@@ -188,23 +73,4 @@ 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,11 +6,11 @@ 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 ->
|
||||||
{:ok, media_attributes_return_fixture()}
|
{:ok, media_attributes_return_fixture()}
|
||||||
@@ -19,7 +19,6 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
{: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)
|
||||||
|
|
||||||
@@ -29,6 +28,16 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
assert worker.args["id"] == media_item.id
|
assert worker.args["id"] == media_item.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "enqueues the worker with a small delay", %{source: source} do
|
||||||
|
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||||
|
|
||||||
|
FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||||
|
|
||||||
|
[job | _] = all_enqueued(worker: MediaDownloadWorker)
|
||||||
|
|
||||||
|
assert_in_delta DateTime.diff(job.scheduled_at, now()), 5, 1
|
||||||
|
end
|
||||||
|
|
||||||
test "does not enqueue a new worker for the source's media IDs we already know about", %{source: source} do
|
test "does not enqueue a new worker for the source's media IDs we already know about", %{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)
|
||||||
media_item_fixture(source_id: source.id, media_id: "test_1")
|
media_item_fixture(source_id: source.id, media_id: "test_1")
|
||||||
@@ -108,49 +117,4 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
|||||||
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
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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,13 +11,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, source: source}
|
{:ok, source: source}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "enabled?/0" do
|
describe "get_recent_media_ids_from_rss/1" 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")
|
||||||
|
|
||||||
@@ -27,7 +21,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, ""}
|
{:ok, ""}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
|
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calls the expected URL for playlist sources" do
|
test "calls the expected URL for playlist sources" do
|
||||||
@@ -39,13 +33,13 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
|||||||
{:ok, ""}
|
{:ok, ""}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
|
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(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(source)
|
assert {:error, "Failed to fetch RSS feed"} = YoutubeRss.get_recent_media_ids_from_rss(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
|
||||||
@@ -53,7 +47,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(source)
|
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "strips whitespace from media IDs", %{source: source} do
|
test "strips whitespace from media IDs", %{source: source} do
|
||||||
@@ -61,7 +55,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(source)
|
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "removes empty media IDs", %{source: source} do
|
test "removes empty media IDs", %{source: source} do
|
||||||
@@ -69,7 +63,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(source)
|
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "removes duplicate media IDs", %{source: source} do
|
test "removes duplicate media IDs", %{source: source} do
|
||||||
@@ -77,7 +71,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(source)
|
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user