Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9f8f6abc | |||
| 2ae1ee1c99 | |||
| b73a9a4a67 | |||
| 3dd901ff3e | |||
| e751c65424 | |||
| 22439e0273 | |||
| 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 |
@@ -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 }}
|
ghcr.io/${{ github.repository }}
|
||||||
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}},prefix=v
|
type=ref,event=tag
|
||||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
|
||||||
type=semver,pattern={{major}},prefix=v
|
|
||||||
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
|
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=auto
|
latest=auto
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"Config.HTTPS",
|
"Config.HTTPS",
|
||||||
"Config.CSP",
|
"Config.CSP",
|
||||||
"XSS.ContentType",
|
"XSS.ContentType",
|
||||||
"Traversal.SendFile"
|
"Traversal.SendFile",
|
||||||
|
"Traversal.SendDownload"
|
||||||
],
|
],
|
||||||
ignore_files: [],
|
ignore_files: [],
|
||||||
version: false
|
version: false
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
- [Unraid](#unraid)
|
- [Unraid](#unraid)
|
||||||
- [Portainer](#portainer)
|
- [Portainer](#portainer)
|
||||||
- [Docker](#docker)
|
- [Docker](#docker)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
- [Username and Password (authentication)](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password)
|
- [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)
|
- [Frequently asked questions](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions)
|
||||||
- [Documentation](https://github.com/kieraneglin/pinchflat/wiki)
|
- [Documentation](https://github.com/kieraneglin/pinchflat/wiki)
|
||||||
@@ -40,9 +41,9 @@
|
|||||||
|
|
||||||
## What it does
|
## 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)
|
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)
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ Docker Compose file:
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
pinchflat:
|
pinchflat:
|
||||||
image: keglin/pinchflat:latest
|
image: ghcr.io/kieraneglin/pinchflat:latest
|
||||||
environment:
|
environment:
|
||||||
# Set the timezone to your local timezone
|
# Set the timezone to your local timezone
|
||||||
- TZ=America/New_York
|
- TZ=America/New_York
|
||||||
@@ -141,6 +142,19 @@ 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.
|
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 |
|
||||||
|
| BASE_ROUTE_PATH | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies |
|
||||||
|
|
||||||
## EFF donations
|
## EFF donations
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
+21
-18
@@ -22,8 +22,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-Light.woff2'), url('/fonts/satoshi/Satoshi-Light.woff'),
|
src: url('../fonts/satoshi/Satoshi-Light.woff2'), url('../fonts/satoshi/Satoshi-Light.woff'),
|
||||||
url('/fonts/satoshi/Satoshi-Light.ttf');
|
url('../fonts/satoshi/Satoshi-Light.ttf');
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -31,8 +31,9 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-LightItalic.woff2'),
|
src: url('../fonts/satoshi/Satoshi-LightItalic.woff2'),
|
||||||
url('/fonts/satoshi/Satoshi-LightItalic.woff'), url('/fonts/satoshi/Satoshi-LightItalic.ttf');
|
url('../fonts/satoshi/Satoshi-LightItalic.woff'),
|
||||||
|
url('../fonts/satoshi/Satoshi-LightItalic.ttf');
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -40,8 +41,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-Regular.woff2'), url('/fonts/satoshi/Satoshi-Regular.woff'),
|
src: url('../fonts/satoshi/Satoshi-Regular.woff2'), url('../fonts/satoshi/Satoshi-Regular.woff'),
|
||||||
url('/fonts/satoshi/Satoshi-Regular.ttf');
|
url('../fonts/satoshi/Satoshi-Regular.ttf');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -49,8 +50,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-Italic.woff2'), url('/fonts/satoshi/Satoshi-Italic.woff'),
|
src: url('../fonts/satoshi/Satoshi-Italic.woff2'), url('../fonts/satoshi/Satoshi-Italic.woff'),
|
||||||
url('/fonts/satoshi/Satoshi-Italic.ttf');
|
url('../fonts/satoshi/Satoshi-Italic.ttf');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -58,8 +59,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-Medium.woff2'), url('/fonts/satoshi/Satoshi-Medium.woff'),
|
src: url('../fonts/satoshi/Satoshi-Medium.woff2'), url('../fonts/satoshi/Satoshi-Medium.woff'),
|
||||||
url('/fonts/satoshi/Satoshi-Medium.ttf');
|
url('../fonts/satoshi/Satoshi-Medium.ttf');
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -67,8 +68,9 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-MediumItalic.woff2'),
|
src: url('../fonts/satoshi/Satoshi-MediumItalic.woff2'),
|
||||||
url('/fonts/satoshi/Satoshi-MediumItalic.woff'), url('/fonts/satoshi/Satoshi-MediumItalic.ttf');
|
url('../fonts/satoshi/Satoshi-MediumItalic.woff'),
|
||||||
|
url('../fonts/satoshi/Satoshi-MediumItalic.ttf');
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -76,8 +78,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-Bold.woff2'), url('/fonts/satoshi/Satoshi-Bold.woff'),
|
src: url('../fonts/satoshi/Satoshi-Bold.woff2'), url('../fonts/satoshi/Satoshi-Bold.woff'),
|
||||||
url('/fonts/satoshi/Satoshi-Bold.ttf');
|
url('../fonts/satoshi/Satoshi-Bold.ttf');
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -85,8 +87,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-BoldItalic.woff2'), url('/fonts/satoshi/Satoshi-BoldItalic.woff'),
|
src: url('../fonts/satoshi/Satoshi-BoldItalic.woff2'),
|
||||||
url('/fonts/satoshi/Satoshi-BoldItalic.ttf');
|
url('../fonts/satoshi/Satoshi-BoldItalic.woff'), url('../fonts/satoshi/Satoshi-BoldItalic.ttf');
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -94,8 +96,9 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('/fonts/satoshi/Satoshi-BlackItalic.woff2'),
|
src: url('../fonts/satoshi/Satoshi-BlackItalic.woff2'),
|
||||||
url('/fonts/satoshi/Satoshi-BlackItalic.woff'), url('/fonts/satoshi/Satoshi-BlackItalic.ttf');
|
url('../fonts/satoshi/Satoshi-BlackItalic.woff'),
|
||||||
|
url('../fonts/satoshi/Satoshi-BlackItalic.ttf');
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ window.Alpine = Alpine
|
|||||||
Alpine.start()
|
Alpine.start()
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
|
||||||
let liveSocket = new LiveSocket('/live', Socket, {
|
let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
|
||||||
params: { _csrf_token: csrfToken },
|
params: { _csrf_token: csrfToken },
|
||||||
dom: {
|
dom: {
|
||||||
onBeforeElUpdated(from, to) {
|
onBeforeElUpdated(from, to) {
|
||||||
|
|||||||
+7
-7
@@ -1,20 +1,20 @@
|
|||||||
window.setTabIndex = (index) => {
|
window.setTabByName = (tabName) => {
|
||||||
window.location.hash = `tab-${index}`
|
window.location.hash = `tab-${tabName}`
|
||||||
|
|
||||||
return index
|
return tabName
|
||||||
}
|
}
|
||||||
|
|
||||||
// The conditionals and currIndex stuff ensures that
|
// The conditionals and currIndex stuff ensures that
|
||||||
// the tab index is always set to 0 if the hash is empty
|
// the tab index is always set to 0 if the hash is empty
|
||||||
// AND other hash values are ignored
|
// AND other hash values are ignored
|
||||||
window.getTabIndex = (currIndex) => {
|
window.getTabFromHash = (currentTabName, defaultTabName) => {
|
||||||
if (window.location.hash === '' || window.location.hash === '#') {
|
if (window.location.hash === '' || window.location.hash === '#') {
|
||||||
return 0
|
return defaultTabName
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.hash.startsWith('#tab-')) {
|
if (window.location.hash.startsWith('#tab-')) {
|
||||||
return parseInt(window.location.hash.replace('#tab-', ''))
|
return window.location.hash.replace('#tab-', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
return currIndex
|
return currentTabName
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -26,7 +26,8 @@ config :pinchflat,
|
|||||||
basic_auth_password: "",
|
basic_auth_password: "",
|
||||||
expose_feed_endpoints: false,
|
expose_feed_endpoints: false,
|
||||||
file_watcher_poll_interval: 1000,
|
file_watcher_poll_interval: 1000,
|
||||||
timezone: "UTC"
|
timezone: "UTC",
|
||||||
|
base_route_path: "/"
|
||||||
|
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
journal_mode: :wal,
|
journal_mode: :wal,
|
||||||
@@ -54,7 +55,7 @@ config :pinchflat, Oban,
|
|||||||
{Oban.Plugins.Cron,
|
{Oban.Plugins.Cron,
|
||||||
crontab: [
|
crontab: [
|
||||||
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
|
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
|
||||||
{"0 2 * * *", Pinchflat.Downloading.MediaRedownloadWorker}
|
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
|
||||||
]}
|
]}
|
||||||
],
|
],
|
||||||
# TODO: consider making this an env var or something?
|
# TODO: consider making this an env var or something?
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import Config
|
|||||||
# before starting your production server.
|
# before starting your production server.
|
||||||
config :pinchflat, PinchflatWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
config :pinchflat, PinchflatWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
||||||
|
|
||||||
config :tzdata, :data_dir, "/etc/elixir_tzdata_data"
|
|
||||||
|
|
||||||
# Configures Swoosh API Client
|
# Configures Swoosh API Client
|
||||||
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Pinchflat.Finch
|
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Pinchflat.Finch
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -46,14 +46,14 @@ if config_env() == :prod do
|
|||||||
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
|
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
|
||||||
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
||||||
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
||||||
|
|
||||||
# For running PF as a podcast host on self-hosted environments
|
# For running PF as a podcast host on self-hosted environments
|
||||||
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
||||||
|
|
||||||
# For testing alternate journal modes (see issue #137)
|
# For testing alternate journal modes (see issue #137)
|
||||||
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
|
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
|
||||||
|
# For running PF in a subdirectory via a reverse proxy
|
||||||
|
base_route_path = System.get_env("BASE_ROUTE_PATH", "/")
|
||||||
|
|
||||||
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
|
||||||
|
|
||||||
config :pinchflat,
|
config :pinchflat,
|
||||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||||
@@ -64,7 +64,11 @@ if config_env() == :prod do
|
|||||||
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
||||||
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
|
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
|
||||||
expose_feed_endpoints: expose_feed_endpoints,
|
expose_feed_endpoints: expose_feed_endpoints,
|
||||||
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC"
|
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC",
|
||||||
|
log_path: log_path,
|
||||||
|
base_route_path: base_route_path
|
||||||
|
|
||||||
|
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
|
||||||
|
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
database: db_path,
|
database: db_path,
|
||||||
@@ -105,6 +109,7 @@ if config_env() == :prod do
|
|||||||
ip: {0, 0, 0, 0},
|
ip: {0, 0, 0, 0},
|
||||||
port: String.to_integer(System.get_env("PORT") || "4000")
|
port: String.to_integer(System.get_env("PORT") || "4000")
|
||||||
],
|
],
|
||||||
|
url: [path: base_route_path],
|
||||||
secret_key_base: secret_key_base
|
secret_key_base: secret_key_base
|
||||||
|
|
||||||
config :pinchflat, :logger, [
|
config :pinchflat, :logger, [
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ RUN echo "Building for ${TARGETPLATFORM:?}"
|
|||||||
RUN apt-get update -qq
|
RUN apt-get update -qq
|
||||||
RUN apt-get install -y inotify-tools curl git openssh-client jq \
|
RUN apt-get install -y inotify-tools curl git openssh-client jq \
|
||||||
python3 python3-setuptools python3-wheel python3-dev pipx \
|
python3 python3-setuptools python3-wheel python3-dev pipx \
|
||||||
python3-mutagen locales procps build-essential
|
python3-mutagen locales procps build-essential graphviz
|
||||||
|
|
||||||
# Install ffmpeg
|
# Install ffmpeg
|
||||||
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
|
|||||||
@@ -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
|
PinchflatWeb.Endpoint
|
||||||
]
|
]
|
||||||
|
|
||||||
:ok = Oban.Telemetry.attach_default_logger()
|
attach_oban_telemetry()
|
||||||
Logger.add_handlers(:pinchflat)
|
Logger.add_handlers(:pinchflat)
|
||||||
|
|
||||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
@@ -40,4 +40,11 @@ defmodule Pinchflat.Application do
|
|||||||
PinchflatWeb.Endpoint.config_change(changed, removed)
|
PinchflatWeb.Endpoint.config_change(changed, removed)
|
||||||
:ok
|
:ok
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def init(state) do
|
def init(state) do
|
||||||
|
ensure_tmpfile_directory()
|
||||||
reset_executing_jobs()
|
reset_executing_jobs()
|
||||||
create_blank_yt_dlp_files()
|
create_blank_yt_dlp_files()
|
||||||
create_blank_user_script_file()
|
create_blank_user_script_file()
|
||||||
@@ -39,6 +40,14 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
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
|
# 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
|
# in the "executing" state. This is a problem because the job runner will not
|
||||||
# pick them up again
|
# pick them up again
|
||||||
|
|||||||
@@ -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,6 +6,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
|
alias Pinchflat.Downloading.CodecParser
|
||||||
alias Pinchflat.Downloading.OutputPathBuilder
|
alias Pinchflat.Downloading.OutputPathBuilder
|
||||||
|
|
||||||
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||||
@@ -15,11 +16,11 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
|
|
||||||
Returns {:ok, [Keyword.t()]}
|
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
|
media_profile = media_item_with_preloads.source.media_profile
|
||||||
|
|
||||||
built_options =
|
built_options =
|
||||||
default_options() ++
|
default_options(override_opts) ++
|
||||||
subtitle_options(media_profile) ++
|
subtitle_options(media_profile) ++
|
||||||
thumbnail_options(media_item_with_preloads) ++
|
thumbnail_options(media_item_with_preloads) ++
|
||||||
metadata_options(media_profile) ++
|
metadata_options(media_profile) ++
|
||||||
@@ -50,11 +51,12 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
build_output_path_for(%MediaItem{source: source_with_preloads})
|
build_output_path_for(%MediaItem{source: source_with_preloads})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp default_options do
|
defp default_options(override_opts) do
|
||||||
|
overwrite_behaviour = Keyword.get(override_opts, :overwrite_behaviour, :force_overwrites)
|
||||||
|
|
||||||
[
|
[
|
||||||
:no_progress,
|
:no_progress,
|
||||||
# Add force-overwrites to make sure redownloading works
|
overwrite_behaviour,
|
||||||
:force_overwrites,
|
|
||||||
# This makes the date metadata conform to what jellyfin expects
|
# This makes the date metadata conform to what jellyfin expects
|
||||||
parse_metadata: "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"
|
parse_metadata: "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"
|
||||||
]
|
]
|
||||||
@@ -120,27 +122,26 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp quality_options(media_profile) do
|
defp quality_options(media_profile) do
|
||||||
video_codec_option = fn res ->
|
vcodec_string = CodecParser.generate_vcodec_string_from_settings()
|
||||||
[format_sort: "res:#{res},+codec:avc:m4a", remux_video: "mp4"]
|
acodec_string = CodecParser.generate_acodec_string_from_settings()
|
||||||
end
|
|
||||||
|
|
||||||
audio_format_precedence = [
|
|
||||||
"bestaudio[ext=m4a]",
|
|
||||||
"bestaudio[ext=mp3]",
|
|
||||||
"bestaudio",
|
|
||||||
"best[ext=m4a]",
|
|
||||||
"best[ext=mp3]",
|
|
||||||
"best"
|
|
||||||
]
|
|
||||||
|
|
||||||
case media_profile.preferred_resolution do
|
case media_profile.preferred_resolution do
|
||||||
# Also be aware that :audio disabled all embedding options for subtitles
|
# Also be aware that :audio disabled all embedding options for subtitles
|
||||||
:audio -> [:extract_audio, format: Enum.join(audio_format_precedence, "/")]
|
:audio ->
|
||||||
:"360p" -> video_codec_option.("360")
|
[:extract_audio, format: "#{acodec_string}/best"]
|
||||||
:"480p" -> video_codec_option.("480")
|
|
||||||
:"720p" -> video_codec_option.("720")
|
resolution_atom ->
|
||||||
:"1080p" -> video_codec_option.("1080")
|
{resolution_string, _} =
|
||||||
:"2160p" -> video_codec_option.("2160")
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
@@ -64,4 +66,36 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
|||||||
{:error, :should_not_download}
|
{:error, :should_not_download}
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :media_fetching,
|
queue: :media_fetching,
|
||||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||||
tags: ["media_item", "media_fetching"]
|
tags: ["media_item", "media_fetching", "show_in_dashboard"]
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@@ -33,12 +33,18 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
Does not download media if its source is set to not download media
|
Does not download media if its source is set to not download media
|
||||||
(unless forced).
|
(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}
|
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
||||||
should_force = Map.get(args, "force", false)
|
should_force = Map.get(args, "force", false)
|
||||||
is_redownload = Map.get(args, "redownload?", false)
|
is_quality_upgrade = Map.get(args, "quality_upgrade?", false)
|
||||||
|
|
||||||
media_item =
|
media_item =
|
||||||
media_item_id
|
media_item_id
|
||||||
@@ -47,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 the source or media item is set to not download media, perform a no-op unless forced
|
||||||
if (media_item.source.download_media && !media_item.prevent_download) || should_force do
|
if (media_item.source.download_media && !media_item.prevent_download) || should_force do
|
||||||
download_media_and_schedule_jobs(media_item, is_redownload)
|
download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force)
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
@@ -56,13 +62,16 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp download_media_and_schedule_jobs(media_item, is_redownload) do
|
defp download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force) do
|
||||||
case MediaDownloader.download_for_media_item(media_item) 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, downloaded_media_item} ->
|
||||||
{:ok, updated_media_item} =
|
{:ok, updated_media_item} =
|
||||||
Media.update_media_item(downloaded_media_item, %{
|
Media.update_media_item(downloaded_media_item, %{
|
||||||
media_size_bytes: compute_media_filesize(downloaded_media_item),
|
media_size_bytes: compute_media_filesize(downloaded_media_item),
|
||||||
media_redownloaded_at: get_redownloaded_at(is_redownload)
|
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
|
||||||
})
|
})
|
||||||
|
|
||||||
:ok = run_user_script(updated_media_item)
|
:ok = run_user_script(updated_media_item)
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
|
|
||||||
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
|
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
def download_for_media_item(%MediaItem{} = media_item) do
|
def download_for_media_item(%MediaItem{} = media_item, override_opts \\ []) do
|
||||||
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
|
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} ->
|
{:ok, parsed_json} ->
|
||||||
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
|
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
|
||||||
|
|
||||||
@@ -103,8 +103,8 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp download_with_options(url, item_with_preloads, output_filepath) do
|
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
|
||||||
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads)
|
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
|
||||||
|
|
||||||
YtDlpMedia.download(url, options, output_filepath: output_filepath)
|
YtDlpMedia.download(url, options, output_filepath: output_filepath)
|
||||||
end
|
end
|
||||||
|
|||||||
+6
-4
@@ -1,10 +1,10 @@
|
|||||||
defmodule Pinchflat.Downloading.MediaRedownloadWorker do
|
defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :media_fetching,
|
queue: :media_fetching,
|
||||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||||
tags: ["media_item", "media_fetching"]
|
tags: ["media_item", "media_fetching", "show_in_dashboard"]
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@@ -12,7 +12,9 @@ defmodule Pinchflat.Downloading.MediaRedownloadWorker do
|
|||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
@doc """
|
@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
|
This worker is scheduled to run daily via the Oban Cron plugin
|
||||||
and it should run _after_ the retention worker.
|
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")
|
Logger.info("Redownloading #{length(redownloadable_media)} media items")
|
||||||
|
|
||||||
Enum.each(redownloadable_media, fn media_item ->
|
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
|
end
|
||||||
end
|
end
|
||||||
@@ -7,10 +7,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.FastIndexing.YoutubeRss
|
alias Pinchflat.FastIndexing.YoutubeRss
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
|
|
||||||
@@ -48,8 +49,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
|
|
||||||
defp list_media_items_by_media_id_for(source, media_ids) do
|
defp list_media_items_by_media_id_for(source, media_ids) do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> where(^dynamic([mi], ^MediaQuery.for_source(source) and mi.media_id in ^media_ids))
|
||||||
|> MediaQuery.with_media_ids(media_ids)
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
|||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :fast_indexing,
|
queue: :fast_indexing,
|
||||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||||
tags: ["media_source", "fast_indexing"]
|
tags: ["media_source", "fast_indexing", "show_in_dashboard"]
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ defmodule Pinchflat.Lifecycle.Notifications.CommandRunner do
|
|||||||
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
|
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
|
||||||
|
|
||||||
{output, exit_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
|
{output, exit_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
|
||||||
Logger.info("[apprise] response: #{output}")
|
|
||||||
|
|
||||||
case exit_code do
|
case exit_code do
|
||||||
0 -> {:ok, String.trim(output)}
|
0 -> {:ok, String.trim(output)}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Wraps a function that may change the number of pending or downloaded
|
Wraps a function that may change the number of pending or downloaded
|
||||||
@@ -62,15 +63,14 @@ defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
|
|||||||
|
|
||||||
defp pending_media_item_count(source) do
|
defp pending_media_item_count(source) do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|> MediaQuery.where_pending_download()
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||||
|> Repo.aggregate(:count)
|
|> Repo.aggregate(:count)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp downloaded_media_item_count(source) do
|
defp downloaded_media_item_count(source) do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||||
|> MediaQuery.with_media_filepath()
|
|
||||||
|> Repo.aggregate(:count)
|
|> Repo.aggregate(:count)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
{:ok, executable_path} ->
|
{:ok, executable_path} ->
|
||||||
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
||||||
|
|
||||||
{output, exit_code} =
|
{_output, _exit_code} =
|
||||||
CliUtils.wrap_cmd(
|
CliUtils.wrap_cmd(
|
||||||
executable_path,
|
executable_path,
|
||||||
[to_string(event_type), encoded_data],
|
[to_string(event_type), encoded_data],
|
||||||
@@ -47,7 +47,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
logging_arg_override: "[suppressed]"
|
logging_arg_override: "[suppressed]"
|
||||||
)
|
)
|
||||||
|
|
||||||
handle_output(output, exit_code)
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -55,12 +55,6 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
raise ArgumentError, "Invalid event type: #{inspect(event_type)}"
|
raise ArgumentError, "Invalid event type: #{inspect(event_type)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_output(output, exit_code) do
|
|
||||||
Logger.debug("Custom lifecycle script exit code: #{exit_code} with output: #{output}")
|
|
||||||
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp backend_executable do
|
defp backend_executable do
|
||||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ defmodule Pinchflat.Media do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.Utils.FilesystemUtils
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
|
|
||||||
@@ -32,9 +32,8 @@ defmodule Pinchflat.Media do
|
|||||||
"""
|
"""
|
||||||
def list_cullable_media_items do
|
def list_cullable_media_items do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.with_media_filepath()
|
|> MediaQuery.require_assoc(:source)
|
||||||
|> MediaQuery.where_past_retention_period()
|
|> where(^MediaQuery.cullable())
|
||||||
|> MediaQuery.where_culling_not_prevented()
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -42,9 +41,10 @@ defmodule Pinchflat.Media do
|
|||||||
Returns a list of media_items that are redownloadable based on the redownload delay
|
Returns a list of media_items that are redownloadable based on the redownload delay
|
||||||
of the media_profile their source belongs to.
|
of the media_profile their source belongs to.
|
||||||
|
|
||||||
The logic is that a media_item is past_redownload_delay if the media_item's
|
The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
|
||||||
upload_date is at least redownload_delay_days ago AND
|
at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
|
||||||
`media_downloaded_at` - `redownload_delay_days` is before the media_item's `upload_date`.
|
is before the media_item's `uploaded_at`.
|
||||||
|
|
||||||
This logic grabs media that we've recently downloaded AND is recently uploaded, but
|
This logic grabs media that we've recently downloaded AND is recently uploaded, but
|
||||||
doesn't grab media that we've recently downloaded and was uploaded a long time ago.
|
doesn't grab media that we've recently downloaded and was uploaded a long time ago.
|
||||||
This also makes things work as expected when downloading media from a source for the
|
This also makes things work as expected when downloading media from a source for the
|
||||||
@@ -54,18 +54,14 @@ defmodule Pinchflat.Media do
|
|||||||
"""
|
"""
|
||||||
def list_redownloadable_media_items do
|
def list_redownloadable_media_items do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.with_media_downloaded_at()
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|> MediaQuery.where_download_not_prevented()
|
|> where(^MediaQuery.redownloadable())
|
||||||
|> MediaQuery.where_not_culled()
|
|
||||||
|> MediaQuery.where_media_not_redownloaded()
|
|
||||||
|> MediaQuery.where_past_redownload_delay()
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of pending media_items for a given source, where
|
Returns a list of pending media_items for a given source, where
|
||||||
pending means the `media_filepath` is `nil` AND the media_item
|
pending means the media_item satisfies `MediaQuery.pending`. You
|
||||||
matches satisfies `MediaQuery.where_pending_download`. You
|
|
||||||
should really check out that function if you need to know more
|
should really check out that function if you need to know more
|
||||||
because it has a lot going on.
|
because it has a lot going on.
|
||||||
|
|
||||||
@@ -73,15 +69,14 @@ defmodule Pinchflat.Media do
|
|||||||
"""
|
"""
|
||||||
def list_pending_media_items_for(%Source{} = source) do
|
def list_pending_media_items_for(%Source{} = source) do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|> MediaQuery.where_pending_download()
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
For a given media_item, tells you if it is pending download. This is defined as
|
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
|
the media_item satisfying `MediaQuery.pending` which you should really check out.
|
||||||
rules of the parent media_profile.
|
|
||||||
|
|
||||||
Intentionally does not take the `download_media` setting of the source into account.
|
Intentionally does not take the `download_media` setting of the source into account.
|
||||||
|
|
||||||
@@ -91,8 +86,8 @@ defmodule Pinchflat.Media do
|
|||||||
media_item = Repo.preload(media_item, source: :media_profile)
|
media_item = Repo.preload(media_item, source: :media_profile)
|
||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.with_id(media_item.id)
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|> MediaQuery.where_pending_download()
|
|> where(^dynamic([m, s, mp], m.id == ^media_item.id and ^MediaQuery.pending()))
|
||||||
|> Repo.exists?()
|
|> Repo.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -141,7 +136,7 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
Unlike `create_media_item`, this will attempt an update if the media_item
|
Unlike `create_media_item`, this will attempt an update if the media_item
|
||||||
already exists. This is so that future indexing can pick up attributes that
|
already exists. This is so that future indexing can pick up attributes that
|
||||||
we may not have asked for in the past (eg: upload_date)
|
we may not have asked for in the past (eg: uploaded_at)
|
||||||
|
|
||||||
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
import Pinchflat.Utils.ChangesetUtils
|
import Pinchflat.Utils.ChangesetUtils
|
||||||
|
|
||||||
@@ -12,7 +14,6 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Tasks.Task
|
alias Pinchflat.Tasks.Task
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
alias Pinchflat.Media.MediaItemsSearchIndex
|
alias Pinchflat.Media.MediaItemsSearchIndex
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
:livestream,
|
:livestream,
|
||||||
:source_id,
|
:source_id,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:upload_date,
|
:uploaded_at,
|
||||||
:upload_date_index,
|
:upload_date_index,
|
||||||
:duration_seconds,
|
:duration_seconds,
|
||||||
# these fields are captured only on download
|
# these fields are captured only on download
|
||||||
@@ -50,7 +51,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
livestream
|
livestream
|
||||||
media_id
|
media_id
|
||||||
source_id
|
source_id
|
||||||
upload_date
|
uploaded_at
|
||||||
short_form_content
|
short_form_content
|
||||||
)a
|
)a
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
field :short_form_content, :boolean, default: false
|
field :short_form_content, :boolean, default: false
|
||||||
field :media_downloaded_at, :utc_datetime
|
field :media_downloaded_at, :utc_datetime
|
||||||
field :media_redownloaded_at, :utc_datetime
|
field :media_redownloaded_at, :utc_datetime
|
||||||
field :upload_date, :date
|
field :uploaded_at, :utc_datetime
|
||||||
field :upload_date_index, :integer, default: 0
|
field :upload_date_index, :integer, default: 0
|
||||||
field :duration_seconds, :integer
|
field :duration_seconds, :integer
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
||||||
end
|
end
|
||||||
|
|
||||||
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :upload_date) do
|
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
|
||||||
source_id = get_field(changeset, :source_id)
|
source_id = get_field(changeset, :source_id)
|
||||||
source = Sources.get_source!(source_id)
|
source = Sources.get_source!(source_id)
|
||||||
# Channels should count down from 99, playlists should count up from 0
|
# Channels should count down from 99, playlists should count up from 0
|
||||||
@@ -141,8 +142,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
|
|
||||||
current_max =
|
current_max =
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source_id)
|
|> where(^dynamic([mi], ^MediaQuery.upload_date_matches(changes.uploaded_at) and ^MediaQuery.for_source(source)))
|
||||||
|> MediaQuery.where_uploaded_on_date(changes.upload_date)
|
|
||||||
|> Repo.aggregate(aggregator, :upload_date_index)
|
|> Repo.aggregate(aggregator, :upload_date_index)
|
||||||
|
|
||||||
case current_max do
|
case current_max do
|
||||||
|
|||||||
+142
-132
@@ -12,138 +12,42 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
|
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
|
|
||||||
# Prefixes:
|
# This allows the module to be aliased and query methods to be used
|
||||||
# - for_* - belonging to a certain record
|
# all in one go
|
||||||
# - join_* - for joining on a certain record
|
# usage: use Pinchflat.Media.MediaQuery
|
||||||
# - with_*, where_* - for filtering based on full, concrete attributes
|
defmacro __using__(_opts) do
|
||||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
quote do
|
||||||
#
|
import Ecto.Query, warn: false
|
||||||
# Suffixes:
|
|
||||||
# - _for - the arg passed is an association record
|
|
||||||
|
|
||||||
# NOTE: that dyanmic query approach kinda rocked - should refactor in future
|
alias unquote(__MODULE__)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def new do
|
def new do
|
||||||
MediaItem
|
MediaItem
|
||||||
end
|
end
|
||||||
|
|
||||||
def for_source(query, source_id) when is_integer(source_id) do
|
def for_source(source_id) when is_integer(source_id), do: dynamic([mi], mi.source_id == ^source_id)
|
||||||
where(query, [mi], mi.source_id == ^source_id)
|
def for_source(source), do: dynamic([mi], mi.source_id == ^source.id)
|
||||||
end
|
|
||||||
|
|
||||||
def for_source(query, source) do
|
def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath))
|
||||||
where(query, [mi], mi.source_id == ^source.id)
|
def download_prevented, do: dynamic([mi], mi.prevent_download == true)
|
||||||
end
|
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 upload_date_matches(other_date), do: dynamic([mi], fragment("date(?) = date(?)", mi.uploaded_at, ^other_date))
|
||||||
|
|
||||||
def join_sources(query) do
|
def upload_date_after_source_cutoff do
|
||||||
from(mi in query, join: s in assoc(mi, :source), as: :sources)
|
dynamic(
|
||||||
end
|
|
||||||
|
|
||||||
def where_past_retention_period(query) do
|
|
||||||
query
|
|
||||||
|> require_assoc(:source)
|
|
||||||
|> where(
|
|
||||||
[mi, source],
|
[mi, source],
|
||||||
fragment("""
|
is_nil(source.download_cutoff_date) or
|
||||||
IFNULL(retention_period_days, 0) > 0 AND
|
fragment("date(?) >= ?", mi.uploaded_at, source.download_cutoff_date)
|
||||||
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
|
||||||
""")
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def where_past_redownload_delay(query) do
|
def format_matching_profile_preference do
|
||||||
query
|
dynamic(
|
||||||
|> require_assoc(:source)
|
[mi, source, media_profile],
|
||||||
|> 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_uploaded_on_date(query, date) do
|
|
||||||
where(query, [mi], mi.upload_date == ^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(
|
|
||||||
fragment("""
|
fragment("""
|
||||||
CASE
|
CASE
|
||||||
WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
|
WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
|
||||||
@@ -165,22 +69,80 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def where_pending_download(query) do
|
def matches_source_title_regex do
|
||||||
query
|
dynamic(
|
||||||
|> where_download_not_prevented()
|
[mi, source],
|
||||||
|> with_no_media_filepath()
|
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex)
|
||||||
|> with_upload_date_after_source_cutoff()
|
)
|
||||||
|> with_format_matching_profile_preference()
|
|
||||||
|> matching_source_title_regex()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def where_pending_or_downloaded(query) do
|
def past_retention_period do
|
||||||
query
|
dynamic(
|
||||||
|> where_pending_download()
|
[mi, source],
|
||||||
|> or_where([mi], not is_nil(mi.media_downloaded_at))
|
fragment("""
|
||||||
|
IFNULL(retention_period_days, 0) > 0 AND
|
||||||
|
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
||||||
|
""")
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp require_assoc(query, identifier) do
|
def past_redownload_delay do
|
||||||
|
dynamic(
|
||||||
|
[mi, source, media_profile],
|
||||||
|
# Returns media items where the uploaded_at 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') > uploaded_at AND
|
||||||
|
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < uploaded_at
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
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
|
if has_named_binding?(query, identifier) do
|
||||||
query
|
query
|
||||||
else
|
else
|
||||||
@@ -188,6 +150,10 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
end
|
end
|
||||||
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
|
defp do_require_assoc(query, :source) do
|
||||||
from(mi in query, join: s in assoc(mi, :source), as: :source)
|
from(mi in query, join: s in assoc(mi, :source), as: :source)
|
||||||
end
|
end
|
||||||
@@ -197,4 +163,48 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
|> require_assoc(:source)
|
|> require_assoc(:source)
|
||||||
|> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
|
|> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -88,14 +88,17 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Parses an upload date from the YYYYMMDD string returned in yt-dlp metadata
|
Parses an upload date from the YYYYMMDD string returned in yt-dlp metadata
|
||||||
and returns a Date struct.
|
and returns a DateTime struct, appending a time of 00:00:00Z.
|
||||||
|
|
||||||
Returns Date.t()
|
Returns DateTime.t()
|
||||||
"""
|
"""
|
||||||
def parse_upload_date(upload_date) do
|
def parse_upload_date(upload_date) do
|
||||||
<<year::binary-size(4)>> <> <<month::binary-size(2)>> <> <<day::binary-size(2)>> = upload_date
|
<<year::binary-size(4)>> <> <<month::binary-size(2)>> <> <<day::binary-size(2)>> = upload_date
|
||||||
|
|
||||||
Date.from_iso8601!("#{year}-#{month}-#{day}")
|
case DateTime.from_iso8601("#{year}-#{month}-#{day}T00:00:00Z") do
|
||||||
|
{:ok, datetime, _} -> datetime
|
||||||
|
_ -> raise "Invalid upload date: #{upload_date}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
|||||||
and not have it, ya know?
|
and not have it, ya know?
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Parses the given JSON response from yt-dlp and returns a map of
|
Parses the given JSON response from yt-dlp and returns a map of
|
||||||
the needful media_item attributes, along with anything needed for
|
the needful media_item attributes, along with anything needed for
|
||||||
@@ -24,15 +26,12 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp parse_media_metadata(metadata) do
|
defp parse_media_metadata(metadata) do
|
||||||
|
Map.merge(
|
||||||
|
Map.from_struct(YtDlpMedia.response_to_struct(metadata)),
|
||||||
%{
|
%{
|
||||||
media_id: metadata["id"],
|
media_filepath: metadata["filepath"]
|
||||||
title: metadata["title"],
|
|
||||||
original_url: metadata["original_url"],
|
|
||||||
description: metadata["description"],
|
|
||||||
media_filepath: metadata["filepath"],
|
|
||||||
livestream: !!metadata["was_live"],
|
|
||||||
duration_seconds: metadata["duration"] && round(metadata["duration"])
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_subtitle_metadata(metadata) do
|
defp parse_subtitle_metadata(metadata) do
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
|||||||
|
|
||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :remote_metadata,
|
queue: :remote_metadata,
|
||||||
tags: ["media_source", "source_metadata", "remote_metadata"],
|
tags: ["media_source", "source_metadata", "remote_metadata", "show_in_dashboard"],
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
or its media items
|
or its media items
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
|
|
||||||
@@ -23,11 +24,11 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
Returns: [%MediaItem{}]
|
Returns: [%MediaItem{}]
|
||||||
"""
|
"""
|
||||||
def persisted_media_items_for(source, opts \\ []) do
|
def persisted_media_items_for(source, opts \\ []) do
|
||||||
limit = Keyword.get(opts, :limit, 500)
|
limit = Keyword.get(opts, :limit, 1_000)
|
||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||||
|> MediaQuery.with_media_filepath()
|
|> order_by(desc: :uploaded_at)
|
||||||
|> Repo.maybe_limit(limit)
|
|> Repo.maybe_limit(limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
|
|
||||||
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
||||||
|
|
||||||
alias Pinchflat.Utils.DatetimeUtils
|
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
alias PinchflatWeb.Router.Helpers, as: Routes
|
alias PinchflatWeb.Router.Helpers, as: Routes
|
||||||
|
|
||||||
@@ -16,12 +15,12 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
Only MediaItems that have been persisted will be included in the feed.
|
Only MediaItems that have been persisted will be included in the feed.
|
||||||
|
|
||||||
## Options:
|
## 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.
|
Returns an XML document as a string.
|
||||||
"""
|
"""
|
||||||
def build(source, opts \\ []) do
|
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())
|
url_base = Keyword.get(opts, :url_base, PinchflatWeb.Endpoint.url())
|
||||||
|
|
||||||
media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit)
|
media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit)
|
||||||
@@ -83,7 +82,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
<title>#{safe(media_item.title)}</title>
|
<title>#{safe(media_item.title)}</title>
|
||||||
<link>#{safe(media_item.original_url)}</link>
|
<link>#{safe(media_item.original_url)}</link>
|
||||||
<description>#{safe(media_item.description)}</description>
|
<description>#{safe(media_item.description)}</description>
|
||||||
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
<pubDate>#{Calendar.strftime(media_item.uploaded_at, @datetime_format)}</pubDate>
|
||||||
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
||||||
<enclosure
|
<enclosure
|
||||||
url="#{media_stream_path(url_base, media_item)}"
|
url="#{media_stream_path(url_base, media_item)}"
|
||||||
@@ -133,12 +132,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_upload_date(media_item) do
|
|
||||||
media_item.upload_date
|
|
||||||
|> DatetimeUtils.date_to_datetime()
|
|
||||||
|> Calendar.strftime(@datetime_format)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp podcast_route(action, params) do
|
defp podcast_route(action, params) do
|
||||||
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
|
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
# See `build_format_clauses` in the Media context for more.
|
# See `build_format_clauses` in the Media context for more.
|
||||||
field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
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 :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
|
has_many :sources, Source
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,17 @@ defmodule Pinchflat.Release do
|
|||||||
load_app()
|
load_app()
|
||||||
|
|
||||||
directories =
|
directories =
|
||||||
Enum.uniq([
|
[
|
||||||
"/config",
|
"/config",
|
||||||
"/downloads",
|
"/downloads",
|
||||||
Application.get_env(:pinchflat, :media_directory),
|
Application.get_env(:pinchflat, :media_directory),
|
||||||
Application.get_env(:pinchflat, :tmpfile_directory),
|
Application.get_env(:pinchflat, :tmpfile_directory),
|
||||||
Application.get_env(:pinchflat, :extras_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 ->
|
Enum.each(directories, fn dir ->
|
||||||
Logger.info("Checking permissions for #{dir}")
|
Logger.info("Checking permissions for #{dir}")
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ defmodule Pinchflat.Settings.Setting do
|
|||||||
:pro_enabled,
|
:pro_enabled,
|
||||||
:yt_dlp_version,
|
:yt_dlp_version,
|
||||||
:apprise_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(
|
@required_fields ~w(
|
||||||
@@ -25,12 +32,43 @@ defmodule Pinchflat.Settings.Setting do
|
|||||||
field :yt_dlp_version, :string
|
field :yt_dlp_version, :string
|
||||||
field :apprise_version, :string
|
field :apprise_version, :string
|
||||||
field :apprise_server, :string
|
field :apprise_server, :string
|
||||||
|
|
||||||
|
field :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
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def changeset(setting, attrs) do
|
def changeset(setting, attrs) do
|
||||||
setting
|
setting
|
||||||
|> cast(attrs, @allowed_fields)
|
|> cast(attrs, @allowed_fields)
|
||||||
|
|> cast(attrs, @virtual_fields, empty_values: [])
|
||||||
|
|> convert_codec_preference_strings()
|
||||||
|> validate_required(@required_fields)
|
|> validate_required(@required_fields)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp convert_codec_preference_strings(changeset) do
|
||||||
|
fields = [
|
||||||
|
video_codec_preference_string: :video_codec_preference,
|
||||||
|
audio_codec_preference_string: :audio_codec_preference
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.reduce(fields, changeset, fn {virtual_field, actual_field}, changeset ->
|
||||||
|
case get_change(changeset, virtual_field) do
|
||||||
|
nil ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
value ->
|
||||||
|
new_value =
|
||||||
|
value
|
||||||
|
|> String.split(">")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(String.trim(&1) == ""))
|
||||||
|
|> Enum.map(&String.downcase/1)
|
||||||
|
|
||||||
|
put_change(changeset, actual_field, new_value)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :media_collection_indexing,
|
queue: :media_collection_indexing,
|
||||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||||
tags: ["media_source", "media_collection_indexing"]
|
tags: ["media_source", "media_collection_indexing", "show_in_dashboard"]
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ defmodule Pinchflat.Sources do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
alias Pinchflat.Repo
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
alias Pinchflat.YtDlp.MediaCollection
|
alias Pinchflat.YtDlp.MediaCollection
|
||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
@@ -127,7 +127,7 @@ defmodule Pinchflat.Sources do
|
|||||||
Tasks.delete_tasks_for(source)
|
Tasks.delete_tasks_for(source)
|
||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> where(^MediaQuery.for_source(source))
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Enum.each(fn media_item ->
|
|> Enum.each(fn media_item ->
|
||||||
Media.delete_media_item(media_item, delete_files: delete_files)
|
Media.delete_media_item(media_item, delete_files: delete_files)
|
||||||
|
|||||||
@@ -12,20 +12,26 @@ defmodule Pinchflat.Sources.SourcesQuery do
|
|||||||
|
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
|
|
||||||
# Prefixes:
|
# This allows the module to be aliased and query methods to be used
|
||||||
# - for_* - belonging to a certain record
|
# all in one go
|
||||||
# - join_* - for joining on a certain record
|
# usage: use Pinchflat.Sources.SourcesQuery
|
||||||
# - with_* - for filtering based on full, concrete attributes
|
defmacro __using__(_opts) do
|
||||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
quote do
|
||||||
#
|
import Ecto.Query, warn: false
|
||||||
# Suffixes:
|
|
||||||
# - _for - the arg passed is an association record
|
alias unquote(__MODULE__)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def new do
|
def new do
|
||||||
Source
|
Source
|
||||||
end
|
end
|
||||||
|
|
||||||
def for_media_profile(query, media_profile) do
|
def for_media_profile(media_profile_id) when is_integer(media_profile_id) do
|
||||||
where(query, [s], s.media_profile_id == ^media_profile.id)
|
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
|
||||||
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
|
||||||
@@ -24,11 +24,15 @@ defmodule Pinchflat.Utils.CliUtils do
|
|||||||
def wrap_cmd(command, args, passthrough_opts \\ [], opts \\ []) do
|
def wrap_cmd(command, args, passthrough_opts \\ [], opts \\ []) do
|
||||||
wrapper_command = Path.join(:code.priv_dir(:pinchflat), "cmd_wrapper.sh")
|
wrapper_command = Path.join(:code.priv_dir(:pinchflat), "cmd_wrapper.sh")
|
||||||
actual_command = [command] ++ args
|
actual_command = [command] ++ args
|
||||||
|
command_opts = set_command_opts() ++ passthrough_opts
|
||||||
logging_arg_override = Keyword.get(opts, :logging_arg_override, Enum.join(args, " "))
|
logging_arg_override = Keyword.get(opts, :logging_arg_override, Enum.join(args, " "))
|
||||||
|
|
||||||
Logger.info("[command_wrapper]: #{command} called with: #{logging_arg_override}")
|
Logger.info("[command_wrapper]: #{command} called with: #{logging_arg_override}")
|
||||||
|
|
||||||
System.cmd(wrapper_command, actual_command, passthrough_opts)
|
{output, status} = System.cmd(wrapper_command, actual_command, command_opts)
|
||||||
|
log_cmd_result(command, logging_arg_override, status, output)
|
||||||
|
|
||||||
|
{output, status}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -71,4 +75,20 @@ defmodule Pinchflat.Utils.CliUtils do
|
|||||||
defp parse_option(arg, acc) when is_binary(arg) do
|
defp parse_option(arg, acc) when is_binary(arg) do
|
||||||
acc ++ [arg]
|
acc ++ [arg]
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
defmodule Pinchflat.Utils.DatetimeUtils do
|
|
||||||
@moduledoc """
|
|
||||||
Utility methods for working with dates and datetimes
|
|
||||||
"""
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Converts a Date to a DateTime
|
|
||||||
|
|
||||||
Returns %DateTime{}
|
|
||||||
"""
|
|
||||||
def date_to_datetime(date) do
|
|
||||||
date
|
|
||||||
|> Date.to_gregorian_days()
|
|
||||||
|> Kernel.*(86_400)
|
|
||||||
|> DateTime.from_gregorian_seconds()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -13,4 +13,27 @@ defmodule Pinchflat.Utils.NumberUtils do
|
|||||||
|> max(minimum)
|
|> max(minimum)
|
||||||
|> min(maximum)
|
|> min(maximum)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
|
|
||||||
output_filepath = generate_output_filepath(addl_opts)
|
output_filepath = generate_output_filepath(addl_opts)
|
||||||
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
|
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
|
||||||
user_configured_opts = cookie_file_options() ++ global_options()
|
user_configured_opts = cookie_file_options()
|
||||||
# These must stay in exactly this order, hence why I'm giving it its own variable.
|
# These must stay in exactly this order, hence why I'm giving it its own variable.
|
||||||
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts
|
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
|
||||||
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
||||||
|
|
||||||
case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do
|
case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do
|
||||||
@@ -71,7 +71,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp global_options do
|
defp global_options do
|
||||||
[:windows_filenames]
|
[
|
||||||
|
:windows_filenames,
|
||||||
|
:quiet,
|
||||||
|
cache_dir: Path.join(Application.get_env(:pinchflat, :tmpfile_directory), "yt-dlp-cache")
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp cookie_file_options do
|
defp cookie_file_options do
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:original_url,
|
:original_url,
|
||||||
:livestream,
|
:livestream,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:upload_date,
|
:uploaded_at,
|
||||||
:duration_seconds
|
:duration_seconds
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:original_url,
|
:original_url,
|
||||||
:livestream,
|
:livestream,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:upload_date,
|
:uploaded_at,
|
||||||
:duration_seconds
|
:duration_seconds
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
Returns the output template for yt-dlp's indexing command.
|
Returns the output template for yt-dlp's indexing command.
|
||||||
"""
|
"""
|
||||||
def indexing_output_template do
|
def indexing_output_template do
|
||||||
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date})j"
|
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp})j"
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -90,7 +90,7 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
livestream: !!response["was_live"],
|
livestream: !!response["was_live"],
|
||||||
duration_seconds: response["duration"] && round(response["duration"]),
|
duration_seconds: response["duration"] && round(response["duration"]),
|
||||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
short_form_content: response["webpage_url"] && short_form_content?(response),
|
||||||
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
uploaded_at: response["upload_date"] && parse_uploaded_at(response)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -110,6 +110,18 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp parse_uploaded_at(%{"timestamp" => ts} = response) when is_number(ts) do
|
||||||
|
case DateTime.from_unix(ts) do
|
||||||
|
{:ok, datetime} -> datetime
|
||||||
|
_ -> MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# This field is needed before inserting into the database, but absence
|
||||||
|
# of this field should fail at insert-time rather than here
|
||||||
|
defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
|
||||||
|
defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||||
|
|
||||||
defp backend_runner do
|
defp backend_runner do
|
||||||
# This approach lets us mock the command for testing
|
# This approach lets us mock the command for testing
|
||||||
Application.get_env(:pinchflat, :yt_dlp_runner)
|
Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
||||||
# the first video has not released yet (ie: is a premier). We don't care about
|
# the first video has not released yet (ie: is a premier). We don't care about
|
||||||
# available formats since we're just getting the media details
|
# 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_template = YtDlpMedia.indexing_output_template()
|
||||||
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
||||||
|
|||||||
@@ -6,25 +6,32 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
Takes a list of tabs and renders them in a tabbed layout.
|
Takes a list of tabs and renders them in a tabbed layout.
|
||||||
"""
|
"""
|
||||||
slot :tab, required: true do
|
slot :tab, required: true do
|
||||||
|
attr :id, :string, required: true
|
||||||
attr :title, :string, required: true
|
attr :title, :string, required: true
|
||||||
end
|
end
|
||||||
|
|
||||||
slot :tab_append, required: false
|
slot :tab_append, required: false
|
||||||
|
|
||||||
def tabbed_layout(assigns) do
|
def tabbed_layout(assigns) do
|
||||||
|
assigns = Map.put(assigns, :first_tab_id, hd(assigns.tab).id)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
x-data="{ openTab: getTabIndex(0), activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
x-data={"{
|
||||||
@hashchange.window="openTab = getTabIndex(openTab)"
|
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"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
||||||
<div class="flex flex-wrap gap-5 sm:gap-10">
|
<div class="flex flex-wrap gap-5 sm:gap-10">
|
||||||
<a
|
<a
|
||||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
:for={tab <- @tab}
|
||||||
href="#"
|
href="#"
|
||||||
@click.prevent={"openTab = setTabIndex(#{idx})"}
|
@click.prevent={"openTab = setTabByName('#{tab.id}')"}
|
||||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
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"
|
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>
|
<span class="text-xl"><%= tab.title %></span>
|
||||||
@@ -35,7 +42,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="mt-4 min-h-60">
|
<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) %>
|
<%= render_slot(tab) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
|
|
||||||
alias PinchflatWeb.CoreComponents
|
import PinchflatWeb.CoreComponents
|
||||||
|
import PinchflatWeb.CustomComponents.TextComponents
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a table component with the given rows and columns.
|
Renders a table component with the given rows and columns.
|
||||||
@@ -27,21 +28,20 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
|
|
||||||
def table(assigns) do
|
def table(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<table class={["w-full table-auto", @table_class]}>
|
<table class={["w-full table-auto bg-boxdark", @table_class]}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-2 text-left dark:bg-meta-4">
|
<tr class="text-left bg-meta-4">
|
||||||
<th :for={col <- @col} class="px-4 py-4 font-medium text-black dark:text-white xl:pl-11">
|
<th :for={col <- @col} class="px-4 py-4 font-medium text-white xl:pl-11">
|
||||||
<%= col[:label] %>
|
<%= col[:label] %>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr :for={{row, i} <- Enum.with_index(@rows)}>
|
<tr :for={row <- @rows} class="hover:bg-strokedark">
|
||||||
<td
|
<td
|
||||||
:for={col <- @col}
|
:for={col <- @col}
|
||||||
class={[
|
class={[
|
||||||
"px-4 py-5 pl-9 dark:border-strokedark xl:pl-11",
|
"px-4 py-5 pl-9 xl:pl-11",
|
||||||
i + 1 > length(@rows) && "border-b border-[#eee] dark:border-π",
|
|
||||||
col[:class]
|
col[:class]
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -77,11 +77,13 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
phx-click={@page_number != 1 && "page_change"}
|
phx-click={@page_number != 1 && "page_change"}
|
||||||
phx-value-direction="dec"
|
phx-value-direction="dec"
|
||||||
>
|
>
|
||||||
<CoreComponents.icon name="hero-chevron-left" />
|
<.icon name="hero-chevron-left" />
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="mx-2">Page <%= @page_number %> of <%= @total_pages %></span>
|
<span class="mx-2">
|
||||||
|
Page <.localized_number number={@page_number} /> of <.localized_number number={@total_pages} />
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span
|
<span
|
||||||
@@ -93,7 +95,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
phx-click={@page_number != @total_pages && "page_change"}
|
phx-click={@page_number != @total_pages && "page_change"}
|
||||||
phx-value-direction="inc"
|
phx-value-direction="inc"
|
||||||
>
|
>
|
||||||
<CoreComponents.icon name="hero-chevron-right" />
|
<.icon name="hero-chevron-right" />
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -94,4 +94,28 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
|
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a localized number using the Intl.NumberFormat API, falling back to the raw number if needed
|
||||||
|
"""
|
||||||
|
attr :number, :any, required: true
|
||||||
|
|
||||||
|
def localized_number(assigns) do
|
||||||
|
~H"""
|
||||||
|
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}><%= @number %></span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a word with a suffix if the count is not 1
|
||||||
|
"""
|
||||||
|
attr :word, :string, required: true
|
||||||
|
attr :count, :integer, required: true
|
||||||
|
attr :suffix, :string, default: "s"
|
||||||
|
|
||||||
|
def pluralize(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= @word %><%= if @count == 1, do: "", else: @suffix %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,28 +4,107 @@ defmodule PinchflatWeb.Layouts do
|
|||||||
embed_templates "layouts/*"
|
embed_templates "layouts/*"
|
||||||
embed_templates "layouts/partials/*"
|
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 :icon, :string, required: true
|
||||||
attr :text, :string, required: true
|
attr :text, :string, required: true
|
||||||
attr :href, :any, required: true
|
attr :href, :any, required: true
|
||||||
attr :target, :any, default: "_self"
|
attr :target, :any, default: "_self"
|
||||||
|
|
||||||
def sidebar_item(assigns) do
|
def sidebar_item(assigns) do
|
||||||
# I'm testing out grouping classes here. Tentative order: font, layout, color, animation, state-modifiers
|
|
||||||
~H"""
|
~H"""
|
||||||
<li>
|
<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
|
<.link
|
||||||
href={@href}
|
href={@href}
|
||||||
target={@target}
|
target={@target}
|
||||||
class={[
|
class={[
|
||||||
"font-medium text-bodydark1",
|
"font-medium",
|
||||||
"group relative flex items-center gap-2.5 rounded-sm px-4 py-2 duration-300 ease-in-out",
|
"group relative flex items-center gap-2.5 rounded-sm px-4 py-2 duration-300 ease-in-out",
|
||||||
"duration-300 ease-in-out",
|
"duration-300 ease-in-out",
|
||||||
"hover:bg-graydark dark:hover:bg-meta-4"
|
"hover:bg-meta-4",
|
||||||
|
@class
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<.icon name={@icon} /> <%= @text %>
|
<.icon :if={@icon} name={@icon} /> <%= @text %>
|
||||||
</.link>
|
</.link>
|
||||||
</li>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="flex h-screen overflow-hidden">
|
<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">
|
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
|
||||||
<.header params={@conn.params} />
|
<.header params={@conn.params} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<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 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">
|
<div class="flex items-center gap-2 sm:gap-4 lg:hidden w-2/6">
|
||||||
<section class="pr-1">
|
<section class="pr-1">
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="q"
|
name="q"
|
||||||
value={@params["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"
|
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -26,7 +26,14 @@
|
|||||||
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
|
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
|
||||||
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
|
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
|
||||||
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
|
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
|
||||||
<.sidebar_item icon="hero-cog-6-tooth" text="Settings" href={~p"/settings"} />
|
<.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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -60,7 +67,7 @@
|
|||||||
class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm"
|
class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm"
|
||||||
x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
|
x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
|
||||||
>
|
>
|
||||||
<span>Pinchflat v<%= Application.spec(:pinchflat)[:vsn] %></span>
|
<span>Pinchflat <%= Application.spec(:pinchflat)[:vsn] %></span>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/kieraneglin/pinchflat/releases"
|
href="https://github.com/kieraneglin/pinchflat/releases"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<p class="mt-4">
|
<p class="mt-4">
|
||||||
To unlock Pro, simply type
|
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.
|
into the text box and press the button.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
onboarding: #{Settings.get!(:onboarding)}
|
onboarding: #{Settings.get!(:onboarding)}
|
||||||
}"}
|
}"}
|
||||||
class="dark text-bodydark bg-boxdark-2"
|
class="dark text-bodydark bg-boxdark-2"
|
||||||
|
data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")}
|
||||||
>
|
>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||||||
|> put_resp_header("accept-ranges", "bytes")
|
|> put_resp_header("accept-ranges", "bytes")
|
||||||
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|
||||||
|> put_resp_header("content-length", to_string(length))
|
|> 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)
|
|> send_file(206, media_item.media_filepath, start_pos, length)
|
||||||
|
|
||||||
{:error, :invalid_range} ->
|
{:error, :invalid_range} ->
|
||||||
@@ -92,8 +93,10 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type(mime_type)
|
|> put_resp_content_type(mime_type)
|
||||||
|> put_resp_header("content-length", to_string(file_size))
|
|
||||||
|> put_resp_header("accept-ranges", "bytes")
|
|> 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)
|
|> send_file(200, media_item.media_filepath)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
|
|||||||
def media_type(media_item) do
|
def media_type(media_item) do
|
||||||
case Path.extname(media_item.media_filepath) do
|
case Path.extname(media_item.media_filepath) do
|
||||||
ext when ext in [".mp4", ".webm", ".mkv"] -> :video
|
ext when ext in [".mp4", ".webm", ".mkv"] -> :video
|
||||||
ext when ext in [".mp3", ".m4a"] -> :audio
|
ext when ext in [".mp3", ".m4a", ".opus"] -> :audio
|
||||||
_ -> :unknown
|
_ -> :unknown
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<.actions_dropdown media_item={@media_item} />
|
<.actions_dropdown media_item={@media_item} />
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Media">
|
<:tab title="Media" id="media">
|
||||||
<div class="flex flex-col gap-10 dark:text-white">
|
<div class="flex flex-col gap-10 dark:text-white">
|
||||||
<%= if media_file_exists?(@media_item) do %>
|
<%= if media_file_exists?(@media_item) do %>
|
||||||
<section class="grid grid-cols-1 xl:grid-cols-2 xl:gap-6 mt-6">
|
<section class="grid grid-cols-1 xl:grid-cols-2 xl:gap-6 mt-6">
|
||||||
@@ -31,11 +31,19 @@
|
|||||||
<.media_preview media_item={@media_item} />
|
<.media_preview media_item={@media_item} />
|
||||||
</div>
|
</div>
|
||||||
<aside class="mt-4 xl:mt-0">
|
<aside class="mt-4 xl:mt-0">
|
||||||
<div>Uploaded: <%= @media_item.upload_date %></div>
|
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
|
||||||
<div :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
<div>
|
||||||
|
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
||||||
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
|
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
|
||||||
|
<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>
|
||||||
<div class="mt-4 text-bodydark">
|
<div :if={@media_item.description} class="mt-4 text-bodydark">
|
||||||
<.break_on_newline text={@media_item.description} />
|
<.break_on_newline text={@media_item.description} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -52,7 +60,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Tasks">
|
<:tab title="Tasks" id="tasks">
|
||||||
<%= if match?([_|_], @media_item.tasks) do %>
|
<%= if match?([_|_], @media_item.tasks) do %>
|
||||||
<.table rows={@media_item.tasks} table_class="text-black dark:text-white">
|
<.table rows={@media_item.tasks} table_class="text-black dark:text-white">
|
||||||
<:col :let={task} label="Worker">
|
<:col :let={task} label="Worker">
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
use Pinchflat.Sources.SourcesQuery
|
||||||
import Ecto.Query, warn: false
|
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.Profiles
|
||||||
alias Pinchflat.Sources.SourcesQuery
|
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
@@ -43,7 +41,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
|||||||
|
|
||||||
sources =
|
sources =
|
||||||
SourcesQuery.new()
|
SourcesQuery.new()
|
||||||
|> SourcesQuery.for_media_profile(media_profile)
|
|> where(^SourcesQuery.for_media_profile(media_profile))
|
||||||
|> order_by(asc: :custom_name)
|
|> order_by(asc: :custom_name)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
|
|
||||||
def friendly_resolution_options do
|
def friendly_resolution_options do
|
||||||
[
|
[
|
||||||
|
{"8k", "4320p"},
|
||||||
{"4k", "2160p"},
|
{"4k", "2160p"},
|
||||||
{"1080p", "1080p"},
|
{"1080p", "1080p"},
|
||||||
{"720p", "720p"},
|
{"720p", "720p"},
|
||||||
|
|||||||
@@ -23,9 +23,8 @@
|
|||||||
<:col :let={media_profile} label="Preferred Resolution">
|
<:col :let={media_profile} label="Preferred Resolution">
|
||||||
<%= media_profile.preferred_resolution %>
|
<%= media_profile.preferred_resolution %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_profile} label="" class="flex place-content-evenly">
|
<:col :let={media_profile} label="" class="flex justify-end">
|
||||||
<.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="mr-4" />
|
||||||
<.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -183,7 +183,7 @@
|
|||||||
options={friendly_format_type_options()}
|
options={friendly_format_type_options()}
|
||||||
type="select"
|
type="select"
|
||||||
label="Include Livestreams"
|
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]))"
|
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -23,13 +23,13 @@
|
|||||||
<.actions_dropdown media_profile={@media_profile} />
|
<.actions_dropdown media_profile={@media_profile} />
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Media Profile">
|
<:tab title="Media Profile" id="media-profile">
|
||||||
<div class="flex flex-col gap-10 text-white">
|
<div class="flex flex-col gap-10 text-white">
|
||||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||||
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
||||||
</div>
|
</div>
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Sources">
|
<:tab title="Sources" id="sources">
|
||||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||||
<:col :let={source} label="Name">
|
<:col :let={source} label="Name">
|
||||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||||
@@ -40,9 +40,8 @@
|
|||||||
<:col :let={source} label="Should Download?">
|
<:col :let={source} label="Should Download?">
|
||||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={source} label="" class="flex place-content-evenly">
|
<:col :let={source} label="" class="flex justify-end">
|
||||||
<.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="mr-4" />
|
||||||
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
</:tab>
|
</:tab>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
defmodule PinchflatWeb.Pages.PageController do
|
defmodule PinchflatWeb.Pages.PageController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
|
||||||
def home(conn, params) do
|
def home(conn, params) do
|
||||||
@@ -20,14 +20,14 @@ defmodule PinchflatWeb.Pages.PageController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp render_home_page(conn) do
|
defp render_home_page(conn) do
|
||||||
|
downloaded_media_items = where(MediaQuery.new(), ^MediaQuery.downloaded())
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> render(:home,
|
|> render(:home,
|
||||||
media_profile_count: Repo.aggregate(MediaProfile, :count, :id),
|
media_profile_count: Repo.aggregate(MediaProfile, :count, :id),
|
||||||
source_count: Repo.aggregate(Source, :count, :id),
|
source_count: Repo.aggregate(Source, :count, :id),
|
||||||
media_item_count:
|
media_item_size: Repo.aggregate(downloaded_media_items, :sum, :media_size_bytes),
|
||||||
MediaQuery.new()
|
media_item_count: Repo.aggregate(downloaded_media_items, :count, :id)
|
||||||
|> MediaQuery.with_media_downloaded_at()
|
|
||||||
|> Repo.aggregate(:count, :id)
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
defmodule PinchflatWeb.Pages.PageHTML do
|
defmodule PinchflatWeb.Pages.PageHTML do
|
||||||
use PinchflatWeb, :html
|
use PinchflatWeb, :html
|
||||||
|
|
||||||
|
alias Pinchflat.Utils.NumberUtils
|
||||||
|
|
||||||
embed_templates "page_html/*"
|
embed_templates "page_html/*"
|
||||||
|
|
||||||
|
attr :media_filesize, :integer, required: true
|
||||||
|
|
||||||
|
def readable_media_filesize(assigns) do
|
||||||
|
{num, suffix} = NumberUtils.human_byte_size(assigns.media_filesize, precision: 2)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
Map.merge(assigns, %{
|
||||||
|
num: num,
|
||||||
|
suffix: suffix
|
||||||
|
})
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<.localized_number number={@num} /> <%= @suffix %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
defmodule Pinchflat.Pages.HistoryTableLive do
|
defmodule Pinchflat.Pages.HistoryTableLive do
|
||||||
use PinchflatWeb, :live_view
|
use PinchflatWeb, :live_view
|
||||||
import Ecto.Query, warn: false
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.Utils.NumberUtils
|
alias Pinchflat.Utils.NumberUtils
|
||||||
alias PinchflatWeb.CustomComponents.TextComponents
|
alias PinchflatWeb.CustomComponents.TextComponents
|
||||||
|
|
||||||
@limit 10
|
@limit 5
|
||||||
|
|
||||||
def render(%{records: []} = assigns) do
|
def render(%{records: []} = assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@@ -23,7 +22,9 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
|||||||
<div>
|
<div>
|
||||||
<span class="mb-4 flex items-center">
|
<span class="mb-4 flex items-center">
|
||||||
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
|
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
|
||||||
<span class="ml-2">Showing <%= length(@records) %> of <%= @total_record_count %></span>
|
<span class="ml-2">
|
||||||
|
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@total_record_count} />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.table rows={@records} table_class="text-white">
|
<.table rows={@records} table_class="text-white">
|
||||||
@@ -33,7 +34,7 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
|||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Upload Date">
|
<:col :let={media_item} label="Upload Date">
|
||||||
<%= media_item.upload_date %>
|
<%= DateTime.to_date(media_item.uploaded_at) %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Indexed At">
|
<:col :let={media_item} label="Indexed At">
|
||||||
<%= format_datetime(media_item.inserted_at) %>
|
<%= format_datetime(media_item.inserted_at) %>
|
||||||
@@ -98,7 +99,8 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
|||||||
|
|
||||||
defp generate_base_query do
|
defp generate_base_query do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.where_pending_or_downloaded()
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|
|> where(^dynamic(^MediaQuery.downloaded() or ^MediaQuery.pending()))
|
||||||
|> order_by(desc: :id)
|
|> order_by(desc: :id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,54 @@
|
|||||||
<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">
|
<div class="flex flex-col justify-center 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">
|
<a href={~p"/media_profiles"} class="flex flex-col items-center py-2">
|
||||||
<span class="text-md font-medium">Media Profile(s)</span>
|
<span class="text-md font-medium">
|
||||||
|
Media <.pluralize count={@media_profile_count} word="Profile" />
|
||||||
|
</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @media_profile_count %>
|
<.localized_number number={@media_profile_count} />
|
||||||
</h4>
|
</h4>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
<a href={~p"/sources"} class="mt-4 flex flex-col items-center justify-center">
|
<a href={~p"/sources"} class="flex flex-col items-center py-2">
|
||||||
<span class="text-md font-medium">Source(s)</span>
|
<span class="text-md font-medium">
|
||||||
|
<.pluralize count={@source_count} word="Source" />
|
||||||
|
</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @source_count %>
|
<.localized_number number={@source_count} />
|
||||||
</h4>
|
</h4>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
<div class="flex flex-col justify-center 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="flex flex-col items-center py-2">
|
||||||
<span class="text-md font-medium">Downloaded Media</span>
|
<span class="text-md font-medium">
|
||||||
|
<.pluralize count={@media_item_count} word="Download" />
|
||||||
|
</span>
|
||||||
<h4 class="text-title-md font-bold text-white">
|
<h4 class="text-title-md font-bold text-white">
|
||||||
<%= @media_item_count %>
|
<.localized_number number={@media_item_count} />
|
||||||
|
</h4>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
|
||||||
|
<span class="flex flex-col items-center py-2">
|
||||||
|
<span class="text-md font-medium">Library Size</span>
|
||||||
|
<h4 class="text-title-md font-bold text-white">
|
||||||
|
<.readable_media_filesize media_filesize={@media_item_size} />
|
||||||
</h4>
|
</h4>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
|
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
|
||||||
<span class="text-2xl font-medium mb-4">History</span>
|
<span class="text-2xl font-medium mb-4">Media History</span>
|
||||||
<section class="mt-6">
|
<section class="mt-6">
|
||||||
<%= live_render(@conn, Pinchflat.Pages.HistoryTableLive) %>
|
<%= live_render(@conn, Pinchflat.Pages.HistoryTableLive) %>
|
||||||
</section>
|
</section>
|
||||||
</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">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,17 +1,17 @@
|
|||||||
defmodule PinchflatWeb.Podcasts.PodcastController do
|
defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.Podcasts.RssFeedBuilder
|
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
|
|
||||||
def rss_feed(conn, %{"uuid" => uuid}) do
|
def rss_feed(conn, %{"uuid" => uuid}) do
|
||||||
source = Repo.get_by!(Source, uuid: uuid)
|
source = Repo.get_by!(Source, uuid: uuid)
|
||||||
url_base = url(conn, ~p"/")
|
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
|
conn
|
||||||
|> put_resp_content_type("application/rss+xml")
|
|> put_resp_content_type("application/rss+xml")
|
||||||
@@ -26,8 +26,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
|||||||
# if the source doesn't have any usable images
|
# if the source doesn't have any usable images
|
||||||
media_items =
|
media_items =
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||||
|> MediaQuery.with_media_filepath()
|
|
||||||
|> Repo.maybe_limit(1)
|
|> Repo.maybe_limit(1)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,13 @@
|
|||||||
<%= if match?([_|_], @search_results) do %>
|
<%= if match?([_|_], @search_results) do %>
|
||||||
<.table rows={@search_results} table_class="text-black dark:text-white">
|
<.table rows={@search_results} table_class="text-black dark:text-white">
|
||||||
<:col :let={result} label="Title">
|
<: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>
|
||||||
<:col :let={result} label="Excerpt">
|
<:col :let={result} label="Excerpt">
|
||||||
<.highlight_search_terms text={result.matching_search_term} />
|
<.highlight_search_terms text={result.matching_search_term} />
|
||||||
</:col>
|
</: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>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="font-bold text-lg text-center text-black dark:text-white">No results found</p>
|
<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)
|
render(conn, "show.html", changeset: changeset)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule PinchflatWeb.Settings.SettingHTML do
|
defmodule PinchflatWeb.Settings.SettingHTML do
|
||||||
use PinchflatWeb, :html
|
use PinchflatWeb, :html
|
||||||
|
|
||||||
|
alias Pinchflat.Downloading.CodecParser
|
||||||
|
|
||||||
embed_templates "setting_html/*"
|
embed_templates "setting_html/*"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -18,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)
|
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{classes}" target="_blank">Apprise docs</a> for more information)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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
|
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,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,13 +1,24 @@
|
|||||||
<.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}>
|
<.error :if={@changeset.action}>
|
||||||
Oops, something went wrong! Please check the errors below.
|
Oops, something went wrong! Please check the errors below.
|
||||||
</.error>
|
</.error>
|
||||||
|
|
||||||
<h3 class="mt-2 md:mt-8 text-2xl text-black dark:text-white">
|
<section>
|
||||||
|
<section class="flex justify-between items-center mt-4">
|
||||||
|
<h3 class="text-2xl text-black dark:text-white">
|
||||||
Notification Settings
|
Notification Settings
|
||||||
</h3>
|
</h3>
|
||||||
|
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||||
|
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<%= live_render(
|
<%= live_render(
|
||||||
@conn,
|
@conn,
|
||||||
Pinchflat.Settings.AppriseServerLive,
|
Pinchflat.Settings.AppriseServerLive,
|
||||||
@@ -15,5 +26,43 @@
|
|||||||
) %>
|
) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<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>
|
<.button class="mt-10 mb-4 sm:mb-8 w-full sm:w-auto" rounding="rounded-lg">Save Settings</.button>
|
||||||
</.simple_form>
|
</.simple_form>
|
||||||
|
|||||||
@@ -1,25 +1,44 @@
|
|||||||
defmodule PinchflatWeb.Sources.SourceController do
|
defmodule PinchflatWeb.Sources.SourceController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
import Ecto.Query, warn: false
|
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
sources =
|
source_query =
|
||||||
Source
|
from s in Source,
|
||||||
|> order_by(asc: :custom_name)
|
as: :source,
|
||||||
|> Repo.all()
|
inner_join: mp in assoc(s, :media_profile),
|
||||||
|> Repo.preload(: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
|
end
|
||||||
|
|
||||||
def new(conn, _params) do
|
def new(conn, _params) do
|
||||||
@@ -104,7 +123,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
|> redirect(to: ~p"/sources")
|
|> redirect(to: ~p"/sources")
|
||||||
end
|
end
|
||||||
|
|
||||||
def force_download(conn, %{"source_id" => id}) do
|
def force_download_pending(conn, %{"source_id" => id}) do
|
||||||
wrap_forced_action(
|
wrap_forced_action(
|
||||||
conn,
|
conn,
|
||||||
id,
|
id,
|
||||||
@@ -113,6 +132,15 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
)
|
)
|
||||||
end
|
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
|
def force_index(conn, %{"source_id" => id}) do
|
||||||
wrap_forced_action(
|
wrap_forced_action(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -31,11 +31,20 @@
|
|||||||
</:option>
|
</:option>
|
||||||
<:option :if={@source.download_media}>
|
<:option :if={@source.download_media}>
|
||||||
<.link
|
<.link
|
||||||
href={~p"/sources/#{@source}/force_download"}
|
href={~p"/sources/#{@source}/force_download_pending"}
|
||||||
method="post"
|
method="post"
|
||||||
data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed."
|
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>
|
</.link>
|
||||||
</:option>
|
</:option>
|
||||||
<:option>
|
<:option>
|
||||||
|
|||||||
@@ -15,16 +15,16 @@
|
|||||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||||
<:col :let={source} label="Name">
|
<:col :let={source} label="Name">
|
||||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||||
<%= source.custom_name || source.collection_name %>
|
<%= StringUtils.truncate(source.custom_name || source.collection_name, 35) %>
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||||
<:col :let={source} label="Should Download?">
|
<:col :let={source} label="Pending"><.localized_number number={source.pending_count} /></:col>
|
||||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
<:col :let={source} label="Downloaded"><.localized_number number={source.downloaded_count} /></:col>
|
||||||
</:col>
|
|
||||||
<:col :let={source} label="Retention">
|
<:col :let={source} label="Retention">
|
||||||
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
||||||
<%= source.retention_period_days %> day(s)
|
<.localized_number number={source.retention_period_days} />
|
||||||
|
<.pluralize count={source.retention_period_days} word="day" />
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-lg">∞</span>
|
<span class="text-lg">∞</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={source} label="" class="flex place-content-evenly">
|
<: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" />
|
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
defmodule Pinchflat.Sources.MediaItemTableLive do
|
defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||||
use PinchflatWeb, :live_view
|
use PinchflatWeb, :live_view
|
||||||
import Ecto.Query, warn: false
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Media.MediaQuery
|
|
||||||
alias Pinchflat.Utils.NumberUtils
|
alias Pinchflat.Utils.NumberUtils
|
||||||
|
|
||||||
@limit 10
|
@limit 10
|
||||||
|
|
||||||
def render(%{records: []} = assigns) do
|
def render(%{total_record_count: 0} = assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="mb-4 flex items-center">
|
<div class="mb-4 flex items-center">
|
||||||
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" />
|
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" />
|
||||||
@@ -21,19 +20,45 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
|||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div>
|
<div>
|
||||||
<span class="mb-4 flex items-center">
|
<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" />
|
<.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 class="ml-2">
|
||||||
|
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@filtered_record_count} />
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
|
<div class="bg-meta-4 rounded-md">
|
||||||
|
<div class="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">
|
<.table rows={@records} table_class="text-white">
|
||||||
<:col :let={media_item} label="Title">
|
<:col :let={media_item} label="Title">
|
||||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
|
||||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
|
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
|
||||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
</:col>
|
||||||
|
<:col :let={media_item} label="Upload Date">
|
||||||
|
<%= DateTime.to_date(media_item.uploaded_at) %>
|
||||||
|
</:col>
|
||||||
|
<:col :let={media_item} label="" class="flex justify-end">
|
||||||
|
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<section class="flex justify-center mt-5">
|
<section class="flex justify-center mt-5">
|
||||||
@@ -44,36 +69,99 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mount(_params, session, socket) do
|
def mount(_params, session, socket) do
|
||||||
|
PinchflatWeb.Endpoint.subscribe("media_table")
|
||||||
|
|
||||||
page = 1
|
page = 1
|
||||||
media_state = session["media_state"]
|
media_state = session["media_state"]
|
||||||
source = Sources.get_source!(session["source_id"])
|
source = Sources.get_source!(session["source_id"])
|
||||||
base_query = generate_base_query(source, media_state)
|
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
|
end
|
||||||
|
|
||||||
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
|
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
|
||||||
direction = if direction == "inc", do: 1, else: -1
|
direction = if direction == "inc", do: 1, else: -1
|
||||||
new_page = assigns.page + direction
|
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)}
|
{:noreply, assign(socket, new_assigns)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("reload_page", _params, %{assigns: assigns} = socket) do
|
def handle_event("search_term", params, socket) do
|
||||||
new_assigns = fetch_pagination_attributes(assigns.base_query, assigns.page)
|
search_term = Map.get(params, "q", nil)
|
||||||
|
new_assigns = fetch_pagination_attributes(socket.assigns.base_query, 1, search_term)
|
||||||
|
|
||||||
{:noreply, assign(socket, new_assigns)}
|
{:noreply, assign(socket, new_assigns)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_pagination_attributes(base_query, page) do
|
# 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, ""), do: fetch_pagination_attributes(base_query, page, nil)
|
||||||
|
|
||||||
|
defp fetch_pagination_attributes(base_query, page, nil) do
|
||||||
total_record_count = Repo.aggregate(base_query, :count, :id)
|
total_record_count = Repo.aggregate(base_query, :count, :id)
|
||||||
total_pages = max(ceil(total_record_count / @limit), 1)
|
total_pages = max(ceil(total_record_count / @limit), 1)
|
||||||
page = NumberUtils.clamp(page, 1, total_pages)
|
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}
|
records =
|
||||||
|
fetch_records(base_query, page)
|
||||||
|
|> order_by(desc: :uploaded_at)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
%{
|
||||||
|
page: page,
|
||||||
|
total_pages: total_pages,
|
||||||
|
records: records,
|
||||||
|
search_term: nil,
|
||||||
|
total_record_count: total_record_count,
|
||||||
|
filtered_record_count: total_record_count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_pagination_attributes(base_query, page, search_term) do
|
||||||
|
filtered_base_query = filtered_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)
|
||||||
|
|> order_by(desc: fragment("rank"), desc: :uploaded_at)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
%{
|
||||||
|
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
|
end
|
||||||
|
|
||||||
defp fetch_records(base_query, page) do
|
defp fetch_records(base_query, page) do
|
||||||
@@ -82,20 +170,41 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
|||||||
base_query
|
base_query
|
||||||
|> limit(^@limit)
|
|> limit(^@limit)
|
||||||
|> offset(^offset)
|
|> offset(^offset)
|
||||||
|> Repo.all()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_base_query(source, "pending") do
|
defp generate_base_query(source, "pending") do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> select(^select_fields())
|
||||||
|> MediaQuery.where_pending_download()
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|> order_by(desc: :id)
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_base_query(source, "downloaded") do
|
defp generate_base_query(source, "downloaded") do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> select(^select_fields())
|
||||||
|> MediaQuery.with_media_filepath()
|
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||||
|> order_by(desc: :id)
|
end
|
||||||
|
|
||||||
|
defp generate_base_query(source, "other") do
|
||||||
|
MediaQuery.new()
|
||||||
|
|> select(^select_fields())
|
||||||
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|
|> where(
|
||||||
|
^dynamic(
|
||||||
|
^MediaQuery.for_source(source) and
|
||||||
|
(not (^MediaQuery.downloaded()) and not (^MediaQuery.pending()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filtered_base_query(base_query, search_term) do
|
||||||
|
base_query
|
||||||
|
|> MediaQuery.require_assoc(:media_items_search_index)
|
||||||
|
|> where(^MediaQuery.matches_search_term(search_term))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Selecting only what we need GREATLY speeds up queries on large tables
|
||||||
|
defp select_fields do
|
||||||
|
[:id, :title, :uploaded_at, :prevent_download]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<.actions_dropdown source={@source} conn={@conn} />
|
<.actions_dropdown source={@source} conn={@conn} />
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Source">
|
<:tab title="Source" id="source">
|
||||||
<div class="flex flex-col gap-10 text-white">
|
<div class="flex flex-col gap-10 text-white">
|
||||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
@@ -36,21 +36,28 @@
|
|||||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||||
</div>
|
</div>
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Pending Media">
|
<:tab title="Pending" id="pending">
|
||||||
<%= live_render(
|
<%= live_render(
|
||||||
@conn,
|
@conn,
|
||||||
Pinchflat.Sources.MediaItemTableLive,
|
Pinchflat.Sources.MediaItemTableLive,
|
||||||
session: %{"source_id" => @source.id, "media_state" => "pending"}
|
session: %{"source_id" => @source.id, "media_state" => "pending"}
|
||||||
) %>
|
) %>
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Downloaded Media">
|
<:tab title="Downloaded" id="downloaded">
|
||||||
<%= live_render(
|
<%= live_render(
|
||||||
@conn,
|
@conn,
|
||||||
Pinchflat.Sources.MediaItemTableLive,
|
Pinchflat.Sources.MediaItemTableLive,
|
||||||
session: %{"source_id" => @source.id, "media_state" => "downloaded"}
|
session: %{"source_id" => @source.id, "media_state" => "downloaded"}
|
||||||
) %>
|
) %>
|
||||||
</:tab>
|
</: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 %>
|
<%= if match?([_|_], @pending_tasks) do %>
|
||||||
<.table rows={@pending_tasks} table_class="text-black dark:text-white">
|
<.table rows={@pending_tasks} table_class="text-black dark:text-white">
|
||||||
<:col :let={task} label="Worker">
|
<:col :let={task} label="Worker">
|
||||||
|
|||||||
@@ -95,7 +95,8 @@
|
|||||||
type="number"
|
type="number"
|
||||||
label="Retention Period (days)"
|
label="Retention Period (days)"
|
||||||
min="0"
|
min="0"
|
||||||
help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely"
|
help="Days between when media is <em>downloaded</em> and when it's deleted. Leave blank to keep media indefinitely"
|
||||||
|
html_help={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section x-show="advancedMode">
|
<section x-show="advancedMode">
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ defmodule PinchflatWeb.Router do
|
|||||||
|
|
||||||
resources "/media_profiles", MediaProfiles.MediaProfileController
|
resources "/media_profiles", MediaProfiles.MediaProfileController
|
||||||
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
||||||
|
|
||||||
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
|
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
|
||||||
|
get "/app_info", Settings.SettingController, :app_info
|
||||||
|
get "/download_logs", Settings.SettingController, :download_logs
|
||||||
|
|
||||||
resources "/sources", Sources.SourceController do
|
resources "/sources", Sources.SourceController do
|
||||||
post "/force_download", Sources.SourceController, :force_download
|
post "/force_download_pending", Sources.SourceController, :force_download_pending
|
||||||
|
post "/force_redownload", Sources.SourceController, :force_redownload
|
||||||
post "/force_index", Sources.SourceController, :force_index
|
post "/force_index", Sources.SourceController, :force_index
|
||||||
post "/force_metadata_refresh", Sources.SourceController, :force_metadata_refresh
|
post "/force_metadata_refresh", Sources.SourceController, :force_metadata_refresh
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ defmodule PinchflatWeb.Telemetry do
|
|||||||
Supervisor.init(children, strategy: :one_for_one)
|
Supervisor.init(children, strategy: :one_for_one)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def job_state_change_broadcast(_event, _measure, _meta, _config) do
|
||||||
|
PinchflatWeb.Endpoint.broadcast("job:state", "change", nil)
|
||||||
|
end
|
||||||
|
|
||||||
def metrics do
|
def metrics do
|
||||||
[
|
[
|
||||||
# Phoenix Metrics
|
# Phoenix Metrics
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "0.1.18",
|
version: "2024.5.30",
|
||||||
elixir: "~> 1.16",
|
elixir: "~> 1.16",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
@@ -48,6 +48,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
[
|
[
|
||||||
{:phoenix, "~> 1.7.10"},
|
{:phoenix, "~> 1.7.10"},
|
||||||
{:phoenix_ecto, "~> 4.4"},
|
{:phoenix_ecto, "~> 4.4"},
|
||||||
|
{:ecto, "~> 3.11.2"},
|
||||||
{:ecto_sql, "~> 3.10"},
|
{:ecto_sql, "~> 3.10"},
|
||||||
{:ecto_sqlite3, ">= 0.0.0"},
|
{:ecto_sqlite3, ">= 0.0.0"},
|
||||||
{:ecto_sqlite3_extras, "~> 1.2.0"},
|
{:ecto_sqlite3_extras, "~> 1.2.0"},
|
||||||
@@ -92,7 +93,15 @@ defmodule Pinchflat.MixProject do
|
|||||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||||
"assets.build": ["tailwind default", "esbuild default"],
|
"assets.build": ["tailwind default", "esbuild default"],
|
||||||
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
|
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"],
|
||||||
|
"ecto.migrate": [
|
||||||
|
"ecto.migrate",
|
||||||
|
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
|
||||||
|
],
|
||||||
|
"ecto.rollback": [
|
||||||
|
"ecto.rollback",
|
||||||
|
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
|
||||||
|
]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
|
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
|
||||||
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
||||||
"dns_cluster": {:hex, :dns_cluster, "0.1.2", "3eb5be824c7888dadf9781018e1a5f1d3d1113b333c50bce90fb1b83df1015f2", [:mix], [], "hexpm", "7494272040f847637bbdb01bcdf4b871e82daf09b813e7d3cb3b84f112c6f2f8"},
|
"dns_cluster": {:hex, :dns_cluster, "0.1.2", "3eb5be824c7888dadf9781018e1a5f1d3d1113b333c50bce90fb1b83df1015f2", [:mix], [], "hexpm", "7494272040f847637bbdb01bcdf4b871e82daf09b813e7d3cb3b84f112c6f2f8"},
|
||||||
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
|
"ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"},
|
||||||
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
|
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
|
||||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"},
|
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"},
|
||||||
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
|
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
|
||||||
|
|||||||
+6
-2
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"description": "Prettier is used for linting of all files so this package has to live in the root of the project. Use the other package.json files for dependencies. Also, look into making this global or something to remove the need for this file.",
|
"description": "Prettier is used for linting of all files so this package has to live in the root of the project. Use the other package.json files for dependencies. Also, look into making this global or something to remove the need for this file.",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.2.4"
|
"prettier": "3.2.4",
|
||||||
|
"sqleton": "^2.2.0"
|
||||||
},
|
},
|
||||||
"license": "<See the LICENSE file>"
|
"scripts": {
|
||||||
|
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddDateIndexesToMediaItems do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create(index(:media_items, [:media_downloaded_at]))
|
||||||
|
create(index(:media_items, [:media_redownloaded_at]))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddCodecPreferencesToSettings do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :video_codec_preference, {:array, :string}, default: []
|
||||||
|
add :audio_codec_preference, {:array, :string}, default: []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.ChangeMediaItemsSearchIndexTokenizer do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
# These all need to run as part of separate `execute` blocks. Do NOT ask me why.
|
||||||
|
execute "DROP TRIGGER IF EXISTS media_items_search_index_insert;"
|
||||||
|
execute "DROP TRIGGER IF EXISTS media_items_search_index_update;"
|
||||||
|
execute "DROP TRIGGER IF EXISTS media_items_search_index_delete;"
|
||||||
|
execute "DROP TABLE IF EXISTS media_items_search_index;"
|
||||||
|
|
||||||
|
execute """
|
||||||
|
CREATE VIRTUAL TABLE media_items_search_index USING fts5(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
tokenize=trigram
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute """
|
||||||
|
CREATE TRIGGER media_items_search_index_insert AFTER INSERT ON media_items BEGIN
|
||||||
|
INSERT INTO media_items_search_index(
|
||||||
|
rowid,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
)
|
||||||
|
VALUES(
|
||||||
|
new.id,
|
||||||
|
new.title,
|
||||||
|
new.description
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute """
|
||||||
|
CREATE TRIGGER media_items_search_index_update AFTER UPDATE ON media_items BEGIN
|
||||||
|
UPDATE media_items_search_index SET
|
||||||
|
title = new.title,
|
||||||
|
description = new.description
|
||||||
|
WHERE
|
||||||
|
rowid = old.id;
|
||||||
|
END;
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute """
|
||||||
|
CREATE TRIGGER media_items_search_index_delete AFTER DELETE ON media_items BEGIN
|
||||||
|
DELETE FROM media_items_search_index WHERE rowid = old.id;
|
||||||
|
END;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Fully re-index the media_items table
|
||||||
|
execute """
|
||||||
|
INSERT INTO media_items_search_index(rowid, title, description)
|
||||||
|
SELECT id, title, description FROM media_items;
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute "DROP TABLE media_items_search_index;"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.RenameUploadDateToUploadedAt do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
rename table(:media_items), :upload_date, to: :uploaded_at
|
||||||
|
|
||||||
|
execute """
|
||||||
|
UPDATE media_items
|
||||||
|
SET uploaded_at = uploaded_at || 'T00:00:00'
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
rename table(:media_items), :uploaded_at, to: :upload_date
|
||||||
|
|
||||||
|
execute """
|
||||||
|
UPDATE media_items
|
||||||
|
SET upload_date = DATE(upload_date)
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.ModifyUploadDateIndex do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
drop index("media_items", [:upload_date])
|
||||||
|
create index("media_items", [:uploaded_at])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddIndexesForLargeCollections do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create index(
|
||||||
|
"media_items",
|
||||||
|
[
|
||||||
|
:source_id,
|
||||||
|
:media_filepath,
|
||||||
|
:uploaded_at,
|
||||||
|
:prevent_download,
|
||||||
|
:livestream,
|
||||||
|
:short_form_content,
|
||||||
|
:title
|
||||||
|
],
|
||||||
|
name: "media_items_pending_and_downloaded_index"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -13,6 +13,19 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "ensure_tmpfile_directory" do
|
||||||
|
test "creates the tmpfile directory if it doesn't exist" do
|
||||||
|
tmpfile_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
|
File.rm_rf!(tmpfile_dir)
|
||||||
|
|
||||||
|
refute File.exists?(tmpfile_dir)
|
||||||
|
|
||||||
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
|
assert File.exists?(tmpfile_dir)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "reset_executing_jobs" do
|
describe "reset_executing_jobs" do
|
||||||
test "resets executing jobs" do
|
test "resets executing jobs" do
|
||||||
job = job_fixture()
|
job = job_fixture()
|
||||||
@@ -78,6 +91,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
|||||||
|
|
||||||
describe "apply_default_settings" do
|
describe "apply_default_settings" do
|
||||||
test "sets yt_dlp version" do
|
test "sets yt_dlp version" do
|
||||||
|
File.rm_rf!(Application.get_env(:pinchflat, :tmpfile_directory))
|
||||||
Settings.set(yt_dlp_version: nil)
|
Settings.set(yt_dlp_version: nil)
|
||||||
|
|
||||||
refute Settings.get!(:yt_dlp_version)
|
refute Settings.get!(:yt_dlp_version)
|
||||||
@@ -88,6 +102,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "sets apprise version" do
|
test "sets apprise version" do
|
||||||
|
File.rm_rf!(Application.get_env(:pinchflat, :tmpfile_directory))
|
||||||
Settings.set(apprise_version: nil)
|
Settings.set(apprise_version: nil)
|
||||||
|
|
||||||
refute Settings.get!(:apprise_version)
|
refute Settings.get!(:apprise_version)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
defmodule Pinchflat.Downloading.CodecParserTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
alias Pinchflat.Settings
|
||||||
|
alias Pinchflat.Downloading.CodecParser
|
||||||
|
|
||||||
|
describe "generate_vcodec_string_from_settings/1" do
|
||||||
|
test "returns a default vcodec string when setting isn't set" do
|
||||||
|
Settings.set(video_codec_preference: [])
|
||||||
|
|
||||||
|
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string_from_settings()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "generates a vcodec string" do
|
||||||
|
Settings.set(video_codec_preference: ["av01"])
|
||||||
|
|
||||||
|
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string_from_settings()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "generate_acodec_string_from_settings/1" do
|
||||||
|
test "returns a default acodec string when setting isn't set" do
|
||||||
|
Settings.set(audio_codec_preference: [])
|
||||||
|
|
||||||
|
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string_from_settings()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "generates an acodec string" do
|
||||||
|
Settings.set(audio_codec_preference: ["mp3"])
|
||||||
|
|
||||||
|
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string_from_settings()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "generate_vcodec_string/1" do
|
||||||
|
test "returns a default vcodec string when nil" do
|
||||||
|
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns a default vcodec string when empty" do
|
||||||
|
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string([])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "generates a vcodec string" do
|
||||||
|
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string(["av01"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores options that don't exist" do
|
||||||
|
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string(["av01", "foo"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "generate_acodec_string/1" do
|
||||||
|
test "returns a default acodec string when nil" do
|
||||||
|
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns a default acodec string when empty" do
|
||||||
|
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string([])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "generates an acodec string" do
|
||||||
|
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string(["mp3"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores options that don't exist" do
|
||||||
|
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string(["mp3", "foo"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,6 +6,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
|
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.Profiles
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Utils.FilesystemUtils
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Downloading.DownloadOptionBuilder
|
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||||
|
|
||||||
@@ -65,6 +66,13 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
assert :force_overwrites in res
|
assert :force_overwrites in res
|
||||||
assert {:parse_metadata, "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"} in res
|
assert {:parse_metadata, "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"} in res
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "includes override options if specified", %{media_item: media_item} do
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item, overwrite_behaviour: :no_force_overwrites)
|
||||||
|
|
||||||
|
refute :force_overwrites in res
|
||||||
|
assert :no_force_overwrites in res
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "build/1 when testing subtitle options" do
|
describe "build/1 when testing subtitle options" do
|
||||||
@@ -237,8 +245,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "build/1 when testing quality options" do
|
describe "build/1 when testing quality options" do
|
||||||
test "it includes quality options" do
|
test "includes quality options" do
|
||||||
resolutions = ["360", "480", "720", "1080", "2160"]
|
resolutions = ["360", "480", "720", "1080", "2160", "4320"]
|
||||||
|
|
||||||
Enum.each(resolutions, fn resolution ->
|
Enum.each(resolutions, fn resolution ->
|
||||||
resolution_atom = String.to_existing_atom(resolution <> "p")
|
resolution_atom = String.to_existing_atom(resolution <> "p")
|
||||||
@@ -248,21 +256,35 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
|
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
|
||||||
|
|
||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res
|
assert {:format_sort, "res:#{resolution}"} in res
|
||||||
|
|
||||||
|
assert {:format, "((bestvideo[vcodec~='^avc']/bestvideo)+(bestaudio[acodec~='^mp4a']/bestaudio))/best"} in res
|
||||||
|
|
||||||
assert {:remux_video, "mp4"} in res
|
assert {:remux_video, "mp4"} in res
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it includes quality options for audio only", %{media_item: media_item} do
|
test "includes quality options for audio only", %{media_item: media_item} do
|
||||||
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :audio})
|
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :audio})
|
||||||
|
|
||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
assert :extract_audio in res
|
assert :extract_audio in res
|
||||||
assert {:format, "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio/best[ext=m4a]/best[ext=mp3]/best"} in res
|
assert {:format, "bestaudio[acodec~='^mp4a']/bestaudio/best"} in res
|
||||||
|
|
||||||
refute {:remux_video, "mp4"} in res
|
refute {:remux_video, "mp4"} in res
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "includes custom quality options if specified", %{media_item: media_item} do
|
||||||
|
Settings.set(video_codec_preference: ["av01"])
|
||||||
|
Settings.set(audio_codec_preference: ["aac"])
|
||||||
|
|
||||||
|
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"})
|
||||||
|
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
|
assert {:format, "((bestvideo[vcodec~='^av01']/bestvideo)+(bestaudio[acodec~='^aac']/bestaudio))/best"} in res
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "build/1 when testing sponsorblock options" do
|
describe "build/1 when testing sponsorblock options" do
|
||||||
|
|||||||
@@ -110,4 +110,32 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
|||||||
refute_enqueued(worker: MediaDownloadWorker)
|
refute_enqueued(worker: MediaDownloadWorker)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "kickoff_redownload_for_existing_media/1" do
|
||||||
|
test "enqueues a download job for each downloaded media item" do
|
||||||
|
source = source_fixture()
|
||||||
|
media_item = media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4")
|
||||||
|
|
||||||
|
assert [{:ok, _}] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
|
||||||
|
|
||||||
|
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't enqueue jobs for media that should be ignored" do
|
||||||
|
source = source_fixture()
|
||||||
|
other_source = source_fixture()
|
||||||
|
_not_downloaded = media_item_fixture(source_id: source.id, media_filepath: nil)
|
||||||
|
_other_source = media_item_fixture(source_id: other_source.id, media_filepath: "some/filepath.mp4")
|
||||||
|
|
||||||
|
_download_prevented =
|
||||||
|
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", prevent_download: true)
|
||||||
|
|
||||||
|
_culled =
|
||||||
|
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", culled_at: now())
|
||||||
|
|
||||||
|
assert [] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
|
||||||
|
|
||||||
|
refute_enqueued(worker: MediaDownloadWorker)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
Oban.Testing.with_testing_mode(:inline, fn ->
|
Oban.Testing.with_testing_mode(:inline, fn ->
|
||||||
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id, redownload?: true}))
|
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id, quality_upgrade?: true}))
|
||||||
|
|
||||||
assert job.state == "completed"
|
assert job.state == "completed"
|
||||||
end)
|
end)
|
||||||
@@ -136,15 +136,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "downloads anyway if forced", %{media_item: media_item} do
|
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
|
|
||||||
|
|
||||||
Sources.update_source(media_item.source, %{download_media: false})
|
|
||||||
Media.update_media_item(media_item, %{prevent_download: true})
|
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
|
||||||
end
|
|
||||||
|
|
||||||
test "it saves the file's size to the database", %{media_item: media_item} do
|
test "it saves the file's size to the database", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
metadata = render_parsed_metadata(:media_metadata)
|
metadata = render_parsed_metadata(:media_metadata)
|
||||||
@@ -159,18 +150,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
assert media_item.media_size_bytes > 0
|
assert media_item.media_size_bytes > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
test "saves redownloaded_at if this is for a redownload", %{media_item: media_item} do
|
test "does not set redownloaded_at by default", %{media_item: media_item} do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
|
||||||
{:ok, render_metadata(:media_metadata)}
|
|
||||||
end)
|
|
||||||
|
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id, redownload?: true})
|
|
||||||
media_item = Repo.reload(media_item)
|
|
||||||
|
|
||||||
assert media_item.media_redownloaded_at != nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "doesn't save redownloaded_at if this is not for a redownload", %{media_item: media_item} do
|
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
{:ok, render_metadata(:media_metadata)}
|
{:ok, render_metadata(:media_metadata)}
|
||||||
end)
|
end)
|
||||||
@@ -198,5 +178,62 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
test "does not blow up if the record doesn't exist" do
|
test "does not blow up if the record doesn't exist" do
|
||||||
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sets the no_force_overwrites runner option", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
|
||||||
|
assert :no_force_overwrites in opts
|
||||||
|
refute :force_overwrites in opts
|
||||||
|
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1 when testing forced downloads" do
|
||||||
|
test "ignores 'prevent_download' if forced", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
|
||||||
|
|
||||||
|
Sources.update_source(media_item.source, %{download_media: false})
|
||||||
|
Media.update_media_item(media_item, %{prevent_download: true})
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets force_overwrites runner option", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
|
||||||
|
assert :force_overwrites in opts
|
||||||
|
refute :no_force_overwrites in opts
|
||||||
|
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1 when testing re-downloads" do
|
||||||
|
test "sets redownloaded_at on the media_item", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id, quality_upgrade?: true})
|
||||||
|
media_item = Repo.reload(media_item)
|
||||||
|
|
||||||
|
assert media_item.media_redownloaded_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets force_overwrites runner option", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
|
||||||
|
assert :force_overwrites in opts
|
||||||
|
refute :no_force_overwrites in opts
|
||||||
|
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user