Compare commits

..

3 Commits

Author SHA1 Message Date
Kieran Eglin c5bf51dfb3 Hooked up script runner for indexing 2024-05-28 10:43:54 -07:00
Kieran Eglin 5d624f545b [WIP] added delay between indexing and downloading in some cases 2024-05-27 11:54:06 -07:00
Kieran Eglin 1305a485a0 Updated CSRF plug to not throw an exception 2024-05-27 11:41:05 -07:00
138 changed files with 1640 additions and 3050 deletions
+1 -1
View File
@@ -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)
View File
-12
View File
@@ -1,12 +0,0 @@
{
"name": "Pinchflat Dev",
"dockerComposeFile": "../docker-compose.yml",
"service": "phx",
"workspaceFolder": "/app",
"shutdownAction": "stopCompose",
"customizations": {
"vscode": {
"extensions": ["phoenixframework.phoenix", "JakeBecker.elixir-ls", "esbenp.prettier-vscode"]
}
}
}
-2
View File
@@ -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"],
+2 -9
View File
@@ -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) }}
+11 -14
View File
@@ -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: |
-8
View File
@@ -23,11 +23,3 @@ alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.SlowIndexing.FileFollowerServer alias Pinchflat.SlowIndexing.FileFollowerServer
Pinchflat.Release.check_file_permissions() Pinchflat.Release.check_file_permissions()
defmodule IexHelpers do
def restart do
:init.restart()
end
end
import IexHelpers
+2
View File
@@ -1 +1,3 @@
assets/vendor/ assets/vendor/
deps/
_build/
+12 -16
View File
@@ -17,7 +17,6 @@
[![](https://img.shields.io/github/license/kieraneglin/pinchflat?style=for-the-badge&color=ee512b)](LICENSE) [![](https://img.shields.io/github/license/kieraneglin/pinchflat?style=for-the-badge&color=ee512b)](LICENSE)
[![](https://img.shields.io/github/v/release/kieraneglin/pinchflat?style=for-the-badge)](https://github.com/kieraneglin/pinchflat/releases) [![](https://img.shields.io/github/v/release/kieraneglin/pinchflat?style=for-the-badge)](https://github.com/kieraneglin/pinchflat/releases)
[![](https://img.shields.io/github/actions/workflow/status/kieraneglin/pinchflat/lint_and_test.yml?style=for-the-badge)](#) [![](https://img.shields.io/github/actions/workflow/status/kieraneglin/pinchflat/lint_and_test.yml?style=for-the-badge)](#)
[![](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode&style=for-the-badge)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/kieraneglin/pinchflat)
</div> </div>
@@ -72,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
@@ -92,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
@@ -109,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
@@ -146,17 +145,14 @@ If you change this setting and it works well for you, please leave a comment on
### Environment variables ### Environment variables
| Name | Required? | Default | Notes | | Name | Required? | Default | Notes |
| --------------------------- | --------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | --------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
| `TZ` | No | `UTC` | Must follow IANA TZ format | | TZ | No | `UTC` | Must follow IANA TZ format |
| `LOG_LEVEL` | No | `debug` | Can be set to `info` but `debug` is strongly recommended | | LOG_LEVEL | No | `debug` | Can be set to `info` |
| `BASIC_AUTH_USERNAME` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) | | BASIC_AUTH_USERNAME | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
| `BASIC_AUTH_PASSWORD` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) | | BASIC_AUTH_PASSWORD | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
| `EXPOSE_FEED_ENDPOINTS` | No | `false` | 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) |
| `ENABLE_IPV6` | No | `false` | Setting to _any_ non-blank value will enable IPv6 | | 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 |
| `YT_DLP_WORKER_CONCURRENCY` | No | `2` | The number of concurrent workers that use `yt-dlp` _per queue_. Set to 1 if you're getting IP limited, otherwise don't touch it |
## EFF donations ## EFF donations
+18 -21
View File
@@ -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
View File
@@ -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) {
+14 -6
View File
@@ -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,
@@ -41,8 +40,7 @@ config :pinchflat, PinchflatWeb.Endpoint,
adapter: Phoenix.Endpoint.Cowboy2Adapter, adapter: Phoenix.Endpoint.Cowboy2Adapter,
render_errors: [ render_errors: [
formats: [html: PinchflatWeb.ErrorHTML, json: PinchflatWeb.ErrorJSON], formats: [html: PinchflatWeb.ErrorHTML, json: PinchflatWeb.ErrorJSON],
root_layout: {PinchflatWeb.Layouts, :root}, layout: false
layout: {PinchflatWeb.Layouts, :app}
], ],
pubsub_server: Pinchflat.PubSub, pubsub_server: Pinchflat.PubSub,
live_view: [signing_salt: "/t5878kO"] live_view: [signing_salt: "/t5878kO"]
@@ -58,6 +56,16 @@ config :pinchflat, Oban,
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker}, {"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker} {"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
]} ]}
],
# TODO: consider making this an env var or something?
queues: [
default: 10,
fast_indexing: 6,
media_indexing: 2,
media_collection_indexing: 2,
media_fetching: 2,
local_metadata: 8,
remote_metadata: 4
] ]
# Configures the mailer # Configures the mailer
@@ -92,8 +100,8 @@ config :tailwind,
] ]
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :default_formatter, 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
+1 -1
View File
@@ -67,7 +67,7 @@ config :pinchflat, PinchflatWeb.Endpoint,
config :pinchflat, dev_routes: true config :pinchflat, dev_routes: true
# Do not include metadata nor timestamps in development logs # Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n" config :logger, :console, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such # Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive. # in production as building large stacktraces may be expensive.
+4 -21
View File
@@ -40,33 +40,18 @@ config :pinchflat, Pinchflat.Repo,
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"]) Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
] ]
# Some users may want to increase the number of workers that use yt-dlp to improve speeds
# Others may want to decrease the number of these workers to lessen the chance of an IP ban
{yt_dlp_worker_count, _} = Integer.parse(System.get_env("YT_DLP_WORKER_CONCURRENCY", "2"))
config :pinchflat, Oban,
queues: [
default: 10,
fast_indexing: 6,
media_collection_indexing: yt_dlp_worker_count,
media_fetching: yt_dlp_worker_count,
remote_metadata: yt_dlp_worker_count,
local_data: 8
]
if config_env() == :prod do if config_env() == :prod do
config_path = "/config" config_path = "/config"
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"])) db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
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", "/")
enable_ipv6 = String.length(System.get_env("ENABLE_IPV6", "")) > 0
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug")) config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
@@ -80,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")
@@ -121,10 +105,9 @@ if config_env() == :prod do
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses. # for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: if(enable_ipv6, do: {0, 0, 0, 0, 0, 0, 0, 0}, else: {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, [
+28 -26
View File
@@ -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"
+2 -4
View File
@@ -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 -2
View File
@@ -1,12 +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
@@ -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
+82
View File
@@ -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)
} }
}) })
@@ -106,9 +105,8 @@ defmodule Pinchflat.Downloading.MediaDownloader do
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts) {:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
runner_opts = [output_filepath: output_filepath, use_cookies: item_with_preloads.source.use_cookies]
YtDlpMedia.download(url, options, runner_opts) YtDlpMedia.download(url, options, output_filepath: output_filepath)
end end
defp recoverable_errors do defp recoverable_errors do
@@ -23,10 +23,10 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do
""" """
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{}) do def perform(%Oban.Job{}) do
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))
@@ -68,9 +56,9 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
defp create_media_item_from_media_id(source, media_id) do defp create_media_item_from_media_id(source, media_id) do
url = "https://www.youtube.com/watch?v=#{media_id}" url = "https://www.youtube.com/watch?v=#{media_id}"
case YtDlpMedia.get_media_attributes(url, use_cookies: source.use_cookies) 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
+2 -15
View File
@@ -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
+1 -7
View File
@@ -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
+23 -18
View File
@@ -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
+6 -33
View File
@@ -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
+10 -29
View File
@@ -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
@@ -75,14 +73,6 @@ defmodule Pinchflat.Media.MediaQuery do
) )
end end
def meets_min_and_max_duration do
dynamic(
[mi, source],
(is_nil(source.min_duration_seconds) or fragment("duration_seconds >= ?", source.min_duration_seconds)) and
(is_nil(source.max_duration_seconds) or fragment("duration_seconds <= ?", source.max_duration_seconds))
)
end
def past_retention_period do def past_retention_period do
dynamic( dynamic(
[mi, source], [mi, source],
@@ -96,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
@@ -115,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],
@@ -131,16 +112,16 @@ defmodule Pinchflat.Media.MediaQuery do
not (^download_prevented()) and not (^download_prevented()) and
^upload_date_after_source_cutoff() and ^upload_date_after_source_cutoff() and
^format_matching_profile_preference() and ^format_matching_profile_preference() and
^matches_source_title_regex() and ^matches_source_title_regex()
^meets_min_and_max_duration()
) )
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()
) )
+33 -32
View File
@@ -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,44 +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(media_item_with_preloads) do def download_and_store_thumbnail_for(database_record, metadata_map) do
yt_dlp_filepath = generate_filepath_for(media_item_with_preloads, "thumbnail.%(ext)s") thumbnails =
real_filepath = generate_filepath_for(media_item_with_preloads, "thumbnail.jpg") (metadata_map["thumbnails"] || [])
command_opts = [output: yt_dlp_filepath] # Give it a low preference if the `preference` key doesn't exist
addl_opts = [use_cookies: media_item_with_preloads.source.use_cookies] |> 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(media_item_with_preloads.original_url, command_opts, addl_opts) 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 """
@@ -126,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
+7 -6
View File
@@ -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
+9 -21
View File
@@ -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
+18 -48
View File
@@ -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
@@ -92,9 +94,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
defp determine_series_directory(source) do defp determine_series_directory(source) do
output_path = DownloadOptionBuilder.build_output_path_for(source) output_path = DownloadOptionBuilder.build_output_path_for(source)
runner_opts = [output: output_path] {:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, output: output_path)
addl_opts = [use_cookies: source.use_cookies]
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, runner_opts, addl_opts)
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
{:ok, series_directory} -> series_directory {:ok, series_directory} -> series_directory
@@ -110,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, use_cookies: source.use_cookies)
end
defp tmp_directory do defp tmp_directory do
Application.get_env(:pinchflat, :tmpfile_directory) Application.get_env(:pinchflat, :tmpfile_directory)
end end
+1 -1
View File
@@ -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)
+8 -1
View File
@@ -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
-3
View File
@@ -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
+36 -7
View File
@@ -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
@@ -94,8 +97,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
{:ok, pid} = FileFollowerServer.start_link() {:ok, pid} = FileFollowerServer.start_link()
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies] result = MediaCollection.get_media_attributes_for_collection(source.original_url, file_listener_handler: handler)
result = MediaCollection.get_media_attributes_for_collection(source.original_url, runner_opts)
FileFollowerServer.stop(pid) FileFollowerServer.stop(pid)
@@ -119,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
+1 -41
View File
@@ -27,7 +27,6 @@ defmodule Pinchflat.Sources.Source do
series_directory series_directory
index_frequency_minutes index_frequency_minutes
fast_index fast_index
use_cookies
download_media download_media
last_indexed_at last_indexed_at
original_url original_url
@@ -36,9 +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
min_duration_seconds
max_duration_seconds
)a )a
# Expensive API calls are made when a source is inserted/updated so # Expensive API calls are made when a source is inserted/updated so
@@ -76,7 +72,6 @@ defmodule Pinchflat.Sources.Source do
field :collection_type, Ecto.Enum, values: [:channel, :playlist] field :collection_type, Ecto.Enum, values: [:channel, :playlist]
field :index_frequency_minutes, :integer, default: 60 * 24 field :index_frequency_minutes, :integer, default: 60 * 24
field :fast_index, :boolean, default: false field :fast_index, :boolean, default: false
field :use_cookies, :boolean, default: false
field :download_media, :boolean, default: true field :download_media, :boolean, default: true
field :last_indexed_at, :utc_datetime field :last_indexed_at, :utc_datetime
# Only download media items that were published after this date # Only download media items that were published after this date
@@ -86,17 +81,12 @@ defmodule Pinchflat.Sources.Source do
field :title_filter_regex, :string field :title_filter_regex, :string
field :output_path_template_override, :string field :output_path_template_override, :string
field :min_duration_seconds, :integer
field :max_duration_seconds, :integer
field :series_directory, :string field :series_directory, :string
field :nfo_filepath, :string field :nfo_filepath, :string
field :poster_filepath, :string field :poster_filepath, :string
field :fanart_filepath, :string field :fanart_filepath, :string
field :banner_filepath, :string field :banner_filepath, :string
field :marked_for_deletion_at, :utc_datetime
belongs_to :media_profile, MediaProfile belongs_to :media_profile, MediaProfile
has_one :metadata, SourceMetadata, on_replace: :update has_one :metadata, SourceMetadata, on_replace: :update
@@ -122,12 +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_min_and_max_durations()
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0) |> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room) # Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}") |> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|> validate_format(:original_url, youtube_channel_or_playlist_regex(), message: "must be a channel or playlist URL")
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false) |> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url) |> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
end end
@@ -141,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
@@ -154,33 +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
defp validate_min_and_max_durations(changeset) do
min_duration = get_change(changeset, :min_duration_seconds)
max_duration = get_change(changeset, :max_duration_seconds)
case {min_duration, max_duration} do
{min, max} when is_nil(min) or is_nil(max) -> changeset
{min, max} when min >= max -> add_error(changeset, :max_duration_seconds, "must be greater than minumum duration")
_ -> changeset
end
end
defimpl Jason.Encoder, for: Source do defimpl Jason.Encoder, for: Source do
def encode(value, opts) do def encode(value, opts) do
value value
@@ -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
+1 -3
View File
@@ -180,9 +180,7 @@ defmodule Pinchflat.Sources do
end end
defp add_source_details_to_changeset(source, changeset) do defp add_source_details_to_changeset(source, changeset) do
use_cookies = Ecto.Changeset.get_field(changeset, :use_cookies) case MediaCollection.get_source_details(changeset.changes.original_url) do
case MediaCollection.get_source_details(changeset.changes.original_url, [], use_cookies: use_cookies) do
{:ok, source_details} -> {:ok, source_details} ->
add_source_details_by_collection_type(source, changeset, source_details) add_source_details_by_collection_type(source, changeset, source_details)
+17
View File
@@ -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
+2 -11
View File
@@ -18,8 +18,6 @@ defmodule Pinchflat.YtDlp.CommandRunner do
- :output_filepath - the path to save the output to. If not provided, a temporary - :output_filepath - the path to save the output to. If not provided, a temporary
file will be created and used. Useful for if you need a reference to the file file will be created and used. Useful for if you need a reference to the file
for a file watcher. for a file watcher.
- :use_cookies - if true, will add a cookie file to the command options. Will not
attach a cookie file if the user hasn't set one up.
Returns {:ok, binary()} | {:error, output, status}. Returns {:ok, binary()} | {:error, output, status}.
""" """
@@ -30,7 +28,7 @@ defmodule Pinchflat.YtDlp.CommandRunner do
output_filepath = generate_output_filepath(addl_opts) output_filepath = generate_output_filepath(addl_opts)
print_to_file_opts = [{:print_to_file, output_template}, output_filepath] print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
user_configured_opts = cookie_file_options(addl_opts) user_configured_opts = cookie_file_options()
# These must stay in exactly this order, hence why I'm giving it its own variable. # These must stay in exactly this order, hence why I'm giving it its own variable.
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options() all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts) formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
@@ -80,14 +78,7 @@ defmodule Pinchflat.YtDlp.CommandRunner do
] ]
end end
defp cookie_file_options(addl_opts) do defp cookie_file_options do
case Keyword.get(addl_opts, :use_cookies) do
true -> add_cookie_file()
_ -> []
end
end
defp add_cookie_file do
base_dir = Application.get_env(:pinchflat, :extras_directory) base_dir = Application.get_env(:pinchflat, :extras_directory)
filename_options_map = %{cookies: "cookies.txt"} filename_options_map = %{cookies: "cookies.txt"}
+11 -41
View File
@@ -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__
@@ -37,9 +36,9 @@ defmodule Pinchflat.YtDlp.Media do
Returns {:ok, map()} | {:error, any, ...}. Returns {:ok, map()} | {:error, any, ...}.
""" """
def download(url, command_opts \\ [], addl_opts \\ []) do def download(url, command_opts \\ [], addl_opts \\ []) do
all_command_opts = [:no_simulate] ++ command_opts opts = [:no_simulate] ++ command_opts
with {:ok, output} <- backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts), with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j", addl_opts),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json} {:ok, parsed_json}
else else
@@ -47,31 +46,17 @@ defmodule Pinchflat.YtDlp.Media do
end end
end end
@doc """
Downloads a thumbnail for a single piece of media. Usually used for
downloading thumbnails for internal use
Returns {:ok, ""} | {:error, any, ...}.
"""
def download_thumbnail(url, command_opts \\ [], addl_opts \\ []) do
all_command_opts = [:no_simulate, :skip_download, :write_thumbnail, convert_thumbnail: "jpg"] ++ command_opts
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since
# we aren't downloading the main media file
backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts)
end
@doc """ @doc """
Returns a map representing the media at the given URL. Returns a map representing the media at the given URL.
Returns {:ok, %Media{}} | {:error, any, ...}. Returns {:ok, [map()]} | {:error, any, ...}.
""" """
def get_media_attributes(url, addl_opts \\ []) do def get_media_attributes(url) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner) runner = Application.get_env(:pinchflat, :yt_dlp_runner)
command_opts = [:simulate, :skip_download] command_opts = [:simulate, :skip_download]
output_template = indexing_output_template() output_template = indexing_output_template()
case runner.run(url, command_opts, output_template, addl_opts) do case runner.run(url, command_opts, output_template) do
{:ok, output} -> {:ok, output} ->
output output
|> Phoenix.json_library().decode!() |> Phoenix.json_library().decode!()
@@ -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,live_status,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 """
@@ -104,11 +87,10 @@ defmodule Pinchflat.YtDlp.Media do
title: response["title"], title: response["title"],
description: response["description"], description: response["description"],
original_url: response["webpage_url"], original_url: response["webpage_url"],
livestream: !!response["live_status"] && response["live_status"] != "not_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)
+7 -20
View File
@@ -25,17 +25,15 @@ defmodule Pinchflat.YtDlp.MediaCollection do
# the first video has not released yet (ie: is a premier). We don't care about # the first video has not released yet (ie: is a premier). We don't care about
# available formats since we're just getting the media details # available formats since we're just getting the media details
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings] command_opts = [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings]
use_cookies = Keyword.get(addl_opts, :use_cookies, false)
output_template = YtDlpMedia.indexing_output_template() output_template = YtDlpMedia.indexing_output_template()
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json) output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false) file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies]
if file_listener_handler do if file_listener_handler do
file_listener_handler.(output_filepath) file_listener_handler.(output_filepath)
end end
case runner.run(url, command_opts, output_template, runner_opts) do case runner.run(url, command_opts, output_template, output_filepath: output_filepath) do
{:ok, output} -> {:ok, output} ->
parsed_lines = parsed_lines =
output output
@@ -66,7 +64,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
Returns {:ok, map()} | {:error, any, ...}. Returns {:ok, map()} | {:error, any, ...}.
""" """
def get_source_details(source_url, command_opts \\ [], addl_opts \\ []) do def get_source_details(source_url, addl_opts \\ []) do
# `ignore_no_formats_error` is necessary because yt-dlp will error out if # `ignore_no_formats_error` is necessary because yt-dlp will error out if
# the first video has not released yet (ie: is a premier). We don't care about # the first video has not released yet (ie: is a premier). We don't care about
# available formats since we're just getting the source details # available formats since we're just getting the source details
@@ -77,10 +75,10 @@ defmodule Pinchflat.YtDlp.MediaCollection do
playlist_end: 1 playlist_end: 1
] ]
all_command_opts = default_opts ++ command_opts command_opts = default_opts ++ addl_opts
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j" output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts), with {:ok, output} <- backend_runner().run(source_url, command_opts, output_template),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, format_source_details(parsed_json)} {:ok, format_source_details(parsed_json)}
else else
@@ -101,24 +99,13 @@ 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, command_opts, addl_opts \\ []) 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!(command_opts, :playlist_items)
all_command_opts = [:skip_download] ++ command_opts
output_template = "playlist:%()j" output_template = "playlist:%()j"
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts), with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json} {:ok, parsed_json}
else else
@@ -19,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 %>
+8 -1
View File
@@ -1,7 +1,14 @@
defmodule PinchflatWeb.ErrorHTML do defmodule PinchflatWeb.ErrorHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
embed_templates "error_html/*" # If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/pinchflat_web/controllers/error_html/404.html.heex
# * lib/pinchflat_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on # The default is to render a plain text page based on
# the template name. For example, "404.html" becomes # the template name. For example, "404.html" becomes
@@ -1,3 +0,0 @@
<section>
<h2 class="text-title-md2 font-bold text-white">404 (not found)</h2>
</section>
@@ -1,29 +0,0 @@
<section>
<h2 class="text-title-md2 font-bold text-white">Internal Server Error</h2>
<p class="text-body-md text-white mt-2">
This shouldn't happen! Please make a
<.inline_link href="https://github.com/kieraneglin/pinchflat/issues/new/choose">GitHub issue</.inline_link>
with the following information:
</p>
<ul class="list-disc ml-8 mb-8">
<li>What you were doing when you saw this page</li>
<li>
Your system details and logs from
<.inline_link href={~p"/app_info"}>app info</.inline_link>
</li>
<li>All the information in the textarea below (use select all + copy)</li>
</ul>
<textarea class="w-full min-h-96 font-mono inline-block rounded-lg" readonly>
**Status**:
`<%= if Map.has_key?(assigns, :status), do: @status, else: "" %>`
**Reason**:
`<%= if Map.has_key?(assigns, :reason), do: inspect(@reason), else: "" %>`
**Stacktrace**:
```
<%= if Map.has_key?(assigns, :stack), do: Exception.format_stacktrace(@stack), else: "" %>
```
</textarea>
</section>
@@ -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
@@ -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>
@@ -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,31 +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,
description: nil,
collection_name: nil,
collection_id: nil,
collection_type: nil,
original_url: nil
})
) )
end end
@@ -127,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
@@ -86,31 +79,6 @@
help="Unchecking still indexes media but it won't be downloaded until you enable this option" help="Unchecking still indexes media but it won't be downloaded until you enable this option"
/> />
<.input
field={f[:use_cookies]}
type="toggle"
label="Use Cookies for Downloading"
help="Uses your YouTube cookies for this source (if configured). Used for downloading private playlists and videos. See docs for important details"
/>
<section x-show="advancedMode">
<.input
field={f[:min_duration_seconds]}
type="number"
label="Minimum Duration (seconds)"
min="0"
help="Minimum duration of the media to be downloaded. Can be blank"
/>
<.input
field={f[:max_duration_seconds]}
type="number"
label="Maximum Duration (seconds)"
min="0"
help="Maximum duration of the media to be downloaded. Can be blank"
/>
</section>
<.input <.input
field={f[:download_cutoff_date]} field={f[:download_cutoff_date]}
type="text" type="text"
@@ -119,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
@@ -127,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">
+1 -1
View File
@@ -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 -7
View File
@@ -4,8 +4,8 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2024.8.28", 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
+1 -1
View File
@@ -7,7 +7,7 @@
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy": {: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
View File
@@ -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=.prettierrc.js --ignore-path=.prettierignore --ignore-path=.gitignore",
"lint:fix": "prettier . --write --config=.prettierrc.js --ignore-path=.prettierignore --ignore-path=.gitignore"
}, },
"private": true "private": true
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 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
@@ -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
@@ -1,9 +0,0 @@
defmodule Pinchflat.Repo.Migrations.AddUseCookiesToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :use_cookies, :boolean, default: true, null: false
end
end
end
@@ -1,10 +0,0 @@
defmodule Pinchflat.Repo.Migrations.AddDurationLimitsToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :min_duration_seconds, :integer
add :max_duration_seconds, :integer
end
end
end
Binary file not shown.

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}"
@@ -27,10 +27,10 @@ RUN apt-get update -y && \
# Hex and Rebar # Hex and Rebar
mix local.hex --force && \ mix local.hex --force && \
mix local.rebar --force && \ mix local.rebar --force && \
# FFmpeg (latest build that doesn't cause an illegal instruction error for some users - see #347) # FFmpeg
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \ export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/autobuild-2024-07-30-14-10/ffmpeg-N-116468-g0e09f6d690-linux64-gpl.tar.xz" ;; \ "linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" ;; \
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/autobuild-2024-07-30-14-10/ffmpeg-N-116468-g0e09f6d690-linuxarm64-gpl.tar.xz" ;; \ "linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \ curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \ tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \
@@ -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

Some files were not shown because too many files have changed in this diff Show More