Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5bf51dfb3 | |||
| 5d624f545b | |||
| 1305a485a0 | |||
| 7603ba017f | |||
| 10880e9699 | |||
| b2e16d50cd | |||
| 3a5c06fe64 | |||
| 99fc2eb8f8 | |||
| e824b1a9f5 | |||
| 95a0c29358 | |||
| 8b0b41186a | |||
| d2f91a8253 | |||
| 776b84c585 | |||
| 29803c9b26 | |||
| 85be8744d4 | |||
| 1cbc62dc27 | |||
| 5af22d3a2f | |||
| bf917febe8 | |||
| 8189dcce71 | |||
| 4e4092c792 | |||
| 0cd6ac797b | |||
| 81d5efd4c1 | |||
| dec3938780 | |||
| a2a0f48065 | |||
| 94c0cf9970 | |||
| bbdc56c656 | |||
| 7bf74a04e7 | |||
| 33040bba34 | |||
| b1a6a6a81f | |||
| a7b0af65e2 | |||
| dcf033f10a | |||
| 8d9bd11882 | |||
| bdcb49185a | |||
| 1f1cd1cb63 | |||
| d575548226 | |||
| 2dfdb31aa4 | |||
| c27e1dba96 | |||
| 65b8790a35 | |||
| b7c6f272a2 | |||
| a38ffbc55b | |||
| 5c86e7192e | |||
| 77a9ff4d9f | |||
| d093799801 | |||
| 90f709dbc8 | |||
| c5975e08da | |||
| 933daf8c78 | |||
| b5d2ba4191 | |||
| a1a568b7f7 | |||
| 1b7fb6d14b | |||
| 944d26b57d | |||
| 5427c6247f | |||
| f655a8ae01 | |||
| 7090349abd | |||
| f2c9c10437 | |||
| ff188f9351 | |||
| 04b14719ee | |||
| 112c6a4f14 | |||
| 8051107d32 | |||
| 3f74f199dc | |||
| 09cac46e14 |
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: '[Triage] <your title here> '
|
||||
labels: triage
|
||||
assignees: kieraneglin
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Diagnostic info**
|
||||
|
||||
<!-- Go to Config > App Info > Copy Diagnostic Info and paste that here -->
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Go to Config > App Info > Download Logs and attach them, if applicable -->
|
||||
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: '[FR] <your title here>'
|
||||
labels: feature request
|
||||
assignees: kieraneglin
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. It's too complicated to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: Other
|
||||
about: For everything else
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: kieraneglin
|
||||
---
|
||||
@@ -40,9 +40,7 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}
|
||||
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
type=ref,event=tag
|
||||
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"Config.HTTPS",
|
||||
"Config.CSP",
|
||||
"XSS.ContentType",
|
||||
"Traversal.SendFile"
|
||||
"Traversal.SendFile",
|
||||
"Traversal.SendDownload"
|
||||
],
|
||||
ignore_files: [],
|
||||
version: false
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
- [Unraid](#unraid)
|
||||
- [Portainer](#portainer)
|
||||
- [Docker](#docker)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Username and Password (authentication)](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password)
|
||||
- [Frequently asked questions](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions)
|
||||
- [Documentation](https://github.com/kieraneglin/pinchflat/wiki)
|
||||
@@ -40,9 +41,9 @@
|
||||
|
||||
## What it does
|
||||
|
||||
Pinchflat is a self-hosted app for downloading YouTube content built using [yt-dlp](https://github.com/yt-dlp/yt-dlp). It's designed to be lightweight, self-contained, and easy to use. You set up rules for how to download content from YouTube channels or playlists and it'll do the rest, checking periodically for new content. It's perfect for people who want to download content for use in with a media center app (Plex, Jellyfin, Kodi) or for those who want to archive media!
|
||||
Pinchflat is a self-hosted app for downloading YouTube content built using [yt-dlp](https://github.com/yt-dlp/yt-dlp). It's designed to be lightweight, self-contained, and easy to use. You set up rules for how to download content from YouTube channels or playlists and it'll do the rest, periodically checking for new content. It's perfect for people who want to download content for use in with a media center app (Plex, Jellyfin, Kodi) or for those who want to archive media!
|
||||
|
||||
It's _not_ great for downloading one-off videos - it's built to download large amounts of content and keep it up to date. It's also not meant for consuming content in-app - Pinchflat downloads content to disk where you can then watch it with a media center app or VLC.
|
||||
While you can [download individual videos](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#how-do-i-download-one-off-videos), Pinchflat is best suited for downloading content from channels or playlists. It's also not meant for consuming content in-app - Pinchflat downloads content to disk where you can then watch it with a media center app or VLC.
|
||||
|
||||
If it doesn't work for your use case, please make a feature request! You can also check out these great alternatives: [Tube Archivist](https://github.com/tubearchivist/tubearchivist), [ytdl-sub](https://github.com/jmbannon/ytdl-sub), and [TubeSync](https://github.com/meeb/tubesync)
|
||||
|
||||
@@ -66,6 +67,7 @@ If it doesn't work for your use case, please make a feature request! You can als
|
||||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||
- Sponsorblock integration
|
||||
- \[Advanced\] allows custom `yt-dlp` options ([docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-yt%E2%80%90dlp-options))
|
||||
- \[Advanced\] supports running custom scripts when after downloading/deleting media (alpha - [docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-lifecycle-scripts))
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -90,6 +92,9 @@ version: '3'
|
||||
services:
|
||||
pinchflat:
|
||||
image: keglin/pinchflat:latest
|
||||
environment:
|
||||
# Set the timezone to your local timezone
|
||||
- TZ=America/New_York
|
||||
ports:
|
||||
- '8945:8945'
|
||||
volumes:
|
||||
@@ -109,7 +114,9 @@ services:
|
||||
```bash
|
||||
# Be sure to replace /host/path/to/config and /host/path/to/downloads below with
|
||||
# the paths to the directories you created in step 1
|
||||
# Be sure to replace America/New_York with your local timezone
|
||||
docker run \
|
||||
-e TZ=America/New_York \
|
||||
-p 8945:8945 \
|
||||
-v /host/path/to/config:/config \
|
||||
-v /host/path/to/downloads:/downloads \
|
||||
@@ -135,9 +142,23 @@ As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), S
|
||||
|
||||
If you change this setting and it works well for you, please leave a comment on [#137](https://github.com/kieraneglin/pinchflat/issues/137)! Doubly so if it does _not_ work well.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Name | Required? | Default | Notes |
|
||||
| --------------------- | --------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| TZ | No | `UTC` | Must follow IANA TZ format |
|
||||
| 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_PASSWORD | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
||||
| EXPOSE_FEED_ENDPOINTS | No | | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) |
|
||||
| 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 |
|
||||
|
||||
## EFF donations
|
||||
|
||||
A portion of all donations to Pinchflat will be donated to the [Electronic Frontier Foundation](https://www.eff.org/). The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down. [See here](https://github.com/kieraneglin/pinchflat/wiki/EFF-Donation-Receipts) for a list of donation receipts.
|
||||
Prior to 2024-05-10, a portion of all donations were given to the [Electronic Frontier Foundation](https://www.eff.org/). Now, the app doesn't accept donations that go to me personally and instead directs you straight to the EFF. [Here](https://github.com/kieraneglin/pinchflat/issues/234) are some people that have generously donated.
|
||||
|
||||
The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down.
|
||||
|
||||
## Pre-release disclaimer
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
window.copyTextToClipboard = async (text) => {
|
||||
// Navigator clipboard api needs a secure context (https)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
// Move textarea out of the viewport so it's not visible
|
||||
textArea.style.position = 'absolute'
|
||||
textArea.style.left = '-999999px'
|
||||
|
||||
document.body.prepend(textArea)
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
textArea.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.copyWithCallbacks = async (text, onCopy, onAfterDelay, delay = 4000) => {
|
||||
await window.copyTextToClipboard(text)
|
||||
onCopy()
|
||||
setTimeout(onAfterDelay, delay)
|
||||
}
|
||||
|
||||
window.markVersionAsSeen = (versionString) => {
|
||||
localStorage.setItem('seenVersion', versionString)
|
||||
}
|
||||
|
||||
window.isVersionSeen = (versionString) => {
|
||||
return localStorage.getItem('seenVersion') === versionString
|
||||
}
|
||||
+1
-24
@@ -23,6 +23,7 @@ import { LiveSocket } from 'phoenix_live_view'
|
||||
import topbar from '../vendor/topbar'
|
||||
import Alpine from 'alpinejs'
|
||||
import './tabs'
|
||||
import './alpine_helpers'
|
||||
|
||||
window.Alpine = Alpine
|
||||
Alpine.start()
|
||||
@@ -50,30 +51,6 @@ let liveSocket = new LiveSocket('/live', Socket, {
|
||||
}
|
||||
})
|
||||
|
||||
window.copyTextToClipboard = async (text) => {
|
||||
// Navigator clipboard api needs a secure context (https)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
// Move textarea out of the viewport so it's not visible
|
||||
textArea.style.position = 'absolute'
|
||||
textArea.style.left = '-999999px'
|
||||
|
||||
document.body.prepend(textArea)
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
textArea.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
|
||||
window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300))
|
||||
|
||||
+7
-7
@@ -1,20 +1,20 @@
|
||||
window.setTabIndex = (index) => {
|
||||
window.location.hash = `tab-${index}`
|
||||
window.setTabByName = (tabName) => {
|
||||
window.location.hash = `tab-${tabName}`
|
||||
|
||||
return index
|
||||
return tabName
|
||||
}
|
||||
|
||||
// The conditionals and currIndex stuff ensures that
|
||||
// the tab index is always set to 0 if the hash is empty
|
||||
// AND other hash values are ignored
|
||||
window.getTabIndex = (currIndex) => {
|
||||
window.getTabFromHash = (currentTabName, defaultTabName) => {
|
||||
if (window.location.hash === '' || window.location.hash === '#') {
|
||||
return 0
|
||||
return defaultTabName
|
||||
}
|
||||
|
||||
if (window.location.hash.startsWith('#tab-')) {
|
||||
return parseInt(window.location.hash.replace('#tab-', ''))
|
||||
return window.location.hash.replace('#tab-', '')
|
||||
}
|
||||
|
||||
return currIndex
|
||||
return currentTabName
|
||||
}
|
||||
|
||||
+4
-3
@@ -14,7 +14,7 @@ config :pinchflat,
|
||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||
apprise_executable: System.find_executable("apprise"),
|
||||
yt_dlp_runner: Pinchflat.YtDlp.CommandRunner,
|
||||
apprise_runner: Pinchflat.Notifications.CommandRunner,
|
||||
apprise_runner: Pinchflat.Lifecycle.Notifications.CommandRunner,
|
||||
media_directory: "/downloads",
|
||||
# The user may or may not store metadata for their needs, but the app will always store its copy
|
||||
metadata_directory: "/config/metadata",
|
||||
@@ -25,7 +25,8 @@ config :pinchflat,
|
||||
basic_auth_username: "",
|
||||
basic_auth_password: "",
|
||||
expose_feed_endpoints: false,
|
||||
file_watcher_poll_interval: 1000
|
||||
file_watcher_poll_interval: 1000,
|
||||
timezone: "UTC"
|
||||
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
journal_mode: :wal,
|
||||
@@ -53,7 +54,7 @@ config :pinchflat, Oban,
|
||||
{Oban.Plugins.Cron,
|
||||
crontab: [
|
||||
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
|
||||
{"0 2 * * *", Pinchflat.Downloading.MediaRedownloadWorker}
|
||||
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
|
||||
]}
|
||||
],
|
||||
# TODO: consider making this an env var or something?
|
||||
|
||||
+6
-2
@@ -53,7 +53,7 @@ if config_env() == :prod do
|
||||
# For testing alternate journal modes (see issue #137)
|
||||
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
|
||||
|
||||
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
||||
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
|
||||
|
||||
config :pinchflat,
|
||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||
@@ -63,7 +63,11 @@ if config_env() == :prod do
|
||||
extras_directory: extras_path,
|
||||
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
||||
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",
|
||||
log_path: log_path
|
||||
|
||||
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
|
||||
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
database: db_path,
|
||||
|
||||
+19
-8
@@ -5,10 +5,23 @@ ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEB
|
||||
|
||||
FROM ${DEV_IMAGE}
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
RUN echo "Building for ${TARGETPLATFORM:?}"
|
||||
|
||||
# Install debian packages
|
||||
RUN apt-get update -qq
|
||||
RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client \
|
||||
python3 python3-pip python3-setuptools python3-wheel python3-dev locales procps
|
||||
RUN apt-get install -y inotify-tools curl git openssh-client jq \
|
||||
python3 python3-setuptools python3-wheel python3-dev pipx \
|
||||
python3-mutagen locales procps build-essential graphviz
|
||||
|
||||
# Install ffmpeg
|
||||
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" ;; \
|
||||
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz" ;; \
|
||||
*) echo "" ;; esac) && \
|
||||
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \
|
||||
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"
|
||||
|
||||
# Install nodejs
|
||||
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
|
||||
@@ -21,16 +34,14 @@ RUN mix local.hex --force
|
||||
RUN mix local.rebar --force
|
||||
|
||||
# Download and update YT-DLP
|
||||
# NOTE: If you're seeing weird issues, consider using the FFMPEG released by yt-dlp
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
|
||||
RUN chmod a+rx /usr/local/bin/yt-dlp
|
||||
RUN yt-dlp -U
|
||||
|
||||
# Download Apprise
|
||||
RUN python3 -m pip install -U apprise --break-system-packages
|
||||
|
||||
# Download Mutagen for music thumbnail generation
|
||||
RUN python3 -m pip install -U mutagen --break-system-packages
|
||||
# Install Apprise
|
||||
RUN export PIPX_HOME=/opt/pipx && \
|
||||
export PIPX_BIN_DIR=/usr/local/bin && \
|
||||
pipx install apprise
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
- Write media datbase ID as metadata/to file/whatever so it gives us an option to retroactively match media to the DB down the line. Useful if someone moves the media without informing the UI
|
||||
- Use a UUID for the media database ID (or at least alongside it)
|
||||
- Look into this and its recommended plugins https://hexdocs.pm/ex_check/readme.html
|
||||
- Add output template option for the source's friendly name
|
||||
@@ -24,7 +24,7 @@ defmodule Pinchflat.Application do
|
||||
PinchflatWeb.Endpoint
|
||||
]
|
||||
|
||||
:ok = Oban.Telemetry.attach_default_logger()
|
||||
attach_oban_telemetry()
|
||||
Logger.add_handlers(:pinchflat)
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
@@ -40,4 +40,11 @@ defmodule Pinchflat.Application do
|
||||
PinchflatWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp attach_oban_telemetry do
|
||||
events = [[:oban, :job, :start], [:oban, :job, :stop], [:oban, :job, :exception]]
|
||||
|
||||
:ok = Oban.Telemetry.attach_default_logger()
|
||||
:telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, [])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,13 +31,23 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
"""
|
||||
@impl true
|
||||
def init(state) do
|
||||
ensure_tmpfile_directory()
|
||||
reset_executing_jobs()
|
||||
create_blank_yt_dlp_files()
|
||||
create_blank_user_script_file()
|
||||
apply_default_settings()
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
defp ensure_tmpfile_directory do
|
||||
tmpfile_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
|
||||
if !File.exists?(tmpfile_dir) do
|
||||
File.mkdir_p!(tmpfile_dir)
|
||||
end
|
||||
end
|
||||
|
||||
# If a node cannot gracefully shut down, the currently executing jobs get stuck
|
||||
# in the "executing" state. This is a problem because the job runner will not
|
||||
# pick them up again
|
||||
@@ -65,6 +75,18 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_blank_user_script_file do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
|
||||
if !File.exists?(filepath) do
|
||||
Logger.info("Creating blank file and making it executable: #{filepath}")
|
||||
|
||||
FilesystemUtils.write_p!(filepath, "")
|
||||
File.chmod(filepath, 0o755)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_default_settings do
|
||||
{:ok, yt_dlp_version} = yt_dlp_runner().version()
|
||||
{:ok, apprise_version} = apprise_runner().version()
|
||||
|
||||
@@ -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
|
||||
@@ -6,18 +6,21 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.CodecParser
|
||||
alias Pinchflat.Downloading.OutputPathBuilder
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||
|
||||
@doc """
|
||||
Builds the options for yt-dlp to download media based on the given media's profile.
|
||||
|
||||
Returns {:ok, [Keyword.t()]}
|
||||
"""
|
||||
def build(%MediaItem{} = media_item_with_preloads) do
|
||||
def build(%MediaItem{} = media_item_with_preloads, override_opts \\ []) do
|
||||
media_profile = media_item_with_preloads.source.media_profile
|
||||
|
||||
built_options =
|
||||
default_options() ++
|
||||
default_options(override_opts) ++
|
||||
subtitle_options(media_profile) ++
|
||||
thumbnail_options(media_item_with_preloads) ++
|
||||
metadata_options(media_profile) ++
|
||||
@@ -33,19 +36,27 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
Builds the output path for yt-dlp to download media based on the given source's
|
||||
media profile. Uses the source's override output path template if it exists.
|
||||
|
||||
Accepts a %MediaItem{} or %Source{} struct. If a %Source{} struct is passed, it
|
||||
will use a default %MediaItem{} struct with the given source.
|
||||
|
||||
Returns binary()
|
||||
"""
|
||||
def build_output_path_for(%Source{} = source_with_preloads) do
|
||||
output_path_template = Sources.output_path_template(source_with_preloads)
|
||||
def build_output_path_for(%MediaItem{} = media_item_with_preloads) do
|
||||
output_path_template = Sources.output_path_template(media_item_with_preloads.source)
|
||||
|
||||
build_output_path(output_path_template, source_with_preloads)
|
||||
build_output_path(output_path_template, media_item_with_preloads)
|
||||
end
|
||||
|
||||
defp default_options do
|
||||
def build_output_path_for(%Source{} = source_with_preloads) do
|
||||
build_output_path_for(%MediaItem{source: source_with_preloads})
|
||||
end
|
||||
|
||||
defp default_options(override_opts) do
|
||||
overwrite_behaviour = Keyword.get(override_opts, :overwrite_behaviour, :force_overwrites)
|
||||
|
||||
[
|
||||
:no_progress,
|
||||
# Add force-overwrites to make sure redownloading works
|
||||
:force_overwrites,
|
||||
overwrite_behaviour,
|
||||
# This makes the date metadata conform to what jellyfin expects
|
||||
parse_metadata: "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"
|
||||
]
|
||||
@@ -111,18 +122,26 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
end
|
||||
|
||||
defp quality_options(media_profile) do
|
||||
video_codec_option = fn res ->
|
||||
[format_sort: "res:#{res},+codec:avc:m4a", remux_video: "mp4"]
|
||||
end
|
||||
vcodec_string = CodecParser.generate_vcodec_string_from_settings()
|
||||
acodec_string = CodecParser.generate_acodec_string_from_settings()
|
||||
|
||||
case media_profile.preferred_resolution do
|
||||
# Also be aware that :audio disabled all embedding options for subtitles
|
||||
:audio -> [:extract_audio, format: "bestaudio[ext=m4a]"]
|
||||
:"360p" -> video_codec_option.("360")
|
||||
:"480p" -> video_codec_option.("480")
|
||||
:"720p" -> video_codec_option.("720")
|
||||
:"1080p" -> video_codec_option.("1080")
|
||||
:"2160p" -> video_codec_option.("2160")
|
||||
:audio ->
|
||||
[:extract_audio, format: "#{acodec_string}/best"]
|
||||
|
||||
resolution_atom ->
|
||||
{resolution_string, _} =
|
||||
resolution_atom
|
||||
|> Atom.to_string()
|
||||
|> Integer.parse()
|
||||
|
||||
[
|
||||
format_sort: "res:#{resolution_string}",
|
||||
# Since Plex doesn't support reading metadata from MKV
|
||||
remux_video: "mp4",
|
||||
format: "((#{vcodec_string})+(#{acodec_string}))/best"
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -154,12 +173,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
Enum.reduce(filenames, [], fn filename, acc ->
|
||||
filepath = Path.join(base_dir, filename)
|
||||
|
||||
case File.read(filepath) do
|
||||
{:ok, file_data} ->
|
||||
if String.trim(file_data) != "", do: [filepath | acc], else: acc
|
||||
|
||||
{:error, _} ->
|
||||
acc
|
||||
if FSUtils.exists_and_nonempty?(filepath) do
|
||||
[filepath | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -168,23 +185,29 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
|
||||
defp output_options(media_item_with_preloads) do
|
||||
[
|
||||
output: build_output_path_for(media_item_with_preloads.source)
|
||||
output: build_output_path_for(media_item_with_preloads)
|
||||
]
|
||||
end
|
||||
|
||||
defp build_output_path(string, source) do
|
||||
additional_options_map = output_options_map(source)
|
||||
defp build_output_path(string, media_item_with_preloads) do
|
||||
additional_options_map = output_options_map(media_item_with_preloads)
|
||||
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
|
||||
|
||||
Path.join(base_directory(), output_path)
|
||||
end
|
||||
|
||||
defp output_options_map(source) do
|
||||
defp output_options_map(media_item_with_preloads) do
|
||||
source = media_item_with_preloads.source
|
||||
|
||||
%{
|
||||
"source_custom_name" => source.custom_name,
|
||||
"source_collection_id" => source.collection_id,
|
||||
"source_collection_name" => source.collection_name,
|
||||
"source_collection_type" => source.collection_type
|
||||
"source_collection_type" => to_string(source.collection_type),
|
||||
"media_upload_date_index" =>
|
||||
media_item_with_preloads.upload_date_index
|
||||
|> to_string()
|
||||
|> String.pad_leading(2, "0")
|
||||
}
|
||||
end
|
||||
|
||||
@@ -198,7 +221,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
|> String.split(~r{\.}, include_captures: true)
|
||||
|> List.insert_at(-3, "-thumb")
|
||||
|> Enum.join()
|
||||
|> build_output_path(media_item_with_preloads.source)
|
||||
|> build_output_path(media_item_with_preloads)
|
||||
end
|
||||
|
||||
defp base_directory do
|
||||
|
||||
@@ -7,6 +7,8 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
||||
|
||||
require Logger
|
||||
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
@@ -14,10 +16,14 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||
|
||||
@doc """
|
||||
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.
|
||||
|
||||
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,
|
||||
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
|
||||
@@ -25,13 +31,17 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
||||
|
||||
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
|
||||
|> 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
|
||||
|
||||
def enqueue_pending_download_tasks(%Source{download_media: false}) do
|
||||
def enqueue_pending_download_tasks(%Source{download_media: false}, _opts) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@@ -51,17 +61,80 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
||||
downloaded, based on the source's download settings and whether media is
|
||||
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()}
|
||||
"""
|
||||
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)
|
||||
|
||||
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})")
|
||||
|
||||
MediaDownloadWorker.kickoff_with_task(media_item)
|
||||
MediaDownloadWorker.kickoff_with_task(media_item, %{}, schedule_in: kickoff_delay)
|
||||
else
|
||||
{:error, :should_not_download}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
For a given source, enqueues download jobs for all media items _that have already been downloaded_.
|
||||
|
||||
This is useful for when a source's download settings have changed and you want to run through all
|
||||
existing media and retry the download. For instance, if the source didn't originally download thumbnails
|
||||
and you've changed the source to download them, you can use this to download all the thumbnails for
|
||||
existing media items.
|
||||
|
||||
NOTE: does not delete existing files whatsoever. Does not overwrite the existing media file if it exists
|
||||
at the location it expects. Will cause a full redownload of everything if the output template has changed
|
||||
|
||||
NOTE: unrelated to the MediaQualityUpgradeWorker, which is for redownloading media items for quality upgrades
|
||||
or improved sponsorblock segments
|
||||
|
||||
Returns [{:ok, %Task{}} | {:error, any()}]
|
||||
"""
|
||||
def kickoff_redownload_for_existing_media(%Source{} = source) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(
|
||||
^dynamic(
|
||||
[m, s, mp],
|
||||
^MediaQuery.for_source(source) and
|
||||
^MediaQuery.downloaded() and
|
||||
not (^MediaQuery.download_prevented()) and
|
||||
not (^MediaQuery.culled())
|
||||
)
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(&MediaDownloadWorker.kickoff_with_task/1)
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
use Oban.Worker,
|
||||
queue: :media_fetching,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||
tags: ["media_item", "media_fetching"]
|
||||
tags: ["media_item", "media_fetching", "show_in_dashboard"]
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -14,6 +14,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Downloading.MediaDownloader
|
||||
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||
|
||||
@doc """
|
||||
Starts the media_item media download worker and creates a task for the media_item.
|
||||
|
||||
@@ -31,12 +33,18 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
Does not download media if its source is set to not download media
|
||||
(unless forced).
|
||||
|
||||
Options:
|
||||
- `force`: force download even if the source is set to not download media. Fully
|
||||
re-downloads media, including the video
|
||||
- `quality_upgrade?`: re-downloads media, including the video. Does not force download
|
||||
if the source is set to not download media
|
||||
|
||||
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||
"""
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
||||
should_force = Map.get(args, "force", false)
|
||||
is_redownload = Map.get(args, "redownload?", false)
|
||||
is_quality_upgrade = Map.get(args, "quality_upgrade?", false)
|
||||
|
||||
media_item =
|
||||
media_item_id
|
||||
@@ -45,7 +53,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
|
||||
# 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
|
||||
download_media_and_schedule_jobs(media_item, is_redownload)
|
||||
download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
@@ -54,39 +62,59 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
||||
end
|
||||
|
||||
defp download_media_and_schedule_jobs(media_item, is_redownload) do
|
||||
case MediaDownloader.download_for_media_item(media_item) do
|
||||
{:ok, updated_media_item} ->
|
||||
Media.update_media_item(updated_media_item, %{
|
||||
media_size_bytes: compute_media_filesize(updated_media_item),
|
||||
media_redownloaded_at: get_redownloaded_at(is_redownload)
|
||||
})
|
||||
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
|
||||
override_opts = [overwrite_behaviour: overwrite_behaviour]
|
||||
|
||||
case MediaDownloader.download_for_media_item(media_item, override_opts) do
|
||||
{:ok, downloaded_media_item} ->
|
||||
{:ok, updated_media_item} =
|
||||
Media.update_media_item(downloaded_media_item, %{
|
||||
media_size_bytes: compute_media_filesize(downloaded_media_item),
|
||||
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
|
||||
})
|
||||
|
||||
:ok = run_user_script(updated_media_item)
|
||||
|
||||
{:ok, updated_media_item}
|
||||
|
||||
{:recovered, _} ->
|
||||
{:error, :retry}
|
||||
|
||||
{:error, _message} ->
|
||||
{:error, :download_failed}
|
||||
{:error, message} ->
|
||||
action_on_error(message)
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_media_filesize(media_item) do
|
||||
case File.stat(media_item.media_filepath) do
|
||||
{:ok, %{size: size}} ->
|
||||
size
|
||||
|
||||
_ ->
|
||||
nil
|
||||
{:ok, %{size: size}} -> size
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_redownloaded_at(is_redownload) do
|
||||
if is_redownload do
|
||||
DateTime.utc_now()
|
||||
defp get_redownloaded_at(true), do: DateTime.utc_now()
|
||||
defp get_redownloaded_at(_), do: nil
|
||||
|
||||
defp action_on_error(message) do
|
||||
# This will attempt re-download at the next indexing, but it won't be retried
|
||||
# immediately as part of job failure logic
|
||||
non_retryable_errors = ["Video unavailable"]
|
||||
|
||||
if String.contains?(to_string(message), non_retryable_errors) do
|
||||
Logger.error("yt-dlp download will not be retried: #{inspect(message)}")
|
||||
|
||||
{:ok, :non_retry}
|
||||
else
|
||||
nil
|
||||
{:error, :download_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# 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.
|
||||
defp run_user_script(media_item) do
|
||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||
|
||||
runner.run(:media_downloaded, media_item)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,11 +29,11 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
||||
|
||||
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||
"""
|
||||
def download_for_media_item(%MediaItem{} = media_item) do
|
||||
def download_for_media_item(%MediaItem{} = media_item, override_opts \\ []) do
|
||||
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
|
||||
|
||||
case download_with_options(media_item.original_url, media_with_preloads, output_filepath) do
|
||||
case download_with_options(media_item.original_url, media_with_preloads, output_filepath, override_opts) do
|
||||
{:ok, parsed_json} ->
|
||||
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
|
||||
|
||||
@@ -103,8 +103,8 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
||||
end
|
||||
end
|
||||
|
||||
defp download_with_options(url, item_with_preloads, output_filepath) do
|
||||
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads)
|
||||
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
|
||||
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
|
||||
|
||||
YtDlpMedia.download(url, options, output_filepath: output_filepath)
|
||||
end
|
||||
|
||||
+6
-4
@@ -1,10 +1,10 @@
|
||||
defmodule Pinchflat.Downloading.MediaRedownloadWorker do
|
||||
defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do
|
||||
@moduledoc false
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :media_fetching,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||
tags: ["media_item", "media_fetching"]
|
||||
tags: ["media_item", "media_fetching", "show_in_dashboard"]
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -12,7 +12,9 @@ defmodule Pinchflat.Downloading.MediaRedownloadWorker do
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
|
||||
@doc """
|
||||
Redownloads media items that are eligible for redownload.
|
||||
Redownloads media items that are eligible for redownload for the purpose
|
||||
of upgrading the quality of the media or improving things like sponsorblock
|
||||
segments.
|
||||
|
||||
This worker is scheduled to run daily via the Oban Cron plugin
|
||||
and it should run _after_ the retention worker.
|
||||
@@ -25,7 +27,7 @@ defmodule Pinchflat.Downloading.MediaRedownloadWorker do
|
||||
Logger.info("Redownloading #{length(redownloadable_media)} media items")
|
||||
|
||||
Enum.each(redownloadable_media, fn media_item ->
|
||||
MediaDownloadWorker.kickoff_with_task(media_item, %{redownload?: true})
|
||||
MediaDownloadWorker.kickoff_with_task(media_item, %{quality_upgrade?: true})
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -9,13 +9,23 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
|
||||
Builds the actual final filepath from a given template. Optionally, you can pass in
|
||||
a map of additional options to be used in the template.
|
||||
|
||||
Custom options are recursively expanded _once_ so you can nest custom options
|
||||
one-deep if needed.
|
||||
|
||||
Translates liquid-style templates into yt-dlp-style templates,
|
||||
leaving yt-dlp syntax intact.
|
||||
"""
|
||||
def build(template_string, additional_template_options \\ %{}) do
|
||||
combined_options = Map.merge(custom_yt_dlp_option_map(), additional_template_options)
|
||||
|
||||
TemplateParser.parse(template_string, combined_options, &identifier_fn/2)
|
||||
expanded_options =
|
||||
Enum.map(combined_options, fn {key, value} ->
|
||||
{:ok, parse_result} = TemplateParser.parse(value, combined_options, &identifier_fn/2)
|
||||
|
||||
{key, parse_result}
|
||||
end)
|
||||
|
||||
TemplateParser.parse(template_string, Map.new(expanded_options), &identifier_fn/2)
|
||||
end
|
||||
|
||||
# The `nil` case simply wraps the identifier in yt-dlp-style syntax. This assumes that
|
||||
@@ -43,6 +53,7 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
|
||||
"upload_yyyy_mm_dd" => "%(upload_date>%Y-%m-%d)S",
|
||||
"season_from_date" => "%(upload_date>%Y)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 }}",
|
||||
"artist_name" => "%(artist,creator,uploader,uploader_id)S"
|
||||
}
|
||||
end
|
||||
|
||||
@@ -7,10 +7,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
|
||||
require Logger
|
||||
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.FastIndexing.YoutubeRss
|
||||
alias Pinchflat.Downloading.DownloadingHelpers
|
||||
|
||||
@@ -41,15 +41,15 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
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)
|
||||
end
|
||||
|
||||
defp list_media_items_by_media_id_for(source, media_ids) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_ids(media_ids)
|
||||
|> where(^dynamic([mi], ^MediaQuery.for_source(source) and mi.media_id in ^media_ids))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -57,8 +57,8 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
url = "https://www.youtube.com/watch?v=#{media_id}"
|
||||
|
||||
case YtDlpMedia.get_media_attributes(url) do
|
||||
{:ok, media_attrs} ->
|
||||
Media.create_media_item_from_backend_attrs(source, media_attrs)
|
||||
{:ok, media_attrs_struct} ->
|
||||
DownloadingHelpers.create_media_item_and_run_script(source, media_attrs_struct)
|
||||
|
||||
err ->
|
||||
err
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||
use Oban.Worker,
|
||||
queue: :fast_indexing,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||
tags: ["media_source", "fast_indexing"]
|
||||
tags: ["media_source", "fast_indexing", "show_in_dashboard"]
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -15,7 +15,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
alias Pinchflat.Notifications.SourceNotifications
|
||||
alias Pinchflat.Lifecycle.Notifications.SourceNotifications
|
||||
|
||||
@doc """
|
||||
Starts the source fast indexing worker and creates a task for the source.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
defmodule Pinchflat.Notifications.AppriseCommandRunner do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.AppriseCommandRunner do
|
||||
@moduledoc """
|
||||
A behaviour for running CLI commands against a notification backend (apprise).
|
||||
|
||||
+4
-5
@@ -1,4 +1,4 @@
|
||||
defmodule Pinchflat.Notifications.CommandRunner do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.CommandRunner do
|
||||
@moduledoc """
|
||||
Runs apprise commands using the `System.cmd/3` function
|
||||
"""
|
||||
@@ -7,7 +7,7 @@ defmodule Pinchflat.Notifications.CommandRunner do
|
||||
|
||||
alias Pinchflat.Utils.CliUtils
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
alias Pinchflat.Notifications.AppriseCommandRunner
|
||||
alias Pinchflat.Lifecycle.Notifications.AppriseCommandRunner
|
||||
|
||||
@behaviour AppriseCommandRunner
|
||||
|
||||
@@ -28,10 +28,9 @@ defmodule Pinchflat.Notifications.CommandRunner do
|
||||
default_opts = [:verbose]
|
||||
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
|
||||
|
||||
{output, return_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
|
||||
Logger.info("[apprise] response: #{output}")
|
||||
{output, exit_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
|
||||
|
||||
case return_code do
|
||||
case exit_code do
|
||||
0 -> {:ok, String.trim(output)}
|
||||
_ -> {:error, String.trim(output)}
|
||||
end
|
||||
+11
-7
@@ -1,12 +1,13 @@
|
||||
defmodule Pinchflat.Notifications.SourceNotifications do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
|
||||
@moduledoc """
|
||||
Contains utilities for sending notifications about sources
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
|
||||
@doc """
|
||||
Wraps a function that may change the number of pending or downloaded
|
||||
@@ -53,20 +54,23 @@ defmodule Pinchflat.Notifications.SourceNotifications do
|
||||
end
|
||||
|
||||
defp relevant_media_item_count(source) do
|
||||
pending_media_item_count(source) + downloaded_media_item_count(source)
|
||||
if source.download_media do
|
||||
pending_media_item_count(source) + downloaded_media_item_count(source)
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp pending_media_item_count(source) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.where_pending_download()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||
|> Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
defp downloaded_media_item_count(source) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||
|> Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
||||
@moduledoc """
|
||||
Runs custom user commands commands using the `System.cmd/3` function
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Utils.CliUtils
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner
|
||||
|
||||
@behaviour UserScriptCommandRunner
|
||||
|
||||
@event_types [
|
||||
:media_indexed,
|
||||
:media_downloaded,
|
||||
:media_deleted
|
||||
]
|
||||
|
||||
@doc """
|
||||
Runs the user script command for the given event type. Passes the event
|
||||
and the encoded data to 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.
|
||||
If something fails, it'll be logged.
|
||||
|
||||
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
|
||||
failures in the development process.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
@impl UserScriptCommandRunner
|
||||
def run(event_type, encodable_data) when event_type in @event_types do
|
||||
case backend_executable() do
|
||||
{:ok, :no_executable} ->
|
||||
:ok
|
||||
|
||||
{:ok, executable_path} ->
|
||||
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
||||
|
||||
{_output, _exit_code} =
|
||||
CliUtils.wrap_cmd(
|
||||
executable_path,
|
||||
[to_string(event_type), encoded_data],
|
||||
[],
|
||||
logging_arg_override: "[suppressed]"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def run(event_type, _encodable_data) do
|
||||
raise ArgumentError, "Invalid event type: #{inspect(event_type)}"
|
||||
end
|
||||
|
||||
defp backend_executable do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
|
||||
if FilesystemUtils.exists_and_nonempty?(filepath) do
|
||||
{:ok, filepath}
|
||||
else
|
||||
Logger.warning("User scripts lifecyle file either not present or is empty. Skipping.")
|
||||
|
||||
{:ok, :no_executable}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
defmodule Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner do
|
||||
@moduledoc """
|
||||
A behaviour for running custom user scripts on certain events.
|
||||
|
||||
Used so we can implement Mox for testing without actually running the
|
||||
user's command.
|
||||
"""
|
||||
|
||||
@callback run(atom(), map()) :: :ok | {:error, binary()}
|
||||
end
|
||||
@@ -4,14 +4,16 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||
|
||||
@doc """
|
||||
Returns the list of media_items.
|
||||
@@ -30,9 +32,8 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
def list_cullable_media_items do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> MediaQuery.where_past_retention_period()
|
||||
|> MediaQuery.where_culling_not_prevented()
|
||||
|> MediaQuery.require_assoc(:source)
|
||||
|> where(^MediaQuery.cullable())
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -52,18 +53,14 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
def list_redownloadable_media_items do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_media_downloaded_at()
|
||||
|> MediaQuery.where_download_not_prevented()
|
||||
|> MediaQuery.where_not_culled()
|
||||
|> MediaQuery.where_media_not_redownloaded()
|
||||
|> MediaQuery.where_past_redownload_delay()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^MediaQuery.redownloadable())
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of pending media_items for a given source, where
|
||||
pending means the `media_filepath` is `nil` AND the media_item
|
||||
matches satisfies `MediaQuery.where_pending_download`. You
|
||||
pending means the media_item satisfies `MediaQuery.pending`. You
|
||||
should really check out that function if you need to know more
|
||||
because it has a lot going on.
|
||||
|
||||
@@ -71,15 +68,14 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
def list_pending_media_items_for(%Source{} = source) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.where_pending_download()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
For a given media_item, tells you if it is pending download. This is defined as
|
||||
the media_item having a `media_filepath` of `nil` and matching the format selection
|
||||
rules of the parent media_profile.
|
||||
the media_item satisfying `MediaQuery.pending` which you should really check out.
|
||||
|
||||
Intentionally does not take the `download_media` setting of the source into account.
|
||||
|
||||
@@ -89,8 +85,8 @@ defmodule Pinchflat.Media do
|
||||
media_item = Repo.preload(media_item, source: :media_profile)
|
||||
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_id(media_item.id)
|
||||
|> MediaQuery.where_pending_download()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic([m, s, mp], m.id == ^media_item.id and ^MediaQuery.pending()))
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@@ -180,6 +176,7 @@ defmodule Pinchflat.Media do
|
||||
|
||||
if delete_files do
|
||||
{:ok, _} = do_delete_media_files(media_item)
|
||||
:ok = run_user_script(:media_deleted, media_item)
|
||||
end
|
||||
|
||||
# Should delete these no matter what
|
||||
@@ -202,6 +199,7 @@ defmodule Pinchflat.Media do
|
||||
|
||||
Tasks.delete_tasks_for(media_item)
|
||||
{:ok, _} = do_delete_media_files(media_item)
|
||||
:ok = run_user_script(:media_deleted, media_item)
|
||||
|
||||
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
||||
end
|
||||
@@ -237,4 +235,10 @@ defmodule Pinchflat.Media do
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
|
||||
end
|
||||
|
||||
defp run_user_script(event, media_item) do
|
||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||
|
||||
runner.run(event, media_item)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,9 +4,14 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
import Ecto.Changeset
|
||||
import Pinchflat.Utils.ChangesetUtils
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
@@ -22,6 +27,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
:source_id,
|
||||
:short_form_content,
|
||||
:upload_date,
|
||||
:upload_date_index,
|
||||
:duration_seconds,
|
||||
# these fields are captured only on download
|
||||
:media_downloaded_at,
|
||||
@@ -64,6 +70,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
field :media_downloaded_at, :utc_datetime
|
||||
field :media_redownloaded_at, :utc_datetime
|
||||
field :upload_date, :date
|
||||
field :upload_date_index, :integer, default: 0
|
||||
field :duration_seconds, :integer
|
||||
|
||||
field :media_filepath, :string
|
||||
@@ -98,6 +105,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
|> cast(attrs, @allowed_fields)
|
||||
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|
||||
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
||||
|> update_upload_date_index()
|
||||
|> validate_required(@required_fields)
|
||||
|> unique_constraint([:media_id, :source_id])
|
||||
end
|
||||
@@ -116,4 +124,41 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
||||
end
|
||||
|
||||
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :upload_date) do
|
||||
source_id = get_field(changeset, :source_id)
|
||||
source = Sources.get_source!(source_id)
|
||||
# Channels should count down from 99, playlists should count up from 0
|
||||
# This reflects the fact that channels prepend new videos to the top of the list
|
||||
# and playlists append new videos to the bottom of the list.
|
||||
default_index = if source.collection_type == :channel, do: 99, else: 0
|
||||
aggregator = if source.collection_type == :channel, do: :min, else: :max
|
||||
change_direction = if source.collection_type == :channel, do: -1, else: 1
|
||||
|
||||
current_max =
|
||||
MediaQuery.new()
|
||||
|> where(^dynamic([mi], mi.upload_date == ^changes.upload_date and ^MediaQuery.for_source(source)))
|
||||
|> Repo.aggregate(aggregator, :upload_date_index)
|
||||
|
||||
case current_max do
|
||||
nil -> put_change(changeset, :upload_date_index, default_index)
|
||||
max -> put_change(changeset, :upload_date_index, max + change_direction)
|
||||
end
|
||||
end
|
||||
|
||||
defp update_upload_date_index(changeset), do: changeset
|
||||
|
||||
defimpl Jason.Encoder, for: MediaItem do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Repo.preload(:source)
|
||||
|> Map.drop(MediaItem.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+146
-123
@@ -12,128 +12,39 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
|
||||
alias Pinchflat.Media.MediaItem
|
||||
|
||||
# Prefixes:
|
||||
# - for_* - belonging to a certain record
|
||||
# - join_* - for joining on a certain record
|
||||
# - with_*, where_* - for filtering based on full, concrete attributes
|
||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
||||
#
|
||||
# Suffixes:
|
||||
# - _for - the arg passed is an association record
|
||||
# This allows the module to be aliased and query methods to be used
|
||||
# all in one go
|
||||
# usage: use Pinchflat.Media.MediaQuery
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias unquote(__MODULE__)
|
||||
end
|
||||
end
|
||||
|
||||
def new do
|
||||
MediaItem
|
||||
end
|
||||
|
||||
def for_source(query, source) do
|
||||
where(query, [mi], mi.source_id == ^source.id)
|
||||
def for_source(source_id) when is_integer(source_id), do: dynamic([mi], mi.source_id == ^source_id)
|
||||
def for_source(source), do: dynamic([mi], mi.source_id == ^source.id)
|
||||
|
||||
def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath))
|
||||
def download_prevented, do: dynamic([mi], mi.prevent_download == 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 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
|
||||
dynamic([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
|
||||
end
|
||||
|
||||
def join_sources(query) do
|
||||
from(mi in query, join: s in assoc(mi, :source), as: :sources)
|
||||
end
|
||||
|
||||
def where_past_retention_period(query) do
|
||||
query
|
||||
|> require_assoc(:source)
|
||||
|> where(
|
||||
[mi, source],
|
||||
fragment("""
|
||||
IFNULL(retention_period_days, 0) > 0 AND
|
||||
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
||||
""")
|
||||
)
|
||||
end
|
||||
|
||||
def where_past_redownload_delay(query) do
|
||||
query
|
||||
|> require_assoc(:source)
|
||||
|> require_assoc(:media_profile)
|
||||
|> where(
|
||||
[_mi, _source, _media_profile],
|
||||
# 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
|
||||
fragment("""
|
||||
IFNULL(redownload_delay_days, 0) > 0 AND
|
||||
DATETIME('now', '-' || redownload_delay_days || ' day') > upload_date AND
|
||||
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < upload_date
|
||||
""")
|
||||
)
|
||||
end
|
||||
|
||||
def where_culling_not_prevented(query) do
|
||||
where(query, [mi], mi.prevent_culling == false)
|
||||
end
|
||||
|
||||
def where_not_culled(query) do
|
||||
where(query, [mi], is_nil(mi.culled_at))
|
||||
end
|
||||
|
||||
def where_media_not_redownloaded(query) do
|
||||
where(query, [mi], is_nil(mi.media_redownloaded_at))
|
||||
end
|
||||
|
||||
def with_id(query, id) do
|
||||
where(query, [mi], mi.id == ^id)
|
||||
end
|
||||
|
||||
def with_media_ids(query, media_ids) do
|
||||
where(query, [mi], mi.media_id in ^media_ids)
|
||||
end
|
||||
|
||||
def with_media_downloaded_at(query) do
|
||||
where(query, [mi], not is_nil(mi.media_downloaded_at))
|
||||
end
|
||||
|
||||
def with_media_filepath(query) do
|
||||
where(query, [mi], not is_nil(mi.media_filepath))
|
||||
end
|
||||
|
||||
def with_no_media_filepath(query) do
|
||||
where(query, [mi], is_nil(mi.media_filepath))
|
||||
end
|
||||
|
||||
def with_upload_date_after_source_cutoff(query) do
|
||||
query
|
||||
|> require_assoc(:source)
|
||||
|> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
|
||||
end
|
||||
|
||||
def where_download_not_prevented(query) do
|
||||
where(query, [mi], mi.prevent_download == false)
|
||||
end
|
||||
|
||||
def matching_source_title_regex(query) do
|
||||
query
|
||||
|> require_assoc(:source)
|
||||
|> where(
|
||||
[mi, source],
|
||||
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex)
|
||||
)
|
||||
end
|
||||
|
||||
def matching_search_term(query, nil), do: query
|
||||
|
||||
def matching_search_term(query, term) do
|
||||
from(mi in query,
|
||||
join: mi_search_index in assoc(mi, :media_items_search_index),
|
||||
where: fragment("media_items_search_index MATCH ?", ^term),
|
||||
select_merge: %{
|
||||
matching_search_term:
|
||||
fragment("""
|
||||
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
|
||||
' ' ||
|
||||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
|
||||
""")
|
||||
},
|
||||
order_by: [desc: fragment("rank")]
|
||||
)
|
||||
end
|
||||
|
||||
def with_format_matching_profile_preference(query) do
|
||||
query
|
||||
|> require_assoc(:media_profile)
|
||||
|> where(
|
||||
def format_matching_profile_preference do
|
||||
dynamic(
|
||||
[mi, source, media_profile],
|
||||
fragment("""
|
||||
CASE
|
||||
WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
|
||||
@@ -155,16 +66,80 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
)
|
||||
end
|
||||
|
||||
def where_pending_download(query) do
|
||||
query
|
||||
|> where_download_not_prevented()
|
||||
|> with_no_media_filepath()
|
||||
|> with_upload_date_after_source_cutoff()
|
||||
|> with_format_matching_profile_preference()
|
||||
|> matching_source_title_regex()
|
||||
def matches_source_title_regex do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex)
|
||||
)
|
||||
end
|
||||
|
||||
defp require_assoc(query, identifier) do
|
||||
def past_retention_period do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
fragment("""
|
||||
IFNULL(retention_period_days, 0) > 0 AND
|
||||
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
||||
""")
|
||||
)
|
||||
end
|
||||
|
||||
def past_redownload_delay do
|
||||
dynamic(
|
||||
[mi, source, media_profile],
|
||||
# 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
|
||||
fragment("""
|
||||
IFNULL(redownload_delay_days, 0) > 0 AND
|
||||
DATETIME('now', '-' || redownload_delay_days || ' day') > upload_date AND
|
||||
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < upload_date
|
||||
""")
|
||||
)
|
||||
end
|
||||
|
||||
def cullable do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
^downloaded() and
|
||||
^past_retention_period() and
|
||||
not (^culling_prevented())
|
||||
)
|
||||
end
|
||||
|
||||
def pending do
|
||||
dynamic(
|
||||
[mi],
|
||||
not (^downloaded()) and
|
||||
not (^download_prevented()) and
|
||||
^upload_date_after_source_cutoff() and
|
||||
^format_matching_profile_preference() and
|
||||
^matches_source_title_regex()
|
||||
)
|
||||
end
|
||||
|
||||
def redownloadable do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
^downloaded() and
|
||||
not (^download_prevented()) and
|
||||
not (^culled()) and
|
||||
not (^redownloaded()) and
|
||||
^past_redownload_delay()
|
||||
)
|
||||
end
|
||||
|
||||
def matches_search_term(nil), do: dynamic([mi], true)
|
||||
|
||||
def matches_search_term(term) do
|
||||
escaped_term = clean_search_term(term)
|
||||
|
||||
# Matching on `term` instead of `escaped_term` because the latter can mangle empty strings
|
||||
case String.trim(term) do
|
||||
"" -> dynamic([mi], true)
|
||||
_ -> dynamic([mi], fragment("media_items_search_index MATCH ?", ^escaped_term))
|
||||
end
|
||||
end
|
||||
|
||||
def require_assoc(query, identifier) do
|
||||
if has_named_binding?(query, identifier) do
|
||||
query
|
||||
else
|
||||
@@ -172,6 +147,10 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
end
|
||||
end
|
||||
|
||||
defp do_require_assoc(query, :media_items_search_index) do
|
||||
from(mi in query, join: s in assoc(mi, :media_items_search_index), as: :media_items_search_index)
|
||||
end
|
||||
|
||||
defp do_require_assoc(query, :source) do
|
||||
from(mi in query, join: s in assoc(mi, :source), as: :source)
|
||||
end
|
||||
@@ -181,4 +160,48 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
|> require_assoc(:source)
|
||||
|> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
|
||||
end
|
||||
|
||||
# This needs to be a non-dynamic query because it alone should control things like
|
||||
# ordering and `snippets` for full-text search
|
||||
def matching_search_term(query, nil), do: query
|
||||
|
||||
def matching_search_term(query, term) do
|
||||
escaped_term = clean_search_term(term)
|
||||
|
||||
from(mi in query,
|
||||
join: mi_search_index in assoc(mi, :media_items_search_index),
|
||||
where: fragment("media_items_search_index MATCH ?", ^escaped_term),
|
||||
select_merge: %{
|
||||
matching_search_term:
|
||||
fragment("""
|
||||
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
|
||||
' ' ||
|
||||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
|
||||
""")
|
||||
},
|
||||
order_by: [desc: fragment("rank")]
|
||||
)
|
||||
end
|
||||
|
||||
# SQLite's FTS5 is very picky about what it will accept as a search term.
|
||||
# To that end, we need to clean up the search term before passing it to the
|
||||
# MATCH clause.
|
||||
# This method:
|
||||
# - Trims leading and trailing whitespace
|
||||
# - Collapses multiple spaces into a single space
|
||||
# - Removes quote characters
|
||||
# - Wraps any word in quotes (must happen after the double quote replacement)
|
||||
#
|
||||
# This allows for works with apostrophes and quotes to be searched for correctly
|
||||
defp clean_search_term(nil), do: ""
|
||||
defp clean_search_term(""), do: ""
|
||||
|
||||
defp clean_search_term(term) do
|
||||
term
|
||||
|> String.trim()
|
||||
|> String.replace(~r/\s+/, " ")
|
||||
|> String.split(~r/\s+/)
|
||||
|> Enum.map(fn str -> String.replace(str, ~s("), "") end)
|
||||
|> Enum.map_join(" ", fn str -> ~s("#{str}") end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,24 +54,36 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
|
||||
@doc """
|
||||
Downloads and stores a thumbnail for a media item, returning the filepath.
|
||||
Chooses the highest quality jpg thumbnail available.
|
||||
Chooses the highest quality thumbnail available (preferring jpg). Returns
|
||||
nil if no thumbnails are available.
|
||||
|
||||
Returns binary()
|
||||
Returns binary() | nil
|
||||
"""
|
||||
def download_and_store_thumbnail_for(database_record, metadata_map) do
|
||||
thumbnail_url =
|
||||
metadata_map["thumbnails"]
|
||||
|> Enum.filter(&(&1["preference"] && String.ends_with?(&1["url"], ".jpg")))
|
||||
|> Enum.sort(&(&1["preference"] >= &2["preference"]))
|
||||
|> List.first()
|
||||
|> Map.get("url")
|
||||
thumbnails =
|
||||
(metadata_map["thumbnails"] || [])
|
||||
# Give it a low preference if the `preference` key doesn't exist
|
||||
|> Enum.map(&Map.put_new(&1, "preference", -1000))
|
||||
# Give it a low preference if image isn't a jpg
|
||||
|> Enum.map(fn t ->
|
||||
preference_weight = if String.ends_with?(t["url"], ".jpg"), do: t["preference"], else: t["preference"] - 1000
|
||||
|
||||
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
|
||||
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
|
||||
Map.put(t, "preference", preference_weight)
|
||||
end)
|
||||
|
||||
:ok = FilesystemUtils.write_p!(filepath, thumbnail_blob)
|
||||
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)
|
||||
|
||||
filepath
|
||||
:ok = FilesystemUtils.write_p!(filepath, thumbnail_blob)
|
||||
|
||||
filepath
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
||||
@@ -30,7 +30,7 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
||||
original_url: metadata["original_url"],
|
||||
description: metadata["description"],
|
||||
media_filepath: metadata["filepath"],
|
||||
livestream: metadata["was_live"],
|
||||
livestream: !!metadata["was_live"],
|
||||
duration_seconds: metadata["duration"] && round(metadata["duration"])
|
||||
}
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :remote_metadata,
|
||||
tags: ["media_source", "source_metadata", "remote_metadata"],
|
||||
tags: ["media_source", "source_metadata", "remote_metadata", "show_in_dashboard"],
|
||||
max_attempts: 3
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -4,8 +4,9 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
||||
or its media items
|
||||
"""
|
||||
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
alias Pinchflat.Metadata.SourceMetadata
|
||||
|
||||
@@ -23,11 +24,11 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
||||
Returns: [%MediaItem{}]
|
||||
"""
|
||||
def persisted_media_items_for(source, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 500)
|
||||
limit = Keyword.get(opts, :limit, 1_000)
|
||||
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||
|> order_by(desc: :upload_date)
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
||||
|
||||
@@ -16,12 +16,12 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
Only MediaItems that have been persisted will be included in the feed.
|
||||
|
||||
## Options:
|
||||
- `:limit` - The maximum number of media items to include in the feed. Defaults to 300.
|
||||
- `:limit` - The maximum number of media items to include in the feed. Defaults to 2,000.
|
||||
|
||||
Returns an XML document as a string.
|
||||
"""
|
||||
def build(source, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 300)
|
||||
limit = Keyword.get(opts, :limit, 2_000)
|
||||
url_base = Keyword.get(opts, :url_base, PinchflatWeb.Endpoint.url())
|
||||
|
||||
media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit)
|
||||
@@ -75,6 +75,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
end
|
||||
|
||||
defp build_media_item_xml(source, media_item, url_base) do
|
||||
item_image_path = item_image_path(url_base, media_item)
|
||||
|
||||
"""
|
||||
<item>
|
||||
<guid isPermaLink="false">#{media_item.uuid}</guid>
|
||||
@@ -91,6 +93,10 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
||||
<itunes:subtitle>#{safe(media_item.title)}</itunes:subtitle>
|
||||
<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>
|
||||
|
||||
#{item_image_path && ~s(<itunes:image href="#{safe(item_image_path)}"></itunes:image>)}
|
||||
#{item_image_path && ~s(<podcast:images srcset="#{safe(item_image_path)}" />)}
|
||||
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
"""
|
||||
@@ -117,6 +123,16 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
def item_image_path(url_base, media_item) do
|
||||
if media_item.thumbnail_filepath && File.exists?(media_item.thumbnail_filepath) do
|
||||
extension = Path.extname(media_item.thumbnail_filepath)
|
||||
|
||||
Path.join(url_base, "#{podcast_route(:episode_image, media_item.uuid)}#{extension}")
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_upload_date(media_item) do
|
||||
media_item.upload_date
|
||||
|> DatetimeUtils.date_to_datetime()
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Sources.Source
|
||||
|
||||
@allowed_fields ~w(
|
||||
@@ -62,7 +63,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
# See `build_format_clauses` in the Media context for more.
|
||||
field :shorts_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(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"
|
||||
|
||||
has_many :sources, Source
|
||||
|
||||
@@ -84,4 +85,17 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
def ext_regex do
|
||||
~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ sources)a
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: MediaProfile do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Map.drop(MediaProfile.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,14 +26,17 @@ defmodule Pinchflat.Release do
|
||||
load_app()
|
||||
|
||||
directories =
|
||||
Enum.uniq([
|
||||
[
|
||||
"/config",
|
||||
"/downloads",
|
||||
Application.get_env(:pinchflat, :media_directory),
|
||||
Application.get_env(:pinchflat, :tmpfile_directory),
|
||||
Application.get_env(:pinchflat, :extras_directory),
|
||||
Application.get_env(:pinchflat, :metadata_directory)
|
||||
])
|
||||
Application.get_env(:pinchflat, :metadata_directory),
|
||||
Application.get_env(:tzdata, :data_dir)
|
||||
]
|
||||
|> Enum.uniq()
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
|
||||
Enum.each(directories, fn dir ->
|
||||
Logger.info("Checking permissions for #{dir}")
|
||||
|
||||
@@ -11,7 +11,14 @@ defmodule Pinchflat.Settings.Setting do
|
||||
:pro_enabled,
|
||||
:yt_dlp_version,
|
||||
:apprise_version,
|
||||
:apprise_server
|
||||
:apprise_server,
|
||||
:video_codec_preference,
|
||||
:audio_codec_preference
|
||||
]
|
||||
|
||||
@virtual_fields [
|
||||
:video_codec_preference_string,
|
||||
:audio_codec_preference_string
|
||||
]
|
||||
|
||||
@required_fields ~w(
|
||||
@@ -25,12 +32,43 @@ defmodule Pinchflat.Settings.Setting do
|
||||
field :yt_dlp_version, :string
|
||||
field :apprise_version, :string
|
||||
field :apprise_server, :string
|
||||
|
||||
field :video_codec_preference, {:array, :string}, default: []
|
||||
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
|
||||
|
||||
@doc false
|
||||
def changeset(setting, attrs) do
|
||||
setting
|
||||
|> cast(attrs, @allowed_fields)
|
||||
|> cast(attrs, @virtual_fields, empty_values: [])
|
||||
|> convert_codec_preference_strings()
|
||||
|> validate_required(@required_fields)
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||
use Oban.Worker,
|
||||
queue: :media_collection_indexing,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||
tags: ["media_source", "media_collection_indexing"]
|
||||
tags: ["media_source", "media_collection_indexing", "show_in_dashboard"]
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -15,7 +15,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||
alias Pinchflat.Notifications.SourceNotifications
|
||||
alias Pinchflat.Lifecycle.Notifications.SourceNotifications
|
||||
|
||||
@doc """
|
||||
Starts the source slow indexing worker and creates a task for the source.
|
||||
|
||||
@@ -8,7 +8,6 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources
|
||||
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!
|
||||
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.
|
||||
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
|
||||
@@ -64,15 +66,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||
source = Repo.reload!(source)
|
||||
|
||||
result =
|
||||
Enum.map(media_attributes, fn media_attrs ->
|
||||
case Media.create_media_item_from_backend_attrs(source, media_attrs) do
|
||||
Enum.map(media_attributes, fn media_attrs_struct ->
|
||||
case DownloadingHelpers.create_media_item_and_run_script(source, media_attrs_struct) do
|
||||
{:ok, media_item} -> media_item
|
||||
{:error, changeset} -> changeset
|
||||
end
|
||||
end)
|
||||
|
||||
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
|
||||
end
|
||||
@@ -118,14 +121,15 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||
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
|
||||
# and important settings like `download_media` may have changed.
|
||||
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} ->
|
||||
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} ->
|
||||
changeset
|
||||
|
||||
@@ -7,6 +7,8 @@ defmodule Pinchflat.Sources.Source do
|
||||
import Ecto.Changeset
|
||||
import Pinchflat.Utils.ChangesetUtils
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
@@ -133,4 +135,18 @@ defmodule Pinchflat.Sources.Source do
|
||||
def filepath_attributes do
|
||||
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ metadata tasks media_items)a
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Source do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Repo.preload(:media_profile)
|
||||
|> Map.drop(Source.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,12 +4,12 @@ defmodule Pinchflat.Sources do
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Pinchflat.Repo
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
alias Pinchflat.YtDlp.MediaCollection
|
||||
alias Pinchflat.Metadata.SourceMetadata
|
||||
@@ -127,7 +127,7 @@ defmodule Pinchflat.Sources do
|
||||
Tasks.delete_tasks_for(source)
|
||||
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> where(^MediaQuery.for_source(source))
|
||||
|> Repo.all()
|
||||
|> Enum.each(fn media_item ->
|
||||
Media.delete_media_item(media_item, delete_files: delete_files)
|
||||
|
||||
@@ -12,20 +12,26 @@ defmodule Pinchflat.Sources.SourcesQuery do
|
||||
|
||||
alias Pinchflat.Sources.Source
|
||||
|
||||
# Prefixes:
|
||||
# - for_* - belonging to a certain record
|
||||
# - join_* - for joining on a certain record
|
||||
# - with_* - for filtering based on full, concrete attributes
|
||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
||||
#
|
||||
# Suffixes:
|
||||
# - _for - the arg passed is an association record
|
||||
# This allows the module to be aliased and query methods to be used
|
||||
# all in one go
|
||||
# usage: use Pinchflat.Sources.SourcesQuery
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias unquote(__MODULE__)
|
||||
end
|
||||
end
|
||||
|
||||
def new do
|
||||
Source
|
||||
end
|
||||
|
||||
def for_media_profile(query, media_profile) do
|
||||
where(query, [s], s.media_profile_id == ^media_profile.id)
|
||||
def for_media_profile(media_profile_id) when is_integer(media_profile_id) do
|
||||
dynamic([s], s.media_profile_id == ^media_profile_id)
|
||||
end
|
||||
|
||||
def for_media_profile(media_profile) do
|
||||
dynamic([s], s.media_profile_id == ^media_profile.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
defmodule Pinchflat.Tasks.TasksQuery do
|
||||
@moduledoc """
|
||||
Query helpers for the Tasks context.
|
||||
|
||||
These methods are made to be one-ish liners used
|
||||
to compose queries. Each method should strive to do
|
||||
_one_ thing. These don't need to be tested as
|
||||
they are just building blocks for other functionality
|
||||
which, itself, will be tested.
|
||||
"""
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Pinchflat.Tasks.Task
|
||||
|
||||
# This allows the module to be aliased and query methods to be used
|
||||
# all in one go
|
||||
# usage: use Pinchflat.Tasks.TasksQuery
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias unquote(__MODULE__)
|
||||
end
|
||||
end
|
||||
|
||||
def new do
|
||||
Task
|
||||
end
|
||||
|
||||
def join_job(query) do
|
||||
query
|
||||
|> join(:left, [t], j in assoc(t, :job))
|
||||
|> preload([t, j], job: j)
|
||||
end
|
||||
|
||||
def in_state(states) when is_list(states) do
|
||||
dynamic([t, j], j.state in ^states)
|
||||
end
|
||||
|
||||
def in_state(state), do: in_state([state])
|
||||
|
||||
def has_tag(tag) do
|
||||
dynamic([t, j], ^tag in j.tags)
|
||||
end
|
||||
end
|
||||
@@ -13,17 +13,26 @@ defmodule Pinchflat.Utils.CliUtils do
|
||||
commands if the job runner is cancelled.
|
||||
|
||||
Delegates to `System.cmd/3` and any options/output
|
||||
are passed through.
|
||||
are passed through. Custom options can be passed in.
|
||||
|
||||
Custom options:
|
||||
- logging_arg_override: if set, the passed value will be logged in place of
|
||||
the actual arguments passed to the command
|
||||
|
||||
Returns {binary(), integer()}
|
||||
"""
|
||||
def wrap_cmd(command, args, opts \\ []) do
|
||||
def wrap_cmd(command, args, passthrough_opts \\ [], opts \\ []) do
|
||||
wrapper_command = Path.join(:code.priv_dir(:pinchflat), "cmd_wrapper.sh")
|
||||
actual_command = [command] ++ args
|
||||
command_opts = set_command_opts() ++ passthrough_opts
|
||||
logging_arg_override = Keyword.get(opts, :logging_arg_override, Enum.join(args, " "))
|
||||
|
||||
Logger.info("[command_wrapper]: #{command} called with: #{Enum.join(args, " ")}")
|
||||
Logger.info("[command_wrapper]: #{command} called with: #{logging_arg_override}")
|
||||
|
||||
System.cmd(wrapper_command, actual_command, opts)
|
||||
{output, status} = System.cmd(wrapper_command, actual_command, command_opts)
|
||||
log_cmd_result(command, logging_arg_override, status, output)
|
||||
|
||||
{output, status}
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -66,4 +75,20 @@ defmodule Pinchflat.Utils.CliUtils do
|
||||
defp parse_option(arg, acc) when is_binary(arg) do
|
||||
acc ++ [arg]
|
||||
end
|
||||
|
||||
defp log_cmd_result(command, logging_arg_override, status, output) do
|
||||
log_message = "[command_wrapper]: #{command} called with: #{logging_arg_override} exited: #{status} with: #{output}"
|
||||
log_level = if status == 0, do: :debug, else: :error
|
||||
|
||||
Logger.log(log_level, log_message)
|
||||
end
|
||||
|
||||
defp set_command_opts do
|
||||
# This resolves an issue where yt-dlp would attempt to write to a read-only directory
|
||||
# if you scanned a new video with `--windows-filenames` enabled. Hopefully can be removed
|
||||
# in the future.
|
||||
[
|
||||
cd: Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,21 @@ defmodule Pinchflat.Utils.FilesystemUtils do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Utils.StringUtils
|
||||
|
||||
@doc """
|
||||
Checks if a file exists and has non-whitespace contents.
|
||||
|
||||
Returns boolean()
|
||||
"""
|
||||
def exists_and_nonempty?(filepath) do
|
||||
case File.read(filepath) do
|
||||
{:ok, contents} ->
|
||||
String.trim(contents) != ""
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a temporary file and returns its path. The file is empty and has the given type.
|
||||
Generates all the directories in the path if they don't exist.
|
||||
|
||||
@@ -13,4 +13,27 @@ defmodule Pinchflat.Utils.NumberUtils do
|
||||
|> max(minimum)
|
||||
|> min(maximum)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a number to a human readable byte size. Can take a precision
|
||||
option to specify the number of decimal places to round to.
|
||||
|
||||
Returns {integer(), String.t()}
|
||||
"""
|
||||
def human_byte_size(number, opts \\ [])
|
||||
def human_byte_size(nil, opts), do: human_byte_size(0, opts)
|
||||
|
||||
def human_byte_size(number, opts) do
|
||||
precision = Keyword.get(opts, :precision, 2)
|
||||
suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||
base = 1024
|
||||
|
||||
Enum.reduce_while(suffixes, {number / 1.0, "B"}, fn suffix, {value, _} ->
|
||||
if value < base do
|
||||
{:halt, {Float.round(value, precision), suffix}}
|
||||
else
|
||||
{:cont, {value / base, suffix}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,9 +28,9 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
||||
|
||||
output_filepath = generate_output_filepath(addl_opts)
|
||||
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
|
||||
user_configured_opts = cookie_file_options() ++ global_options()
|
||||
user_configured_opts = cookie_file_options()
|
||||
# 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
|
||||
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
|
||||
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
||||
|
||||
case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do
|
||||
@@ -71,7 +71,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
||||
end
|
||||
|
||||
defp global_options do
|
||||
[:windows_filenames]
|
||||
[
|
||||
:windows_filenames,
|
||||
:quiet,
|
||||
cache_dir: Path.join(Application.get_env(:pinchflat, :tmpfile_directory), "yt-dlp-cache")
|
||||
]
|
||||
end
|
||||
|
||||
defp cookie_file_options do
|
||||
@@ -81,16 +85,10 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
||||
Enum.reduce(filename_options_map, [], fn {opt_name, filename}, acc ->
|
||||
filepath = Path.join(base_dir, filename)
|
||||
|
||||
case File.read(filepath) do
|
||||
{:ok, file_data} ->
|
||||
if String.trim(file_data) != "" do
|
||||
[{opt_name, filepath} | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
acc
|
||||
if FSUtils.exists_and_nonempty?(filepath) do
|
||||
[{opt_name, filepath} | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -87,7 +87,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
title: response["title"],
|
||||
description: response["description"],
|
||||
original_url: response["webpage_url"],
|
||||
livestream: response["was_live"],
|
||||
livestream: !!response["was_live"],
|
||||
duration_seconds: response["duration"] && round(response["duration"]),
|
||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
||||
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||
|
||||
@@ -24,7 +24,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
||||
# `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
|
||||
# available formats since we're just getting the media details
|
||||
command_opts = [:simulate, :skip_download, :ignore_no_formats_error]
|
||||
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings]
|
||||
output_template = YtDlpMedia.indexing_output_template()
|
||||
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
||||
|
||||
@@ -266,6 +266,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
slot :inner_block
|
||||
slot :input_append
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
assigns
|
||||
@@ -424,20 +425,23 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
<.label for={@id}>
|
||||
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
||||
</.label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent px-5 py-3 font-normal text-black",
|
||||
"outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter",
|
||||
"dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary",
|
||||
@inputclass,
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"w-full rounded-lg border-[1.5px] px-5 py-3 font-normal border-form-strokedark bg-form-input",
|
||||
"outline-none transition active:border-primary disabled:cursor-default disabled:bg-whiter",
|
||||
"text-white focus:border-primary",
|
||||
@inputclass,
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<%= render_slot(@input_append) %>
|
||||
</div>
|
||||
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
|
||||
@@ -89,4 +89,46 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render a button with an icon. Optionally include a tooltip.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon_button icon_name="hero-check" tooltip="Complete" />
|
||||
"""
|
||||
attr :icon_name, :string, required: true
|
||||
attr :class, :string, default: ""
|
||||
attr :tooltip, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def icon_button(assigns) do
|
||||
~H"""
|
||||
<div class="group relative inline-block">
|
||||
<button
|
||||
class={[
|
||||
"flex justify-center items-center rounded-lg ",
|
||||
"bg-form-input border-2 border-strokedark",
|
||||
"hover:bg-meta-4 hover:border-form-strokedark",
|
||||
@class
|
||||
]}
|
||||
type="button"
|
||||
{@rest}
|
||||
>
|
||||
<CoreComponents.icon name={@icon_name} class="text-stroke" />
|
||||
</button>
|
||||
<div
|
||||
:if={@tooltip}
|
||||
class={[
|
||||
"hidden absolute left-1/2 top-full z-20 mt-3 -translate-x-1/2 whitespace-nowrap rounded-md",
|
||||
"px-4.5 py-1.5 text-sm font-medium opacity-0 drop-shadow-4 group-hover:opacity-100 group-hover:block bg-meta-4"
|
||||
]}
|
||||
>
|
||||
<span class="border-light absolute -top-1 left-1/2 -z-10 h-2 w-2 -translate-x-1/2 rotate-45 rounded-sm bg-meta-4">
|
||||
</span>
|
||||
<span><%= @tooltip %></span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,25 +6,32 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
||||
Takes a list of tabs and renders them in a tabbed layout.
|
||||
"""
|
||||
slot :tab, required: true do
|
||||
attr :id, :string, required: true
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
slot :tab_append, required: false
|
||||
|
||||
def tabbed_layout(assigns) do
|
||||
assigns = Map.put(assigns, :first_tab_id, hd(assigns.tab).id)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
x-data="{ openTab: getTabIndex(0), activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
||||
@hashchange.window="openTab = getTabIndex(openTab)"
|
||||
x-data={"{
|
||||
openTab: getTabFromHash('#{@first_tab_id}', '#{@first_tab_id}'),
|
||||
activeClasses: 'text-meta-5 border-meta-5',
|
||||
inactiveClasses: 'border-transparent'
|
||||
}"}
|
||||
@hashchange.window={"openTab = getTabFromHash(openTab, '#{@first_tab_id}')"}
|
||||
class="w-full"
|
||||
>
|
||||
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
||||
<div class="flex flex-wrap gap-5 sm:gap-10">
|
||||
<a
|
||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
||||
:for={tab <- @tab}
|
||||
href="#"
|
||||
@click.prevent={"openTab = setTabIndex(#{idx})"}
|
||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
||||
@click.prevent={"openTab = setTabByName('#{tab.id}')"}
|
||||
x-bind:class={"openTab === '#{tab.id}' ? activeClasses : inactiveClasses"}
|
||||
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
|
||||
>
|
||||
<span class="text-xl"><%= tab.title %></span>
|
||||
@@ -35,7 +42,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
||||
</div>
|
||||
</header>
|
||||
<div class="mt-4 min-h-60">
|
||||
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
||||
<div :for={tab <- @tab} x-show={"openTab === '#{tab.id}'"} class="font-medium leading-relaxed">
|
||||
<%= render_slot(tab) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,21 +27,20 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
||||
|
||||
def table(assigns) do
|
||||
~H"""
|
||||
<table class={["w-full table-auto", @table_class]}>
|
||||
<table class={["w-full table-auto bg-boxdark", @table_class]}>
|
||||
<thead>
|
||||
<tr class="bg-gray-2 text-left dark:bg-meta-4">
|
||||
<th :for={col <- @col} class="px-4 py-4 font-medium text-black dark:text-white xl:pl-11">
|
||||
<tr class="text-left bg-meta-4">
|
||||
<th :for={col <- @col} class="px-4 py-4 font-medium text-white xl:pl-11">
|
||||
<%= col[:label] %>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={{row, i} <- Enum.with_index(@rows)}>
|
||||
<tr :for={row <- @rows} class="hover:bg-strokedark">
|
||||
<td
|
||||
:for={col <- @col}
|
||||
class={[
|
||||
"px-4 py-5 pl-9 dark:border-strokedark xl:pl-11",
|
||||
i + 1 > length(@rows) && "border-b border-[#eee] dark:border-π",
|
||||
"px-4 py-5 pl-9 xl:pl-11",
|
||||
col[:class]
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -78,4 +78,20 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
||||
<span><%= @text %></span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a UTC datetime in the specified format and timezone
|
||||
"""
|
||||
attr :datetime, :any, required: true
|
||||
attr :format, :string, default: "%Y-%m-%d %H:%M:%S"
|
||||
attr :timezone, :string, default: nil
|
||||
|
||||
def datetime_in_zone(assigns) do
|
||||
timezone = assigns.timezone || Application.get_env(:pinchflat, :timezone)
|
||||
assigns = Map.put(assigns, :timezone, timezone)
|
||||
|
||||
~H"""
|
||||
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,28 +4,107 @@ defmodule PinchflatWeb.Layouts do
|
||||
embed_templates "layouts/*"
|
||||
embed_templates "layouts/partials/*"
|
||||
|
||||
@doc """
|
||||
Renders a sidebar menu item link
|
||||
|
||||
## Examples
|
||||
|
||||
<.sidebar_link icon="hero-home" text="Home" href="/" />
|
||||
"""
|
||||
attr :icon, :string, required: true
|
||||
attr :text, :string, required: true
|
||||
attr :href, :any, required: true
|
||||
attr :target, :any, default: "_self"
|
||||
|
||||
def sidebar_item(assigns) do
|
||||
# I'm testing out grouping classes here. Tentative order: font, layout, color, animation, state-modifiers
|
||||
~H"""
|
||||
<li>
|
||||
<.link
|
||||
href={@href}
|
||||
target={@target}
|
||||
class={[
|
||||
"font-medium text-bodydark1",
|
||||
"group relative flex items-center gap-2.5 rounded-sm px-4 py-2 duration-300 ease-in-out",
|
||||
"duration-300 ease-in-out",
|
||||
"hover:bg-graydark dark:hover:bg-meta-4"
|
||||
]}
|
||||
>
|
||||
<.icon name={@icon} /> <%= @text %>
|
||||
</.link>
|
||||
<li class="text-bodydark1">
|
||||
<.sidebar_link icon={@icon} text={@text} href={@href} target={@target} />
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a sidebar menu item with a submenu
|
||||
|
||||
## Examples
|
||||
|
||||
<.sidebar_submenu icon="hero-home" text="Home" current_path="/">
|
||||
<:submenu icon="hero-home" text="Home" href="/" />
|
||||
</.sidebar_submenu>
|
||||
"""
|
||||
|
||||
attr :icon, :string, required: true
|
||||
attr :text, :string, required: true
|
||||
attr :current_path, :string, required: true
|
||||
|
||||
slot :submenu do
|
||||
attr :icon, :string
|
||||
attr :text, :string
|
||||
attr :href, :any
|
||||
attr :target, :any
|
||||
end
|
||||
|
||||
def sidebar_submenu(assigns) do
|
||||
initially_selected = Enum.any?(assigns[:submenu], &(&1[:href] == assigns[:current_path]))
|
||||
assigns = Map.put(assigns, :initially_selected, initially_selected)
|
||||
|
||||
~H"""
|
||||
<li class="text-bodydark1" x-data={"{ selected: #{@initially_selected} }"}>
|
||||
<span
|
||||
class={[
|
||||
"font-medium cursor-pointer",
|
||||
"group relative flex items-center justify-between rounded-sm px-4 py-2 duration-300 ease-in-out",
|
||||
"duration-300 ease-in-out",
|
||||
"hover:bg-meta-4"
|
||||
]}
|
||||
x-on:click="selected = !selected"
|
||||
>
|
||||
<span class="flex items-center gap-2.5">
|
||||
<.icon name={@icon} /> <%= @text %>
|
||||
</span>
|
||||
<span class="text-bodydark2">
|
||||
<.icon name="hero-chevron-up" x-bind:class="{ 'rotate-180': selected }" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ul x-cloak x-show="selected">
|
||||
<li :for={menu <- @submenu} class="text-bodydark2">
|
||||
<.sidebar_link icon={menu[:icon]} text={menu[:text]} href={menu[:href]} target={menu[:target]} class="pl-10" />
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a sidebar menu item link
|
||||
|
||||
## Examples
|
||||
|
||||
<.sidebar_link icon="hero-home" text="Home" href="/" />
|
||||
"""
|
||||
attr :icon, :string
|
||||
attr :text, :string, required: true
|
||||
attr :href, :any, required: true
|
||||
attr :target, :any, default: "_self"
|
||||
attr :class, :string, default: ""
|
||||
|
||||
def sidebar_link(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
href={@href}
|
||||
target={@target}
|
||||
class={[
|
||||
"font-medium",
|
||||
"group relative flex items-center gap-2.5 rounded-sm px-4 py-2 duration-300 ease-in-out",
|
||||
"duration-300 ease-in-out",
|
||||
"hover:bg-meta-4",
|
||||
@class
|
||||
]}
|
||||
>
|
||||
<.icon :if={@icon} name={@icon} /> <%= @text %>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<.sidebar />
|
||||
<.sidebar conn={@conn} />
|
||||
|
||||
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
|
||||
<.header params={@conn.params} />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
|
||||
<div class="w-65 px-4 py-2 shadow-2 md:px-6">
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
|
||||
<img src={~p"/images/logo-2024-03-20.png"} alt="Pinchflat" class="w-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -3,21 +3,22 @@
|
||||
<h3 class="text-2xl text-white">Donate</h3>
|
||||
<p class="text-sm">Thank you for your support :)</p>
|
||||
<p class="mt-4">
|
||||
If you find the project valuable and want to support its development, a
|
||||
<.inline_link href="https://www.paypal.me/kieraneglin">donation</.inline_link>
|
||||
would be greatly appreciated.
|
||||
If you find the project valuable and want to say thanks, a
|
||||
<.inline_link href="https://supporters.eff.org/donate">
|
||||
donation to the Electronic Frontier Foundation
|
||||
</.inline_link>
|
||||
would be greatly appreciated! You can optionally post proof of donation on
|
||||
<.inline_link href="https://github.com/kieraneglin/pinchflat/issues/234">this issue</.inline_link>
|
||||
to hopefully inspire others to donate as well.
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
Plus, $5 USD from any donation over $10 USD will be donated to
|
||||
<.inline_link href="https://www.eff.org/">The Electronic Frontier Foundation</.inline_link>
|
||||
who defend your online liberties and backed
|
||||
The Electronic Frontier Foundation is a nonprofit organization who defend your online liberties and backed
|
||||
<.inline_code>youtube-dl</.inline_code>
|
||||
when Google took them down<.inline_link href="https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf">
|
||||
<.icon name="hero-arrow-top-right-on-square" class="h-3 w-3" />
|
||||
</.inline_link>.
|
||||
</.inline_link>
|
||||
</p>
|
||||
|
||||
<.link href="https://www.paypal.me/kieraneglin" target="_blank">
|
||||
<.link href="https://supporters.eff.org/donate" target="_blank">
|
||||
<.button color="bg-primary" class="w-full mt-8">
|
||||
Donate
|
||||
</.button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<header class="sticky top-0 z-999 flex h-20 w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
|
||||
<header class="sticky top-0 z-999 flex min-h-20 w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
|
||||
<div class="flex flex-grow items-center justify-between lg:justify-end px-4 py-4 shadow-2 md:px-6 2xl:px-11">
|
||||
<div class="flex items-center gap-2 sm:gap-4 lg:hidden w-2/6">
|
||||
<section class="pr-1">
|
||||
<img src={~p"/images/icon.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-10" />
|
||||
<img src={~p"/images/icon-2024-03-20.png"} alt="Pinchflat" class="w-10" />
|
||||
</section>
|
||||
|
||||
<button
|
||||
@@ -22,7 +22,7 @@
|
||||
type="text"
|
||||
name="q"
|
||||
value={@params["q"]}
|
||||
placeholder="Type to search..."
|
||||
placeholder="Search all media..."
|
||||
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<section>
|
||||
<div class="flex items-center justify-between gap-2 px-6 py-4">
|
||||
<a href="/" class="flex items-center">
|
||||
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
|
||||
<img src={~p"/images/logo-2024-03-20.png"} alt="Pinchflat" class="w-auto" />
|
||||
</a>
|
||||
|
||||
<button class="block mt-3 lg:hidden" @click.stop="sidebarVisible = !sidebarVisible">
|
||||
@@ -18,13 +18,22 @@
|
||||
</div>
|
||||
<div class="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
|
||||
<nav class="mt-3 px-4 py-4 lg:px-6">
|
||||
<h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">MENU</h3>
|
||||
<h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">
|
||||
<span>MENU</span>
|
||||
</h3>
|
||||
<div class="flex flex-col justify-between">
|
||||
<ul class="mb-6 flex flex-col gap-1.5">
|
||||
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
|
||||
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
|
||||
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
|
||||
<.sidebar_item icon="hero-cog-6-tooth" text="Settings" href={~p"/settings"} />
|
||||
<.sidebar_submenu
|
||||
icon="hero-cog-6-tooth"
|
||||
text="Config"
|
||||
current_path={Phoenix.Controller.current_path(@conn)}
|
||||
>
|
||||
<:submenu text="Settings" href={~p"/settings"} />
|
||||
<:submenu text="App Info" href={~p"/app_info"} />
|
||||
</.sidebar_submenu>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -54,10 +63,22 @@
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
||||
Pinchflat v<%= Application.spec(:pinchflat)[:vsn] %>
|
||||
<span
|
||||
class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm"
|
||||
x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
|
||||
>
|
||||
<span>Pinchflat <%= Application.spec(:pinchflat)[:vsn] %></span>
|
||||
<a
|
||||
href="https://github.com/kieraneglin/pinchflat/releases"
|
||||
target="_blank"
|
||||
class="bg-meta-2 text-boxdark px-1.5 rounded-full text-xs"
|
||||
x-cloak
|
||||
x-show={"!isVersionSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
|
||||
>
|
||||
NEW
|
||||
</a>
|
||||
</span>
|
||||
<span class="group relative flex items-center gap-2.5 px-4 text-sm">
|
||||
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
||||
yt-dlp <%= Settings.get!(:yt_dlp_version) %>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -30,7 +30,7 @@ defmodule Pinchflat.UpgradeButtonLive do
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|
||||
if normalized_text == "got it!" do
|
||||
if normalized_text == "got it" do
|
||||
Settings.set(pro_enabled: true)
|
||||
|
||||
{:noreply, update(socket, :button_disabled, fn _ -> false end)}
|
||||
|
||||
@@ -3,23 +3,25 @@
|
||||
<h3 class="text-2xl text-white">Pro Mode</h3>
|
||||
<p class="text-sm">Don't worry - Pinchflat is completely free :)</p>
|
||||
<p class="mt-4">
|
||||
If you find the project valuable and want to support its development, a
|
||||
<.inline_link href="https://www.paypal.me/kieraneglin">donation</.inline_link>
|
||||
would be greatly appreciated.
|
||||
If you find the project valuable and want to say thanks, a
|
||||
<.inline_link href="https://supporters.eff.org/donate">
|
||||
donation to the Electronic Frontier Foundation
|
||||
</.inline_link>
|
||||
would be greatly appreciated! You can optionally post proof of donation on
|
||||
<.inline_link href="https://github.com/kieraneglin/pinchflat/issues/234">this issue</.inline_link>
|
||||
to hopefully inspire others to donate as well.
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
Plus, $5 USD from any donation over $10 USD will be donated to
|
||||
<.inline_link href="https://www.eff.org/">The Electronic Frontier Foundation</.inline_link>
|
||||
who defend your online liberties and backed
|
||||
The Electronic Frontier Foundation is a nonprofit organization who defend your online liberties and backed
|
||||
<.inline_code>youtube-dl</.inline_code>
|
||||
when Google took them down<.inline_link href="https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf">
|
||||
<.icon name="hero-arrow-top-right-on-square" class="h-3 w-3" />
|
||||
</.inline_link>. <strong>You do not need to donate to unlock Pro</strong>. It's just a way to say thanks!
|
||||
</.inline_link>. <strong>You do not need to donate to unlock Pro</strong>. It's just a way to show support!
|
||||
</p>
|
||||
|
||||
<p class="mt-4">
|
||||
To unlock Pro, simply type
|
||||
<.inline_code>got it!</.inline_code>
|
||||
<.inline_code>got it</.inline_code>
|
||||
into the text box and press the button.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||
|> put_resp_header("accept-ranges", "bytes")
|
||||
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|
||||
|> put_resp_header("content-length", to_string(length))
|
||||
|> put_resp_header("content-disposition", "inline; filename=\"#{media_item.title}\"")
|
||||
|> send_file(206, media_item.media_filepath, start_pos, length)
|
||||
|
||||
{:error, :invalid_range} ->
|
||||
@@ -92,8 +93,10 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||
|
||||
conn
|
||||
|> put_resp_content_type(mime_type)
|
||||
|> put_resp_header("content-length", to_string(file_size))
|
||||
|> put_resp_header("accept-ranges", "bytes")
|
||||
|> put_resp_header("content-range", "bytes 0-#{file_size - 1}/#{file_size}")
|
||||
|> put_resp_header("content-length", to_string(file_size))
|
||||
|> put_resp_header("content-disposition", "inline; filename=\"#{media_item.title}\"")
|
||||
|> send_file(200, media_item.media_filepath)
|
||||
end
|
||||
else
|
||||
|
||||
@@ -18,7 +18,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
|
||||
def media_type(media_item) do
|
||||
case Path.extname(media_item.media_filepath) do
|
||||
ext when ext in [".mp4", ".webm", ".mkv"] -> :video
|
||||
ext when ext in [".mp3", ".m4a"] -> :audio
|
||||
ext when ext in [".mp3", ".m4a", ".opus"] -> :audio
|
||||
_ -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||
<:option>
|
||||
<span x-data="{ copied: false }" x-on:click={~s"
|
||||
copyWithCallbacks(
|
||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`,
|
||||
() => copied = true,
|
||||
() => copied = false
|
||||
)
|
||||
"}>
|
||||
Copy JSON
|
||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||
</span>
|
||||
</:option>
|
||||
<:option>
|
||||
<div class="h-px w-full bg-bodydark2"></div>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}/force_download"}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.media_item_form changeset={@changeset} action={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"} />
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
|
||||
<h3 class=" text-2xl text-black dark:text-white">
|
||||
<h3 class="text-2xl text-black dark:text-white">
|
||||
General Options
|
||||
</h3>
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
</nav>
|
||||
</div>
|
||||
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<.tabbed_layout>
|
||||
<:tab_append>
|
||||
<.actions_dropdown media_item={@media_item} />
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Media">
|
||||
<:tab title="Media" id="media">
|
||||
<div class="flex flex-col gap-10 dark:text-white">
|
||||
<%= if media_file_exists?(@media_item) do %>
|
||||
<section class="grid grid-cols-1 xl:grid-cols-2 xl:gap-6 mt-6">
|
||||
@@ -33,7 +33,15 @@
|
||||
<aside class="mt-4 xl:mt-0">
|
||||
<div>Uploaded: <%= @media_item.upload_date %></div>
|
||||
<div>
|
||||
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
|
||||
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
||||
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
|
||||
<span class="mx-2">or</span>
|
||||
</span>
|
||||
<span>
|
||||
<.subtle_link href={~p"/media/#{@media_item.uuid}/stream"} target="_blank">
|
||||
Open Local Stream
|
||||
</.subtle_link>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 text-bodydark">
|
||||
<.break_on_newline text={@media_item.description} />
|
||||
@@ -52,7 +60,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Tasks">
|
||||
<:tab title="Tasks" id="tasks">
|
||||
<%= if match?([_|_], @media_item.tasks) do %>
|
||||
<.table rows={@media_item.tasks} table_class="text-black dark:text-white">
|
||||
<:col :let={task} label="Worker">
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||
use PinchflatWeb, :controller
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
use Pinchflat.Sources.SourcesQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Profiles
|
||||
alias Pinchflat.Sources.SourcesQuery
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
|
||||
def index(conn, _params) do
|
||||
@@ -43,7 +41,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||
|
||||
sources =
|
||||
SourcesQuery.new()
|
||||
|> SourcesQuery.for_media_profile(media_profile)
|
||||
|> where(^SourcesQuery.for_media_profile(media_profile))
|
||||
|> order_by(asc: :custom_name)
|
||||
|> Repo.all()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
|
||||
def friendly_resolution_options do
|
||||
[
|
||||
{"8k", "4320p"},
|
||||
{"4k", "2160p"},
|
||||
{"1080p", "1080p"},
|
||||
{"720p", "720p"},
|
||||
@@ -63,7 +64,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
source_collection_name:
|
||||
"the YouTube name of the sources that use this profile (often the same as source_custom_name)",
|
||||
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
|
||||
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_episode_from_date: "the upload date formatted as sYYYYeMMDD",
|
||||
season_episode_index_from_date:
|
||||
"the upload date formatted as sYYYYeMMDDII where II is an index to prevent date collisions"
|
||||
}
|
||||
end
|
||||
|
||||
@@ -94,7 +99,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
end
|
||||
|
||||
defp media_center_output_template do
|
||||
"/shows/{{ source_custom_name }}/Season {{ season_from_date }}/{{ season_episode_from_date }} - {{ title }}.{{ ext }}"
|
||||
"/shows/{{ source_custom_name }}/Season {{ season_from_date }}/{{ season_episode_index_from_date }} - {{ title }}.{{ ext }}"
|
||||
end
|
||||
|
||||
defp audio_output_template do
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||
<:option>
|
||||
<span x-data="{ copied: false }" x-on:click={~s"
|
||||
copyWithCallbacks(
|
||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`,
|
||||
() => copied = true,
|
||||
() => copied = false
|
||||
)
|
||||
"}>
|
||||
Copy JSON
|
||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||
</span>
|
||||
</:option>
|
||||
<:option>
|
||||
<div class="h-px w-full bg-bodydark2"></div>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/media_profiles/#{@media_profile}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
|
||||
>
|
||||
Delete Profile
|
||||
</.link>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
|
||||
class="mt-5 md:mt-0"
|
||||
>
|
||||
Delete Profile + Files
|
||||
</.link>
|
||||
</:option>
|
||||
</.button_dropdown>
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.media_profile_form changeset={@changeset} action={~p"/media_profiles/#{@media_profile}"} />
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,8 @@
|
||||
<:col :let={media_profile} label="Preferred Resolution">
|
||||
<%= media_profile.preferred_resolution %>
|
||||
</:col>
|
||||
<:col :let={media_profile} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/media_profiles/#{media_profile.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||
<:col :let={media_profile} label="" class="flex justify-end">
|
||||
<.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@
|
||||
options={friendly_format_type_options()}
|
||||
type="select"
|
||||
label="Include Livestreams"
|
||||
help="Excludes media that comes from a past livestream"
|
||||
help="How to handle past livestreams"
|
||||
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.media_profile_form changeset={@changeset} action={~p"/media_profiles"} />
|
||||
</div>
|
||||
|
||||
@@ -17,39 +17,19 @@
|
||||
</nav>
|
||||
</div>
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<.tabbed_layout>
|
||||
<:tab_append>
|
||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/media_profiles/#{@media_profile}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
|
||||
>
|
||||
Delete Profile
|
||||
</.link>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
|
||||
class="mt-5 md:mt-0"
|
||||
>
|
||||
Delete Profile + Files
|
||||
</.link>
|
||||
</:option>
|
||||
</.button_dropdown>
|
||||
<.actions_dropdown media_profile={@media_profile} />
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Media Profile">
|
||||
<:tab title="Media Profile" id="media-profile">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Sources">
|
||||
<:tab title="Sources" id="sources">
|
||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||
<:col :let={source} label="Name">
|
||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||
@@ -60,9 +40,8 @@
|
||||
<:col :let={source} label="Should Download?">
|
||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||
</:col>
|
||||
<:col :let={source} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||
<:col :let={source} label="" class="flex justify-end">
|
||||
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||
</:col>
|
||||
</.table>
|
||||
</:tab>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
defmodule PinchflatWeb.Pages.PageController do
|
||||
alias Pinchflat.Media.MediaItem
|
||||
use PinchflatWeb, :controller
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
|
||||
def home(conn, params) do
|
||||
@@ -21,11 +20,14 @@ defmodule PinchflatWeb.Pages.PageController do
|
||||
end
|
||||
|
||||
defp render_home_page(conn) do
|
||||
downloaded_media_items = where(MediaQuery.new(), ^MediaQuery.downloaded())
|
||||
|
||||
conn
|
||||
|> render(:home,
|
||||
media_profile_count: Repo.aggregate(MediaProfile, :count, :id),
|
||||
source_count: Repo.aggregate(Source, :count, :id),
|
||||
media_item_count: Repo.aggregate(MediaItem, :count, :id)
|
||||
media_item_size: Repo.aggregate(downloaded_media_items, :sum, :media_size_bytes),
|
||||
media_item_count: Repo.aggregate(downloaded_media_items, :count, :id)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
defmodule PinchflatWeb.Pages.PageHTML do
|
||||
use PinchflatWeb, :html
|
||||
|
||||
alias Pinchflat.Utils.NumberUtils
|
||||
|
||||
embed_templates "page_html/*"
|
||||
|
||||
def readable_media_filesize(media_filesize) do
|
||||
{num, suffix} = NumberUtils.human_byte_size(media_filesize, precision: 1)
|
||||
|
||||
"#{Float.round(num)} #{suffix}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
defmodule Pinchflat.Pages.HistoryTableLive do
|
||||
use PinchflatWeb, :live_view
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Utils.NumberUtils
|
||||
alias PinchflatWeb.CustomComponents.TextComponents
|
||||
|
||||
@limit 5
|
||||
|
||||
def render(%{records: []} = assigns) do
|
||||
~H"""
|
||||
<div class="mb-4 flex items-center">
|
||||
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" />
|
||||
<p class="ml-2">Nothing Here!</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<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" />
|
||||
<span class="ml-2">Showing <%= length(@records) %> of <%= @total_record_count %></span>
|
||||
</span>
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.table rows={@records} table_class="text-white">
|
||||
<:col :let={media_item} label="Title">
|
||||
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}>
|
||||
<%= StringUtils.truncate(media_item.title, 35) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={media_item} label="Upload Date">
|
||||
<%= media_item.upload_date %>
|
||||
</:col>
|
||||
<:col :let={media_item} label="Indexed At">
|
||||
<%= format_datetime(media_item.inserted_at) %>
|
||||
</:col>
|
||||
<:col :let={media_item} label="Downloaded At">
|
||||
<%= format_datetime(media_item.media_downloaded_at) %>
|
||||
</:col>
|
||||
<:col :let={media_item} label="Source">
|
||||
<.subtle_link href={~p"/sources/#{media_item.source_id}"}>
|
||||
<%= StringUtils.truncate(media_item.source.custom_name, 35) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
<section class="flex justify-center mt-5">
|
||||
<.live_pagination_controls page_number={@page} total_pages={@total_pages} />
|
||||
</section>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
page = 1
|
||||
base_query = generate_base_query()
|
||||
pagination_attrs = fetch_pagination_attributes(base_query, page)
|
||||
|
||||
{:ok, assign(socket, Map.merge(pagination_attrs, %{base_query: base_query}))}
|
||||
end
|
||||
|
||||
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
|
||||
direction = if direction == "inc", do: 1, else: -1
|
||||
new_page = assigns.page + direction
|
||||
new_assigns = fetch_pagination_attributes(assigns.base_query, new_page)
|
||||
|
||||
{:noreply, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
def handle_event("reload_page", _params, %{assigns: assigns} = socket) do
|
||||
new_assigns = fetch_pagination_attributes(assigns.base_query, assigns.page)
|
||||
|
||||
{:noreply, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
defp fetch_pagination_attributes(base_query, page) 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)
|
||||
|
||||
%{page: page, total_pages: total_pages, records: records, total_record_count: total_record_count}
|
||||
end
|
||||
|
||||
defp fetch_records(base_query, page) do
|
||||
offset = (page - 1) * @limit
|
||||
|
||||
base_query
|
||||
|> limit(^@limit)
|
||||
|> offset(^offset)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:source)
|
||||
end
|
||||
|
||||
defp generate_base_query do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic(^MediaQuery.downloaded() or ^MediaQuery.pending()))
|
||||
|> order_by(desc: :id)
|
||||
end
|
||||
|
||||
defp format_datetime(nil), do: ""
|
||||
|
||||
defp format_datetime(datetime) do
|
||||
TextComponents.datetime_in_zone(%{datetime: datetime, format: "%Y-%m-%d %H:%M"})
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||
<a href={~p"/media_profiles"} class="mt-4 flex flex-col items-center justify-center">
|
||||
<span class="text-md font-medium">Media Profile(s)</span>
|
||||
@@ -17,11 +17,32 @@
|
||||
</div>
|
||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||
<span class="mt-4 flex flex-col items-center justify-center">
|
||||
<span class="text-md font-medium">Media Item(s)</span>
|
||||
<span class="text-md font-medium">Downloaded Media</span>
|
||||
<h4 class="text-title-md font-bold text-white">
|
||||
<%= @media_item_count %>
|
||||
</h4>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-strokedark">I know this page isn't super useful yet, but give it time :)</span>
|
||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||
<span class="mt-4 flex flex-col items-center justify-center">
|
||||
<span class="text-md font-medium">Library Size</span>
|
||||
<h4 class="text-title-md font-bold text-white">
|
||||
<%= readable_media_filesize(@media_item_size) %>
|
||||
</h4>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
|
||||
<span class="text-2xl font-medium mb-4">Media History</span>
|
||||
<section class="mt-6">
|
||||
<%= live_render(@conn, Pinchflat.Pages.HistoryTableLive) %>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
|
||||
<span class="text-2xl font-medium mb-4">Active Tasks</span>
|
||||
<section class="mt-6 min-h-80">
|
||||
<%= live_render(@conn, Pinchflat.Pages.JobTableLive) %>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
defmodule Pinchflat.Pages.JobTableLive do
|
||||
use PinchflatWeb, :live_view
|
||||
use Pinchflat.Tasks.TasksQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias PinchflatWeb.CustomComponents.TextComponents
|
||||
|
||||
def render(%{tasks: []} = assigns) do
|
||||
~H"""
|
||||
<div class="mb-4 flex items-center">
|
||||
<p>Nothing Here!</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.table rows={@tasks} table_class="text-white">
|
||||
<:col :let={task} label="Task">
|
||||
<%= worker_to_task_name(task.job.worker) %>
|
||||
</:col>
|
||||
<:col :let={task} label="Subject">
|
||||
<.subtle_link href={task_to_link(task)}>
|
||||
<%= StringUtils.truncate(task_to_record_name(task), 35) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={task} label="Attempt No.">
|
||||
<%= task.job.attempt %>
|
||||
</:col>
|
||||
<:col :let={task} label="Started At">
|
||||
<%= format_datetime(task.job.attempted_at) %>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
PinchflatWeb.Endpoint.subscribe("job:state")
|
||||
|
||||
{:ok, assign(socket, tasks: get_tasks())}
|
||||
end
|
||||
|
||||
def handle_info(%{topic: "job:state", event: "change"}, socket) do
|
||||
{:noreply, assign(socket, tasks: get_tasks())}
|
||||
end
|
||||
|
||||
defp get_tasks do
|
||||
TasksQuery.new()
|
||||
|> TasksQuery.join_job()
|
||||
|> where(^TasksQuery.in_state("executing"))
|
||||
|> where(^TasksQuery.has_tag("show_in_dashboard"))
|
||||
|> order_by([t, j], desc: j.attempted_at)
|
||||
|> Repo.all()
|
||||
|> Repo.preload([:media_item, :source])
|
||||
end
|
||||
|
||||
defp worker_to_task_name(worker) do
|
||||
final_module_part =
|
||||
worker
|
||||
|> String.split(".")
|
||||
|> Enum.at(-1)
|
||||
|
||||
map_worker_to_task_name(final_module_part)
|
||||
end
|
||||
|
||||
defp map_worker_to_task_name("FastIndexingWorker"), do: "Fast Indexing Source"
|
||||
defp map_worker_to_task_name("MediaDownloadWorker"), do: "Downloading Media"
|
||||
defp map_worker_to_task_name("MediaCollectionIndexingWorker"), do: "Indexing Source"
|
||||
defp map_worker_to_task_name("MediaQualityUpgradeWorker"), do: "Upgrading Media Quality"
|
||||
defp map_worker_to_task_name("SourceMetadataStorageWorker"), do: "Fetching Source Metadata"
|
||||
defp map_worker_to_task_name(other), do: other <> " (Report to Devs)"
|
||||
|
||||
defp task_to_record_name(%Task{} = task) do
|
||||
case task do
|
||||
%Task{source: source} when source != nil -> source.custom_name
|
||||
%Task{media_item: mi} when mi != nil -> mi.title
|
||||
_ -> "Unknown Record"
|
||||
end
|
||||
end
|
||||
|
||||
defp task_to_link(%Task{} = task) do
|
||||
case task do
|
||||
%Task{source: source} when source != nil -> ~p"/sources/#{source.id}"
|
||||
%Task{media_item: mi} when mi != nil -> ~p"/sources/#{mi.source_id}/media/#{mi}"
|
||||
_ -> "#"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_datetime(nil), do: ""
|
||||
|
||||
defp format_datetime(datetime) do
|
||||
TextComponents.datetime_in_zone(%{datetime: datetime, format: "%Y-%m-%d %H:%M"})
|
||||
end
|
||||
end
|
||||
@@ -1,16 +1,17 @@
|
||||
defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||
use PinchflatWeb, :controller
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||
alias Pinchflat.Podcasts.PodcastHelpers
|
||||
|
||||
def rss_feed(conn, %{"uuid" => uuid}) do
|
||||
source = Repo.get_by!(Source, uuid: uuid)
|
||||
url_base = url(conn, ~p"/")
|
||||
xml = RssFeedBuilder.build(source, limit: 300, url_base: url_base)
|
||||
xml = RssFeedBuilder.build(source, limit: 2_000, url_base: url_base)
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/rss+xml")
|
||||
@@ -25,8 +26,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||
# if the source doesn't have any usable images
|
||||
media_items =
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||
|> Repo.maybe_limit(1)
|
||||
|> Repo.all()
|
||||
|
||||
@@ -40,4 +40,16 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||
|> send_file(200, filepath)
|
||||
end
|
||||
end
|
||||
|
||||
def episode_image(conn, %{"uuid" => uuid}) do
|
||||
media_item = Repo.get_by!(MediaItem, uuid: uuid)
|
||||
|
||||
if media_item.thumbnail_filepath && File.exists?(media_item.thumbnail_filepath) do
|
||||
conn
|
||||
|> put_resp_content_type(MIME.from_path(media_item.thumbnail_filepath))
|
||||
|> send_file(200, media_item.thumbnail_filepath)
|
||||
else
|
||||
send_resp(conn, 404, "Image not found")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,24 +5,18 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<div class="flex flex-col gap-10 dark:text-white">
|
||||
<%= if match?([_|_], @search_results) do %>
|
||||
<.table rows={@search_results} table_class="text-black dark:text-white">
|
||||
<:col :let={result} label="Title">
|
||||
<%= result.title %>
|
||||
<.subtle_link href={~p"/sources/#{result.source_id}/media/#{result.id}"}>
|
||||
<%= StringUtils.truncate(result.title, 35) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={result} label="Excerpt">
|
||||
<.highlight_search_terms text={result.matching_search_term} />
|
||||
</:col>
|
||||
<:col :let={result} label="" class="flex place-content-evenly">
|
||||
<.link
|
||||
href={~p"/sources/#{result.source_id}/media/#{result.id}"}
|
||||
class="hover:text-secondary duration-200 ease-in-out mx-0.5"
|
||||
>
|
||||
<.icon name="hero-eye" />
|
||||
</.link>
|
||||
</:col>
|
||||
</.table>
|
||||
<% else %>
|
||||
<p class="font-bold text-lg text-center text-black dark:text-white">No results found</p>
|
||||
|
||||
@@ -23,4 +23,20 @@ defmodule PinchflatWeb.Settings.SettingController do
|
||||
render(conn, "show.html", changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def app_info(conn, _params) do
|
||||
render(conn, "app_info.html")
|
||||
end
|
||||
|
||||
def download_logs(conn, _params) do
|
||||
log_path = Application.get_env(:pinchflat, :log_path)
|
||||
|
||||
if log_path && File.exists?(log_path) do
|
||||
send_download(conn, {:file, log_path}, filename: "pinchflat-logs-#{Date.utc_today()}.txt")
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Log file couldn't be found")
|
||||
|> redirect(to: ~p"/app_info")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
defmodule PinchflatWeb.Settings.SettingHTML do
|
||||
use PinchflatWeb, :html
|
||||
|
||||
alias Pinchflat.Downloading.CodecParser
|
||||
|
||||
embed_templates "setting_html/*"
|
||||
|
||||
@doc """
|
||||
Renders a setting form.
|
||||
"""
|
||||
attr :conn, Plug.Conn, required: true
|
||||
attr :changeset, Ecto.Changeset, required: true
|
||||
attr :action, :string, required: true
|
||||
|
||||
@@ -17,4 +20,14 @@ defmodule PinchflatWeb.Settings.SettingHTML do
|
||||
|
||||
~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 diagnostic_info_string do
|
||||
"""
|
||||
- App Version: #{Application.spec(:pinchflat)[:vsn]}
|
||||
- yt-dlp Version: #{Settings.get!(:yt_dlp_version)}
|
||||
- Apprise Version: #{Settings.get!(:apprise_version)}
|
||||
- System Architecture: #{to_string(:erlang.system_info(:system_architecture))}
|
||||
- Timezone: #{Application.get_env(:pinchflat, :timezone)}
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
||||
<div class="flex gap-3 items-center">
|
||||
<h2 class="text-title-md2 font-bold text-white ml-4">
|
||||
App Info
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||
<div class="max-w-full">
|
||||
<.button color="bg-primary" rounding="rounded-lg" x-data="{ copied: false }" x-on:click={~s"
|
||||
copyWithCallbacks(
|
||||
`#{diagnostic_info_string()}`,
|
||||
() => copied = true,
|
||||
() => copied = false
|
||||
)
|
||||
"}>
|
||||
Copy Diagnostic Info
|
||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||
</.button>
|
||||
<.link href={~p"/download_logs"}>
|
||||
<.button color="bg-primary" rounding="rounded-lg" class="ml-4">
|
||||
Download Logs
|
||||
</.button>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
defmodule Pinchflat.Settings.AppriseServerLive do
|
||||
use PinchflatWeb, :live_view
|
||||
|
||||
alias PinchflatWeb.Settings.SettingHTML
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.input
|
||||
type="text"
|
||||
id="setting_apprise_server"
|
||||
name="setting[apprise_server]"
|
||||
value={@value}
|
||||
label="Apprise Server"
|
||||
help={SettingHTML.apprise_server_help()}
|
||||
html_help={true}
|
||||
inputclass="font-mono text-sm mr-4"
|
||||
placeholder="https://discordapp.com/api/webhooks/{WebhookID}/{WebhookToken}"
|
||||
phx-change="apprise_server_changed"
|
||||
>
|
||||
<:input_append>
|
||||
<.icon_button icon_name={@icon_name} class="h-12 w-12" phx-click="send_apprise_test" tooltip={@tooltip} />
|
||||
</:input_append>
|
||||
</.input>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, session, socket) do
|
||||
new_assigns = %{
|
||||
value: session["value"],
|
||||
icon_name: "hero-paper-airplane",
|
||||
tooltip: "Send Test"
|
||||
}
|
||||
|
||||
{:ok, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
def handle_event("send_apprise_test", _params, %{assigns: assigns} = socket) do
|
||||
backend_runner().run([assigns.value], title: "Pinchflat Test", body: "This is a test message from Pinchflat")
|
||||
Process.send_after(self(), :reset_button_icon, 4_000)
|
||||
|
||||
{:noreply, assign(socket, %{icon_name: "hero-check", tooltip: "Sent!"})}
|
||||
end
|
||||
|
||||
def handle_event("apprise_server_changed", %{"setting" => setting}, socket) do
|
||||
{:noreply, assign(socket, %{value: setting["apprise_server"]})}
|
||||
end
|
||||
|
||||
def handle_info(:reset_button_icon, socket) do
|
||||
{:noreply, assign(socket, %{icon_name: "hero-paper-airplane", tooltip: "Send Test"})}
|
||||
end
|
||||
|
||||
defp backend_runner do
|
||||
Application.get_env(:pinchflat, :apprise_runner)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
<aside>
|
||||
<h2 class="text-xl font-bold mb-2">Codec Preferences</h2>
|
||||
<section class="ml-2 md:ml-4 mb-2 max-w-prose">
|
||||
<section>
|
||||
Available video codecs:
|
||||
<ul class="list-disc ml-8">
|
||||
<li :for={{codec, _} <- CodecParser.video_codec_map()}><%= codec %></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="mt-4">
|
||||
Available audio codecs:
|
||||
<ul class="list-disc ml-8">
|
||||
<li :for={{codec, _} <- CodecParser.audio_codec_map()}><%= codec %></li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -1,21 +1,68 @@
|
||||
<.simple_form :let={f} for={@changeset} action={@action}>
|
||||
<.simple_form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
action={@action}
|
||||
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
|
||||
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
|
||||
>
|
||||
<.error :if={@changeset.action}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
|
||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||
Notification Settings
|
||||
</h3>
|
||||
<section>
|
||||
<section class="flex justify-between items-center mt-4">
|
||||
<h3 class="text-2xl text-black dark:text-white">
|
||||
Notification Settings
|
||||
</h3>
|
||||
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<.input
|
||||
field={f[:apprise_server]}
|
||||
type="text"
|
||||
label="Apprise Server"
|
||||
help={apprise_server_help()}
|
||||
html_help={true}
|
||||
inputclass="font-mono text-sm"
|
||||
placeholder="https://discordapp.com/api/webhooks/{WebhookID}/{WebhookToken}"
|
||||
/>
|
||||
<%= live_render(
|
||||
@conn,
|
||||
Pinchflat.Settings.AppriseServerLive,
|
||||
session: %{"value" => f[:apprise_server].value}
|
||||
) %>
|
||||
</section>
|
||||
|
||||
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Settings</.button>
|
||||
<section class="mt-10" x-show="advancedMode">
|
||||
<section>
|
||||
<h3 class="text-2xl text-black dark:text-white">
|
||||
Codec Options
|
||||
</h3>
|
||||
|
||||
<p class="text-sm mt-2 max-w-prose">
|
||||
The best available codec will be used if your preferred codecs are not found
|
||||
</p>
|
||||
|
||||
<.input
|
||||
id="video_codec_preference_string"
|
||||
name="setting[video_codec_preference_string]"
|
||||
value={Enum.join(f[:video_codec_preference].value, ">")}
|
||||
placeholder="avc>vp9>av01"
|
||||
type="text"
|
||||
label="Video Codec Preference"
|
||||
help="Order of preference for video codecs. Separate with >. Will be remuxed into an MP4 container. See below for available codecs"
|
||||
inputclass="font-mono text-sm mr-4"
|
||||
/>
|
||||
|
||||
<.input
|
||||
id="audio_codec_preference_string"
|
||||
name="setting[audio_codec_preference_string]"
|
||||
value={Enum.join(f[:audio_codec_preference].value, ">")}
|
||||
placeholder="mp4a>opus>aac"
|
||||
type="text"
|
||||
label="Audio Codec Preference"
|
||||
help="Order of preference for audio codecs. Separate with >. See below for available codecs"
|
||||
inputclass="font-mono text-sm mr-4"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mt-5">
|
||||
<.codec_settings_help />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<.button class="mt-10 mb-4 sm:mb-8 w-full sm:w-auto" rounding="rounded-lg">Save Settings</.button>
|
||||
</.simple_form>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.setting_form changeset={@changeset} action={~p"/settings"} />
|
||||
<div class="max-w-full">
|
||||
<.setting_form conn={@conn} changeset={@changeset} action={~p"/settings"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
defmodule PinchflatWeb.Sources.SourceController do
|
||||
use PinchflatWeb, :controller
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
alias Pinchflat.Downloading.DownloadingHelpers
|
||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||
|
||||
def index(conn, _params) do
|
||||
sources =
|
||||
Source
|
||||
|> order_by(asc: :custom_name)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:media_profile)
|
||||
source_query =
|
||||
from s in Source,
|
||||
as: :source,
|
||||
inner_join: mp in assoc(s, :media_profile),
|
||||
preload: [media_profile: mp],
|
||||
order_by: [asc: s.custom_name],
|
||||
select: map(s, ^Source.__schema__(:fields)),
|
||||
select_merge: %{
|
||||
downloaded_count:
|
||||
subquery(
|
||||
from m in MediaItem,
|
||||
where: m.source_id == parent_as(:source).id,
|
||||
where: ^MediaQuery.downloaded(),
|
||||
select: count(m.id)
|
||||
),
|
||||
pending_count:
|
||||
subquery(
|
||||
from m in MediaItem,
|
||||
join: s in assoc(m, :source),
|
||||
where: m.source_id == parent_as(:source).id,
|
||||
where: ^MediaQuery.pending(),
|
||||
select: count(m.id)
|
||||
)
|
||||
}
|
||||
|
||||
render(conn, :index, sources: sources)
|
||||
render(conn, :index, sources: Repo.all(source_query))
|
||||
end
|
||||
|
||||
def new(conn, _params) do
|
||||
@@ -104,7 +123,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||
|> redirect(to: ~p"/sources")
|
||||
end
|
||||
|
||||
def force_download(conn, %{"source_id" => id}) do
|
||||
def force_download_pending(conn, %{"source_id" => id}) do
|
||||
wrap_forced_action(
|
||||
conn,
|
||||
id,
|
||||
@@ -113,6 +132,15 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||
)
|
||||
end
|
||||
|
||||
def force_redownload(conn, %{"source_id" => id}) do
|
||||
wrap_forced_action(
|
||||
conn,
|
||||
id,
|
||||
"Forcing re-download of downloaded media items.",
|
||||
&DownloadingHelpers.kickoff_redownload_for_existing_media/1
|
||||
)
|
||||
end
|
||||
|
||||
def force_index(conn, %{"source_id" => id}) do
|
||||
wrap_forced_action(
|
||||
conn,
|
||||
|
||||
@@ -3,22 +3,48 @@
|
||||
<span
|
||||
x-data="{ copied: false }"
|
||||
x-on:click={"
|
||||
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
|
||||
copied = true
|
||||
setTimeout(() => copied = false, 4000)
|
||||
"}
|
||||
copyWithCallbacks(
|
||||
'#{rss_feed_url(@conn, @source)}',
|
||||
() => copied = true,
|
||||
() => copied = false
|
||||
)
|
||||
"}
|
||||
>
|
||||
Copy RSS Feed
|
||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||
</span>
|
||||
</:option>
|
||||
<:option>
|
||||
<span x-data="{ copied: false }" x-on:click={~s"
|
||||
copyWithCallbacks(
|
||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`,
|
||||
() => copied = true,
|
||||
() => copied = false
|
||||
)
|
||||
"}>
|
||||
Copy JSON
|
||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||
</span>
|
||||
</:option>
|
||||
<:option>
|
||||
<div class="h-px w-full bg-bodydark2"></div>
|
||||
</:option>
|
||||
<:option :if={@source.download_media}>
|
||||
<.link
|
||||
href={~p"/sources/#{@source}/force_download"}
|
||||
href={~p"/sources/#{@source}/force_download_pending"}
|
||||
method="post"
|
||||
data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed."
|
||||
>
|
||||
Force Download
|
||||
Download Pending
|
||||
</.link>
|
||||
</:option>
|
||||
<:option :if={@source.download_media}>
|
||||
<.link
|
||||
href={~p"/sources/#{@source}/force_redownload"}
|
||||
method="post"
|
||||
data-confirm="Are you sure you want to re-download all currently downloaded media items? This isn't normally needed and won't change anything if the files already exist."
|
||||
>
|
||||
Redownload Existing
|
||||
</.link>
|
||||
</:option>
|
||||
<:option>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources/#{@source}"} />
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,12 @@
|
||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||
<:col :let={source} label="Name">
|
||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||
<%= source.custom_name || source.collection_name %>
|
||||
<%= StringUtils.truncate(source.custom_name || source.collection_name, 35) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||
<:col :let={source} label="Should Download?">
|
||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||
</:col>
|
||||
<:col :let={source} label="Pending"><%= source.pending_count %></:col>
|
||||
<:col :let={source} label="Downloaded"><%= source.downloaded_count %></:col>
|
||||
<:col :let={source} label="Retention">
|
||||
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
||||
<%= source.retention_period_days %> day(s)
|
||||
@@ -35,7 +34,6 @@
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={source} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
@@ -1,35 +1,62 @@
|
||||
defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
use PinchflatWeb, :live_view
|
||||
import Ecto.Query, warn: false
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Utils.NumberUtils
|
||||
|
||||
@limit 10
|
||||
|
||||
def render(%{records: []} = assigns) do
|
||||
def render(%{total_record_count: 0} = assigns) do
|
||||
~H"""
|
||||
<p class="text-black dark:text-white">Nothing Here!</p>
|
||||
<div class="mb-4 flex items-center">
|
||||
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" />
|
||||
<p class="ml-2">Nothing Here!</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<span class="mb-4 inline-block">
|
||||
Showing <%= length(@records) %> of <%= @total_record_count %>
|
||||
</span>
|
||||
<.table rows={@records} table_class="text-black dark:text-white">
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<span class="flex items-center">
|
||||
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
|
||||
<span class="ml-2">Showing <%= length(@records) %> of <%= @filtered_record_count %></span>
|
||||
</span>
|
||||
<div class="bg-meta-4 rounded-md">
|
||||
<div class="relative">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
|
||||
<.icon name="hero-magnifying-glass" />
|
||||
</span>
|
||||
<form phx-change="search_term" phx-submit="search_term">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value={@search_term}
|
||||
placeholder="Search in table..."
|
||||
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
|
||||
phx-debounce="200"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<.table rows={@records} table_class="text-white">
|
||||
<:col :let={media_item} label="Title">
|
||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||
<:col :let={media_item} label="Upload Date">
|
||||
<%= media_item.upload_date %>
|
||||
</:col>
|
||||
<: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"} />
|
||||
</:col>
|
||||
<: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" />
|
||||
</:col>
|
||||
</.table>
|
||||
<section class="flex justify-center mt-5">
|
||||
@@ -40,30 +67,73 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
end
|
||||
|
||||
def mount(_params, session, socket) do
|
||||
PinchflatWeb.Endpoint.subscribe("media_table")
|
||||
|
||||
page = 1
|
||||
media_state = session["media_state"]
|
||||
source = Sources.get_source!(session["source_id"])
|
||||
base_query = generate_base_query(source, media_state)
|
||||
pagination_attrs = fetch_pagination_attributes(base_query, page)
|
||||
pagination_attrs = fetch_pagination_attributes(base_query, page, nil)
|
||||
|
||||
{:ok, assign(socket, Map.merge(pagination_attrs, %{base_query: base_query, source: source}))}
|
||||
new_assigns =
|
||||
Map.merge(
|
||||
pagination_attrs,
|
||||
%{
|
||||
base_query: base_query,
|
||||
source: source,
|
||||
media_state: media_state
|
||||
}
|
||||
)
|
||||
|
||||
{:ok, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
|
||||
direction = if direction == "inc", do: 1, else: -1
|
||||
new_page = assigns.page + direction
|
||||
new_assigns = fetch_pagination_attributes(assigns.base_query, new_page)
|
||||
new_assigns = fetch_pagination_attributes(assigns.base_query, new_page, assigns.search_term)
|
||||
|
||||
{:noreply, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
defp fetch_pagination_attributes(base_query, page) 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)
|
||||
def handle_event("search_term", params, socket) do
|
||||
search_term = Map.get(params, "q", nil)
|
||||
new_assigns = fetch_pagination_attributes(socket.assigns.base_query, 1, search_term)
|
||||
|
||||
%{page: page, total_pages: total_pages, records: records, total_record_count: total_record_count}
|
||||
{:noreply, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
# This, along with the handle_info below, is a pattern to reload _all_
|
||||
# tables on page rather than just the one that triggered the reload.
|
||||
def handle_event("reload_page", _params, socket) do
|
||||
PinchflatWeb.Endpoint.broadcast("media_table", "reload", nil)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(%{topic: "media_table", event: "reload"}, %{assigns: assigns} = socket) do
|
||||
new_assigns = fetch_pagination_attributes(assigns.base_query, assigns.page, assigns.search_term)
|
||||
|
||||
{:noreply, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
defp fetch_pagination_attributes(base_query, page, search_term) do
|
||||
filtered_base_query = filter_base_query(base_query, search_term)
|
||||
|
||||
total_record_count = Repo.aggregate(base_query, :count, :id)
|
||||
filtered_record_count = Repo.aggregate(filtered_base_query, :count, :id)
|
||||
total_pages = max(ceil(filtered_record_count / @limit), 1)
|
||||
page = NumberUtils.clamp(page, 1, total_pages)
|
||||
records = fetch_records(filtered_base_query, page)
|
||||
|
||||
%{
|
||||
page: page,
|
||||
total_pages: total_pages,
|
||||
records: records,
|
||||
search_term: search_term,
|
||||
total_record_count: total_record_count,
|
||||
filtered_record_count: filtered_record_count
|
||||
}
|
||||
end
|
||||
|
||||
defp fetch_records(base_query, page) do
|
||||
@@ -77,15 +147,34 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
|
||||
defp generate_base_query(source, "pending") do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.where_pending_download()
|
||||
|> order_by(desc: :id)
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> MediaQuery.require_assoc(:media_items_search_index)
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||
|> order_by(desc: fragment("rank"), desc: :upload_date)
|
||||
end
|
||||
|
||||
defp generate_base_query(source, "downloaded") do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> order_by(desc: :id)
|
||||
|> MediaQuery.require_assoc(:media_items_search_index)
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||
|> order_by(desc: fragment("rank"), desc: :upload_date)
|
||||
end
|
||||
|
||||
defp generate_base_query(source, "other") do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> MediaQuery.require_assoc(:media_items_search_index)
|
||||
|> where(
|
||||
^dynamic(
|
||||
^MediaQuery.for_source(source) and
|
||||
(not (^MediaQuery.downloaded()) and not (^MediaQuery.pending()))
|
||||
)
|
||||
)
|
||||
|> order_by(desc: fragment("rank"), desc: :upload_date)
|
||||
end
|
||||
|
||||
defp filter_base_query(base_query, search_term) do
|
||||
base_query
|
||||
|> where(^MediaQuery.matches_search_term(search_term))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources"} />
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
</nav>
|
||||
</div>
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="max-w-full">
|
||||
<.tabbed_layout>
|
||||
<:tab_append>
|
||||
<.actions_dropdown source={@source} conn={@conn} />
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Source">
|
||||
<:tab title="Source" id="source">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||
<section>
|
||||
@@ -36,21 +36,28 @@
|
||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Pending Media">
|
||||
<:tab title="Pending" id="pending">
|
||||
<%= live_render(
|
||||
@conn,
|
||||
Pinchflat.Sources.MediaItemTableLive,
|
||||
session: %{"source_id" => @source.id, "media_state" => "pending"}
|
||||
) %>
|
||||
</:tab>
|
||||
<:tab title="Downloaded Media">
|
||||
<:tab title="Downloaded" id="downloaded">
|
||||
<%= live_render(
|
||||
@conn,
|
||||
Pinchflat.Sources.MediaItemTableLive,
|
||||
session: %{"source_id" => @source.id, "media_state" => "downloaded"}
|
||||
) %>
|
||||
</:tab>
|
||||
<:tab title="Pending Tasks">
|
||||
<:tab title="Other" id="other">
|
||||
<%= live_render(
|
||||
@conn,
|
||||
Pinchflat.Sources.MediaItemTableLive,
|
||||
session: %{"source_id" => @source.id, "media_state" => "other"}
|
||||
) %>
|
||||
</:tab>
|
||||
<:tab title="Tasks" id="tasks">
|
||||
<%= if match?([_|_], @pending_tasks) do %>
|
||||
<.table rows={@pending_tasks} table_class="text-black dark:text-white">
|
||||
<:col :let={task} label="Worker">
|
||||
@@ -64,7 +71,7 @@
|
||||
</:col>
|
||||
</.table>
|
||||
<% else %>
|
||||
<p class="text-black dark:text-white">Nothing Here!</p>
|
||||
<p>Nothing Here!</p>
|
||||
<% end %>
|
||||
</:tab>
|
||||
</.tabbed_layout>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user